diff --git a/nvx-rs/src/host/artifacts.rs b/nvx-rs/src/host/artifacts.rs deleted file mode 100644 index c8cc59871..000000000 --- a/nvx-rs/src/host/artifacts.rs +++ /dev/null @@ -1,1667 +0,0 @@ -//! Locating the OpenVMM executable and the NVX guest artifacts. -//! -//! [`Artifacts`] names the three files that a VM runs. They come from an extracted NVX release -//! archive, an NVX repository checkout, explicit paths, or a bundle that the build script staged -//! with the `bundled` feature. [`Artifacts::discover`] tries these sources in a fixed order so -//! consumers such as MXC need no NVX-specific configuration. -//! -//! [`ArtifactDir`] names every artifact of the guests that `nvx.py` runs, which [`Guest`] -//! describes, in a repository checkout or an extracted release: -//! -//! | Artifact | Repository layout | Release layout | -//! | --- | --- | --- | -//! | OpenVMM | `openvmm/target/release/openvmm[.exe]` | `bin/openvmm[.exe]` | -//! | Kernel | `build/vmlinux` | `guest/vmlinux` | -//! | Alpine initramfs | `build/initramfs.cpio.gz` | `guest/initramfs.cpio.gz` | -//! | Ubuntu initramfs | `build/initramfs-ubuntu.cpio.gz` | `guest/initramfs-ubuntu.cpio.gz` | -//! | Azure Linux initramfs | `build/initramfs-azurelinux.cpio.gz` | `guest/initramfs-azurelinux.cpio.gz` | -//! | Ubuntu distro layer | `build/ubuntu-distro.erofs` | `guest/ubuntu-distro.erofs` | -//! -//! Each initramfs has a package manifest beside it, `NAME.packages.json`, and the distro layer a -//! manifest, `NAME.manifest.json`. A release package holds the guest artifacts that its -//! `SOURCE-MANIFEST.json` declares, and `nvx.py download` installs them into the repository -//! layout of a checkout, with the release's OpenVMM executable. The paths and messages are those -//! of `artifact_path`, `openvmm_binary_path`, and `require_file` in -//! `scripts/nvx_tools/common.py`, so that `nvx-rs` and the `nvx.py` commands that build, -//! install, benchmark, and test the guests agree on where each artifact is. - -use std::collections::HashMap; -use std::env; -use std::ffi::OsString; -use std::fmt; -use std::fs; -use std::io::{self, Read}; -use std::path::{Path, PathBuf}; - -use serde::Serialize; -use serde_json::Value; - -use crate::contract; -use crate::error::{Error, ErrorCode, Result}; -use crate::host::paths; -use crate::host::platform; -use crate::host::store::{ArtifactIdentity, RuntimeIdentity}; - -/// Name of the release manifest at the root of a release archive or repository checkout. -pub const MANIFEST_NAME: &str = "SOURCE-MANIFEST.json"; - -/// Directory staged by the build script when the `bundled` feature is enabled. -#[cfg(feature = "bundled")] -const BUNDLED_DIR: Option<&str> = option_env!("NVX_BUNDLED_ARTIFACTS_DIR"); - -/// The OpenVMM executable and the NVX guest kernel and initramfs. -/// -/// Every path is used as given. Resolve relative paths with [`absolute`] before a VM runs them, -/// and check that the files exist, because nothing here does. -/// -/// Artifacts located in a release or a repository checkout keep its [`ArtifactDir`], whose -/// install metadata names their [`runtime_identity`](Self::runtime_identity). Artifacts named by -/// path, through [`new`](Self::new) or the explicit variables of [`discover`](Self::discover), -/// have none: their caller supplies the runtime identity of a launch. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct Artifacts { - /// The `openvmm` executable. - pub openvmm: PathBuf, - /// The NVX guest kernel (`vmlinux`). - pub kernel: PathBuf, - /// The NVX Alpine initramfs (`initramfs.cpio.gz`): the guest userland and its control agent. - pub initrd: PathBuf, - /// The release or checkout in which the artifacts were located, if they were. - directory: Option, -} - -impl Artifacts { - /// Names the three artifacts that a VM needs. - pub fn new( - openvmm: impl Into, - kernel: impl Into, - initrd: impl Into, - ) -> Self { - Self { - openvmm: openvmm.into(), - kernel: kernel.into(), - initrd: initrd.into(), - directory: None, - } - } - - /// Returns the sandbox artifacts of `directory`, which they remember. - fn located(directory: &ArtifactDir) -> Self { - Self { - directory: Some(directory.clone()), - ..Self::new( - directory.openvmm(), - directory.kernel(), - directory.initramfs(Guest::Alpine), - ) - } - } - - /// Returns the release or repository checkout in which the artifacts were located, or `None` - /// for artifacts named by path. - pub fn artifact_dir(&self) -> Option<&ArtifactDir> { - self.directory.as_ref() - } - - /// Returns the runtime identity of these artifacts, which the install metadata of the release - /// or checkout in which they were located names, as [`ArtifactDir::runtime_identity`] reads - /// it, without reading or hashing an artifact. - /// - /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) for artifacts named by - /// path, whose caller supplies their identity instead, for artifacts whose paths no longer - /// name the ones of their directory, and as [`ArtifactDir::runtime_identity`] fails. - pub fn runtime_identity(&self) -> Result { - let Some(directory) = &self.directory else { - return Err(Error::unavailable( - "the artifacts are named by path, so no install metadata identifies them: locate \ - them in an NVX release or repository checkout, or supply their runtime identity", - )); - }; - let located = Self::located(directory); - for (path, expected, description) in [ - (&self.openvmm, &located.openvmm, "OpenVMM executable"), - (&self.kernel, &located.kernel, "guest kernel"), - (&self.initrd, &located.initrd, "guest initramfs"), - ] { - if path != expected { - return Err(Error::unavailable(format!( - "the {description} {} is not the one of {}, whose install metadata \ - identifies the artifacts", - path.display(), - directory.root().display() - ))); - } - } - directory.runtime_identity() - } - - /// Checks that this host can run NVX VMs and that the three files exist. - /// - /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) otherwise, with the - /// code [`ErrorCode::ArtifactMissing`] for a missing file. It does not check the hypervisor; - /// [`probe_hypervisor`](super::platform::probe_hypervisor) does. - pub fn probe(&self) -> Result<()> { - if !platform::SUPPORTED { - return Err(Error::unavailable( - "the openvmm backend supports Linux and Windows hosts only", - )); - } - for (path, description) in [ - (&self.openvmm, "OpenVMM executable"), - (&self.kernel, "NVX guest kernel"), - (&self.initrd, "NVX guest initramfs"), - ] { - if !path.is_file() { - return Err(Error::new( - ErrorCode::ArtifactMissing, - format!("{description} not found: {}", path.display()), - )); - } - } - Ok(()) - } - - /// Uses an extracted NVX release archive: `bin/openvmm[.exe]`, `guest/vmlinux`, and - /// `guest/initramfs.cpio.gz`. - /// - /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) when the release's - /// `SOURCE-MANIFEST.json` declares an incompatible control contract. - pub fn from_release_dir(release_dir: impl AsRef) -> Result { - Self::from_artifact_dir(&ArtifactDir::release(release_dir.as_ref())?) - } - - /// Uses an NVX repository checkout after `nvx.py download` or a local build: - /// `openvmm/target/release/openvmm[.exe]`, `build/vmlinux`, and `build/initramfs.cpio.gz`. - pub fn from_repo_layout(repo_root: impl AsRef) -> Result { - Self::from_artifact_dir(&ArtifactDir::repository(repo_root.as_ref())?) - } - - /// Uses a directory in either the release or the repository layout. - /// - /// A repository checkout also has a `guest/` directory, which holds the guest sources, so the - /// layouts are told apart by their built artifacts instead: a directory that contains - /// `bin/openvmm[.exe]`, `guest/vmlinux`, or `guest/initramfs.cpio.gz` is a release, and any - /// other directory is a repository checkout. - pub fn from_dir(directory: impl AsRef) -> Result { - Self::from_artifact_dir(&ArtifactDir::from_dir(directory.as_ref())?) - } - - /// Uses the OpenVMM executable, guest kernel, and Alpine initramfs of `directory`, once its - /// `SOURCE-MANIFEST.json` declares the control contract that this crate speaks. - /// - /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) as - /// [`check_manifest`] does. - fn from_artifact_dir(directory: &ArtifactDir) -> Result { - check_manifest(&directory.root().join(MANIFEST_NAME))?; - Ok(Self::located(directory)) - } - - /// Returns the artifacts staged by the build script, if the crate was built with the - /// `bundled` feature for Linux or Windows and the staged directory still exists. - /// - /// The staged directory lives in Cargo's build output, so it suits development and tests on - /// the build host. Deployments copy it next to their executable instead; see - /// [`Artifacts::discover`]. - pub fn bundled() -> Result> { - #[cfg(feature = "bundled")] - if let Some(directory) = BUNDLED_DIR.map(Path::new) - && directory.is_dir() - { - return Self::from_release_dir(directory).map(Some); - } - Ok(None) - } - - /// Locates the artifacts on this host. - /// - /// Sources are tried in this order, and the first one present wins: - /// - /// 1. The [`ENV_OPENVMM`](Self::ENV_OPENVMM), [`ENV_KERNEL`](Self::ENV_KERNEL), and - /// [`ENV_INITRD`](Self::ENV_INITRD) variables, which must be set together. - /// 2. The [`ENV_DIR`](Self::ENV_DIR) variable, naming a release or repository directory. - /// 3. The [`BESIDE_EXE_DIR`](Self::BESIDE_EXE_DIR) directory next to the current executable, - /// in the release layout. Consumers ship the bundle there; with the `bundled` feature the - /// build script exports its staged directory to dependent build scripts as - /// `DEP_NVX_ARTIFACTS_DIR` for this purpose. - /// 4. The directory staged by the build script ([`Artifacts::bundled`]). - /// - /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) when no source is - /// present or the chosen source is unusable. It does not check that the files exist. - pub fn discover() -> Result { - let exe_dir = env::current_exe() - .ok() - .and_then(|exe| exe.parent().map(Path::to_path_buf)); - discover_with(|name| env::var_os(name), exe_dir.as_deref(), Self::bundled) - } - - /// Environment variable naming the `openvmm` executable. - pub const ENV_OPENVMM: &'static str = "NVX_OPENVMM"; - /// Environment variable naming the guest kernel. - pub const ENV_KERNEL: &'static str = "NVX_KERNEL"; - /// Environment variable naming the control initramfs. - pub const ENV_INITRD: &'static str = "NVX_INITRD"; - /// Environment variable naming a release or repository directory. - pub const ENV_DIR: &'static str = "NVX_ARTIFACTS_DIR"; - /// Name of the release-layout directory that [`Artifacts::discover`] looks for next to the - /// current executable. - pub const BESIDE_EXE_DIR: &'static str = "nvx"; -} - -/// Implements artifact discovery with injectable environment and bundle sources for tests. -fn discover_with( - variable: impl Fn(&str) -> Option, - exe_dir: Option<&Path>, - bundled: impl FnOnce() -> Result>, -) -> Result { - let explicit = [ - Artifacts::ENV_OPENVMM, - Artifacts::ENV_KERNEL, - Artifacts::ENV_INITRD, - ] - .map(|name| variable(name).filter(|value| !value.is_empty())); - match explicit { - [Some(openvmm), Some(kernel), Some(initrd)] => { - return Ok(Artifacts::new(openvmm, kernel, initrd)); - } - [None, None, None] => {} - _ => { - return Err(Error::unavailable(format!( - "set all or none of {}, {}, and {}", - Artifacts::ENV_OPENVMM, - Artifacts::ENV_KERNEL, - Artifacts::ENV_INITRD - ))); - } - } - if let Some(directory) = variable(Artifacts::ENV_DIR).filter(|value| !value.is_empty()) { - return Artifacts::from_dir(PathBuf::from(directory)); - } - if let Some(directory) = exe_dir.map(|directory| directory.join(Artifacts::BESIDE_EXE_DIR)) - && directory.join(MANIFEST_NAME).is_file() - { - return Artifacts::from_release_dir(directory); - } - if let Some(artifacts) = bundled()? { - return Ok(artifacts); - } - Err(Error::new( - ErrorCode::ArtifactMissing, - format!( - "cannot locate the OpenVMM executable and NVX guest artifacts: set {} or {}, {}, and \ - {}, place an NVX release in a {:?} directory next to the executable, or build the \ - nvx crate with the bundled feature", - Artifacts::ENV_DIR, - Artifacts::ENV_OPENVMM, - Artifacts::ENV_KERNEL, - Artifacts::ENV_INITRD, - Artifacts::BESIDE_EXE_DIR, - ), - )) -} - -/// Returns whether `directory` is in the release layout: whether it contains `bin/openvmm[.exe]`, -/// `guest/vmlinux`, or `guest/initramfs.cpio.gz`. -fn is_release_dir(directory: &Path) -> bool { - let guest = directory.join(RELEASE_GUEST_DIR); - [ - directory.join(RELEASE_BIN_DIR).join(openvmm_executable()), - guest.join(KERNEL_NAME), - guest.join(Guest::Alpine.initramfs_name()), - ] - .iter() - .any(|path| path.is_file()) -} - -/// Returns the file name of the OpenVMM executable on this host. -pub fn openvmm_executable() -> &'static str { - if cfg!(windows) { - "openvmm.exe" - } else { - "openvmm" - } -} - -/// Resolves a relative path against the current directory, failing with -/// [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable). -pub fn absolute(path: &Path) -> Result { - std::path::absolute(path).map_err(|error| { - Error::unavailable(format!("cannot resolve {}", path.display())).with_source(error) - }) -} - -/// Reads and parses the JSON file at `path`, naming it with `description` in errors. -fn read_json(path: &Path, description: &str) -> Result { - let text = fs::read_to_string(path).map_err(|error| { - Error::unavailable(format!("cannot read {description} {}", path.display())) - .with_source(error) - })?; - serde_json::from_str(&text).map_err(|error| { - Error::unavailable(format!( - "{description} {} is not valid JSON", - path.display() - )) - .with_source(error) - }) -} - -/// Checks that an NVX `SOURCE-MANIFEST.json` declares the control contract this crate speaks, -/// failing with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable). -pub fn check_manifest(path: &Path) -> Result<()> { - let manifest = read_json(path, "NVX manifest")?; - if contract::manifest_is_compatible(&manifest) { - Ok(()) - } else { - Err(Error::unavailable(format!( - "NVX manifest {} does not declare the {} control contract", - path.display(), - contract::CONTROL_CONTRACT_REVISION - ))) - } -} - -/// File name of the guest kernel in both layouts. -pub const KERNEL_NAME: &str = "vmlinux"; -/// Suffix of the package manifest that lists the packages of an initramfs. -pub const PACKAGE_MANIFEST_SUFFIX: &str = ".packages.json"; -/// Suffix of the manifest of a distro layer, which binds the layer's digest and packages. -pub const DISTRO_MANIFEST_SUFFIX: &str = ".manifest.json"; -/// Repository-layout directory of guest artifacts. -const REPO_BUILD_DIR: &str = "build"; -/// Repository-layout path of the OpenVMM build output directory. -const REPO_OPENVMM_DIR: [&str; 3] = ["openvmm", "target", "release"]; -/// Release-layout directory of the OpenVMM executable. -const RELEASE_BIN_DIR: &str = "bin"; -/// Release-layout directory of the guest artifacts. -const RELEASE_GUEST_DIR: &str = "guest"; -/// Name of the list of the SHA-256 digests of the files of a release, at its root. -pub const CHECKSUMS_NAME: &str = "SHA256SUMS"; -/// Name of the build provenance of the OpenVMM executable, beside the guest artifacts of a -/// repository checkout. -pub const OPENVMM_PROVENANCE_NAME: &str = "openvmm.provenance.json"; -/// Name of the build provenance of the guest kernel, beside it. -pub const KERNEL_PROVENANCE_NAME: &str = "vmlinux.provenance.json"; -/// Name of the build provenance of the Alpine initramfs, beside it. -pub const INITRAMFS_PROVENANCE_NAME: &str = "initramfs.provenance.json"; -/// Format of the build provenance that this crate reads. -const PROVENANCE_FORMAT: u64 = 1; -/// Longest file of install metadata that this crate reads, in bytes. A release's checksum list -/// holds one line for each of its files, and a build provenance a few fields. -const MAX_METADATA_LEN: u64 = 1 << 20; - -/// A Linux guest that `nvx.py` builds and runs, as `scripts/nvx_tools/guests.py` describes it. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum Guest { - /// Alpine Linux: the default guest of `nvx-rs run`, and the one that runs sandboxes. - Alpine, - /// Ubuntu Base, which also provides the distro layer of sandbox workloads. - Ubuntu, - /// Azure Linux. - AzureLinux, -} - -impl Guest { - /// Every guest, in the order in which `nvx.py` lists them. - pub const ALL: [Self; 3] = [Self::Alpine, Self::Ubuntu, Self::AzureLinux]; - - /// Returns the guest's name in `nvx.py` options: `alpine`, `ubuntu`, or `azurelinux`. - pub const fn name(self) -> &'static str { - match self { - Self::Alpine => "alpine", - Self::Ubuntu => "ubuntu", - Self::AzureLinux => "azurelinux", - } - } - - /// Returns the guest named `name`. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and the message - /// `unsupported guest "NAME"; choose alpine, ubuntu, azurelinux`, which quotes the name with - /// Rust's debug formatting. - pub fn from_name(name: &str) -> Result { - Self::ALL - .into_iter() - .find(|guest| guest.name() == name) - .ok_or_else(|| { - let names: Vec<&str> = Self::ALL.iter().map(|guest| guest.name()).collect(); - Error::unsupported(format!( - "unsupported guest {name:?}; choose {}", - names.join(", ") - )) - }) - } - - /// Returns the name of the guest's distribution, as messages show it: `Alpine Linux`, - /// `Ubuntu Base`, or `Azure Linux`. - pub const fn distribution(self) -> &'static str { - match self { - Self::Alpine => "Alpine Linux", - Self::Ubuntu => "Ubuntu Base", - Self::AzureLinux => "Azure Linux", - } - } - - /// Returns the file name of the guest's initramfs. - pub const fn initramfs_name(self) -> &'static str { - match self { - Self::Alpine => "initramfs.cpio.gz", - Self::Ubuntu => "initramfs-ubuntu.cpio.gz", - Self::AzureLinux => "initramfs-azurelinux.cpio.gz", - } - } - - /// Returns the RAM, in MiB, with which `nvx-rs run` boots the guest without `--memory-mib`: - /// the `default_memory_mib` of its descriptor in `scripts/nvx_tools/guests.py`, which - /// `nvx.py test-microvm` also uses, and which a test of `nvx.py` checks against the help of - /// `nvx-rs run`. - pub const fn default_memory_mib(self) -> u32 { - match self { - Self::Alpine => 128, - Self::Ubuntu | Self::AzureLinux => 512, - } - } - - /// Returns the file name of the package manifest of the guest's initramfs. - pub fn package_manifest_name(self) -> String { - format!("{}{PACKAGE_MANIFEST_SUFFIX}", self.initramfs_name()) - } - - /// Returns the file name of the guest's EROFS distro layer, `GUEST-distro.erofs`, if the - /// guest provides one: only Ubuntu does. - pub const fn distro_layer_name(self) -> Option<&'static str> { - match self { - Self::Ubuntu => Some("ubuntu-distro.erofs"), - Self::Alpine | Self::AzureLinux => None, - } - } - - /// Returns the file name of the manifest of the guest's distro layer, if it has one. - pub fn distro_manifest_name(self) -> Option { - self.distro_layer_name() - .map(|name| format!("{name}{DISTRO_MANIFEST_SUFFIX}")) - } -} - -impl fmt::Display for Guest { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.name()) - } -} - -/// The layout of a directory of NVX artifacts. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Layout { - /// A repository checkout: the guest artifacts in `build/`, and OpenVMM in - /// `openvmm/target/release/`, where a local build writes them and where `nvx.py download` - /// installs a release. - Repository, - /// An extracted release package: the guest artifacts in `guest/`, and OpenVMM in `bin/`. - Release, -} - -/// A directory of the artifacts that `nvx.py` runs: a repository checkout or an extracted -/// release, with the paths that `artifact_path` and `openvmm_binary_path` of -/// `scripts/nvx_tools/common.py` give for a checkout. -/// -/// Paths are joined to the directory, made absolute, without checking that they exist; the -/// `require_*` methods check them with the messages of `nvx.py`. Unlike [`Artifacts`], nothing -/// here reads the release's `SOURCE-MANIFEST.json`, which `nvx.py` does not check either. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ArtifactDir { - /// The absolute directory. - root: PathBuf, - /// Its layout. - layout: Layout, -} - -impl ArtifactDir { - /// Uses a repository checkout. - /// - /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) if a relative - /// `root` meets an unreadable current directory. - pub fn repository(root: impl AsRef) -> Result { - Self::new(root.as_ref(), Layout::Repository) - } - - /// Uses an extracted release package. - /// - /// Fails as [`repository`](Self::repository) does. - pub fn release(root: impl AsRef) -> Result { - Self::new(root.as_ref(), Layout::Release) - } - - /// Uses a directory in the layout that its built artifacts reveal, as - /// [`Artifacts::from_dir`] tells them apart: a directory that contains `bin/openvmm[.exe]`, - /// `guest/vmlinux`, or `guest/initramfs.cpio.gz` is a release, and any other directory is a - /// repository checkout. - /// - /// Fails as [`repository`](Self::repository) does. - pub fn from_dir(directory: impl AsRef) -> Result { - let directory = directory.as_ref(); - let layout = if is_release_dir(directory) { - Layout::Release - } else { - Layout::Repository - }; - Self::new(directory, layout) - } - - /// Returns the directory of `layout` at `root`, made absolute. - fn new(root: &Path, layout: Layout) -> Result { - Ok(Self { - root: absolute(root)?, - layout, - }) - } - - /// Returns the absolute directory. - pub fn root(&self) -> &Path { - &self.root - } - - /// Returns the directory's layout. - pub fn layout(&self) -> Layout { - self.layout - } - - /// Returns the path of the guest artifact `name`, `artifact_path(name)` of `nvx.py`: - /// `build/NAME` in a checkout, and `guest/NAME` in a release. - pub fn artifact(&self, name: &str) -> PathBuf { - let directory = match self.layout { - Layout::Repository => REPO_BUILD_DIR, - Layout::Release => RELEASE_GUEST_DIR, - }; - self.root.join(directory).join(name) - } - - /// Returns the path of the OpenVMM executable, `openvmm_binary_path()` of `nvx.py`: - /// `openvmm/target/release/openvmm[.exe]` in a checkout, and `bin/openvmm[.exe]` in a - /// release. - pub fn openvmm(&self) -> PathBuf { - let directory = match self.layout { - Layout::Repository => REPO_OPENVMM_DIR.iter().collect(), - Layout::Release => PathBuf::from(RELEASE_BIN_DIR), - }; - self.root.join(directory).join(openvmm_executable()) - } - - /// Returns the path of the guest kernel, which every guest boots. - pub fn kernel(&self) -> PathBuf { - self.artifact(KERNEL_NAME) - } - - /// Returns the path of a guest's initramfs. - pub fn initramfs(&self, guest: Guest) -> PathBuf { - self.artifact(guest.initramfs_name()) - } - - /// Returns the path of the package manifest of a guest's initramfs. - pub fn package_manifest(&self, guest: Guest) -> PathBuf { - self.artifact(&guest.package_manifest_name()) - } - - /// Returns the path of a guest's distro layer, if the guest provides one. - pub fn distro_layer(&self, guest: Guest) -> Option { - guest.distro_layer_name().map(|name| self.artifact(name)) - } - - /// Returns the path of the manifest of a guest's distro layer, if the guest provides one. - pub fn distro_manifest(&self, guest: Guest) -> Option { - guest - .distro_manifest_name() - .map(|name| self.artifact(&name)) - } - - /// Checks that the OpenVMM executable is a regular file and returns its path. - /// - /// Fails with [`ErrorCode::ArtifactMissing`] and the message `OpenVMM release binary not - /// found: PATH`. - pub fn require_openvmm(&self) -> Result { - required(self.openvmm(), "OpenVMM release binary") - } - - /// Checks that the guest kernel is a regular file and returns its path. - /// - /// Fails with [`ErrorCode::ArtifactMissing`] and the message `Linux direct kernel not found: - /// PATH`. - pub fn require_kernel(&self) -> Result { - required(self.kernel(), "Linux direct kernel") - } - - /// Checks that a guest's initramfs is a regular file and returns its path, as `nvx-rs run` - /// does before it boots the guest. - /// - /// Fails with [`ErrorCode::ArtifactMissing`] and the message `DISTRIBUTION initramfs not - /// found: PATH`, such as `Ubuntu Base initramfs not found: PATH`. - pub fn require_initramfs(&self, guest: Guest) -> Result { - required( - self.initramfs(guest), - &format!("{} initramfs", guest.distribution()), - ) - } - - /// Returns the artifacts of a sandbox, as `nvx-rs sandbox run` and `nvx-rs sandbox start` - /// require them: the OpenVMM executable, the kernel, and the Alpine initramfs, in that order, - /// located in this directory. - /// - /// Fails with [`ErrorCode::ArtifactMissing`] for the first one that is not a regular file, - /// with the messages of [`require_openvmm`](Self::require_openvmm) and - /// [`require_kernel`](Self::require_kernel), or `initramfs not found: PATH`. - pub fn sandbox_artifacts(&self) -> Result { - self.require_openvmm()?; - self.require_kernel()?; - required(self.initramfs(Guest::Alpine), "initramfs")?; - Ok(Artifacts::located(self)) - } - - /// Returns the paths of every artifact, for a caller to show or record. - pub fn paths(&self) -> ArtifactPaths { - ArtifactPaths { - layout: self.layout, - root: self.root.clone(), - openvmm: self.openvmm(), - kernel: self.kernel(), - guests: Guest::ALL - .iter() - .map(|&guest| GuestPaths { - name: guest.name(), - distribution: guest.distribution(), - initramfs: self.initramfs(guest), - package_manifest: self.package_manifest(guest), - distro_layer: self.distro_layer(guest), - distro_manifest: self.distro_manifest(guest), - }) - .collect(), - } - } - - /// Returns the identity of the sandbox runtime of this directory, the one that - /// [`sandbox_artifacts`](Self::sandbox_artifacts) and [`Artifacts::from_dir`] name: its - /// OpenVMM executable, its kernel, and its Alpine initramfs, as the directory's install - /// metadata names them, under the contracts that this crate speaks. - /// - /// The metadata names each artifact by the SHA-256 digest that its build or its package - /// recorded, so nothing here reads or hashes an artifact: - /// - /// - A release lists the digest of each of its files in its `SHA256SUMS`, under the file's - /// path in the release: `bin/openvmm[.exe]`, `guest/vmlinux`, and - /// `guest/initramfs.cpio.gz`. - /// - A repository checkout keeps the build provenance of each artifact beside its guest - /// artifacts: `build/openvmm.provenance.json`, `build/vmlinux.provenance.json`, and - /// `build/initramfs.provenance.json`, of format 1, whose `executable_sha256`, - /// `kernel_sha256`, and `initramfs_sha256` are the digests. `nvx.py` records them as it - /// builds an artifact, and `nvx.py download` installs those of a release. - /// - /// The artifacts of a release therefore have the same identity in the release and in a - /// checkout that installed it. Fails with - /// [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) when the metadata is missing or - /// unreadable, or names no valid digest of an artifact. A file of metadata that is not a - /// regular file, such as a FIFO, is refused without being waited on. - pub fn runtime_identity(&self) -> Result { - let [openvmm, kernel, initramfs] = match self.layout { - Layout::Release => self.listed_identities()?, - Layout::Repository => [ - provenance_identity( - &self.artifact(OPENVMM_PROVENANCE_NAME), - "OpenVMM", - "executable_sha256", - )?, - provenance_identity( - &self.artifact(KERNEL_PROVENANCE_NAME), - "kernel", - "kernel_sha256", - )?, - provenance_identity( - &self.artifact(INITRAMFS_PROVENANCE_NAME), - "initramfs", - "initramfs_sha256", - )?, - ], - }; - Ok(RuntimeIdentity::new(openvmm, kernel, initramfs)) - } - - /// Returns the identities of the OpenVMM executable, kernel, and Alpine initramfs of a - /// release, as its `SHA256SUMS` lists their digests: one line for each file, its 64 - /// lowercase hexadecimal digits, two spaces, and its path in the release. - fn listed_identities(&self) -> Result<[ArtifactIdentity; 3]> { - let path = self.root.join(CHECKSUMS_NAME); - let description = "release checksum list"; - let text = read_metadata(&path, description, "the release is incomplete")?; - let mut identities = HashMap::new(); - for line in text.lines() { - let malformed = || { - Error::unavailable(format!( - "the {description} {} has a malformed line: {line:?}", - path.display() - )) - }; - let (digest, name) = line.split_once(" ").ok_or_else(malformed)?; - let identity = sha256_identity(digest) - .filter(|_| !name.is_empty()) - .ok_or_else(malformed)?; - if identities.insert(name, identity).is_some() { - return Err(Error::unavailable(format!( - "the {description} {} lists {name} twice", - path.display() - ))); - } - } - let mut listed = |name: String| { - identities.remove(name.as_str()).ok_or_else(|| { - Error::unavailable(format!( - "the {description} {} does not list {name}", - path.display() - )) - }) - }; - Ok([ - listed(format!("{RELEASE_BIN_DIR}/{}", openvmm_executable()))?, - listed(format!("{RELEASE_GUEST_DIR}/{KERNEL_NAME}"))?, - listed(format!( - "{RELEASE_GUEST_DIR}/{}", - Guest::Alpine.initramfs_name() - ))?, - ]) - } -} - -/// Returns the identity of an artifact whose SHA-256 digest is `digest`, if it holds 64 lowercase -/// hexadecimal digits, as `nvx.py` writes digests and requires them. -fn sha256_identity(digest: &str) -> Option { - if digest - .bytes() - .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) - { - ArtifactIdentity::sha256(digest).ok() - } else { - None - } -} - -/// Reads the file of install metadata at `path`, which messages call `description`, as text of -/// at most [`MAX_METADATA_LEN`] bytes. A missing file fails with `advice` on how to provide it. -/// -/// The open does not wait, as it would on Unix for a FIFO without a writer, and a file that is -/// not a regular file is refused before anything is read from it, as a FIFO whose writer keeps it -/// open would keep a read waiting: a sandbox's transitions read the metadata while they hold its -/// lock. -fn read_metadata(path: &Path, description: &str, advice: &str) -> Result { - let unreadable = |error: io::Error| { - if error.kind() == io::ErrorKind::NotFound { - Error::unavailable(format!( - "the {description} {} is missing, so the runtime cannot be identified: {advice}", - path.display() - )) - } else { - Error::unavailable(format!("cannot read the {description} {}", path.display())) - .with_source(error) - } - }; - let not_regular = || { - Error::unavailable(format!( - "the {description} {} is not a regular file", - path.display() - )) - }; - let mut options = fs::OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - - // Reads of a regular file do not depend on the flag. - options.custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()); - } - let opened = options.open(path); - #[cfg(unix)] - { - // A socket refuses an open. - if let Err(error) = &opened - && error.raw_os_error() == Some(nix::errno::Errno::ENXIO as i32) - { - return Err(not_regular()); - } - } - let file = opened.map_err(unreadable)?; - if !file.metadata().map_err(unreadable)?.file_type().is_file() { - return Err(not_regular()); - } - let mut text = String::new(); - file.take(MAX_METADATA_LEN + 1) - .read_to_string(&mut text) - .map_err(unreadable)?; - if text.len() as u64 > MAX_METADATA_LEN { - return Err(Error::unavailable(format!( - "the {description} {} is larger than {MAX_METADATA_LEN} bytes", - path.display() - ))); - } - Ok(text) -} - -/// Returns the identity of the artifact `what` that the build provenance at `path` records: the -/// SHA-256 digest of its `field`, in a provenance of format [`PROVENANCE_FORMAT`]. -fn provenance_identity(path: &Path, what: &str, field: &str) -> Result { - let description = format!("{what} build provenance"); - let text = read_metadata( - path, - &description, - "build the artifact with nvx.py, or install a release with nvx.py download", - )?; - let invalid = |problem: &str| { - Error::unavailable(format!("the {description} {} {problem}", path.display())) - }; - let provenance: Value = serde_json::from_str(&text) - .map_err(|error| invalid("is not valid JSON").with_source(error))?; - if provenance.get("format").and_then(Value::as_u64) != Some(PROVENANCE_FORMAT) { - return Err(invalid(&format!( - "does not have format {PROVENANCE_FORMAT}" - ))); - } - provenance - .get(field) - .and_then(Value::as_str) - .and_then(sha256_identity) - .ok_or_else(|| invalid(&format!("has no SHA-256 digest in {field}"))) -} - -/// Checks that `path` is a regular file, as `require_file` of `nvx.py` does, and returns it, or -/// fails with [`ErrorCode::ArtifactMissing`]. -fn required(path: PathBuf, description: &str) -> Result { - require_artifact(&path, description)?; - Ok(path) -} - -/// Checks that an artifact that a launch runs, such as the OpenVMM executable, is a regular -/// file, as [`require_existing`] does, and fails with [`ErrorCode::ArtifactMissing`]. -pub(crate) fn require_artifact(path: &Path, description: &str) -> Result<()> { - require_existing(path, description, ErrorCode::ArtifactMissing) -} - -/// Checks that `path` is a regular file, following links, as `require_file` of `nvx.py` does, -/// and fails with `code` and the message `{description} not found: {path}`, with `path` spelled -/// with its components alone, as [`Path::components`] reads them. -pub(crate) fn require_existing(path: &Path, description: &str, code: ErrorCode) -> Result<()> { - if path.is_file() { - return Ok(()); - } - Err(Error::new( - code, - format!( - "{description} not found: {}", - paths::normalized(path).display() - ), - )) -} - -/// The paths of the artifacts of an [`ArtifactDir`], which serialize as a JSON object such as: -/// -/// ```json -/// {"layout": "repository", "root": "/nvx", "openvmm": "/nvx/openvmm/target/release/openvmm", -/// "kernel": "/nvx/build/vmlinux", "guests": [{"name": "alpine", -/// "distribution": "Alpine Linux", "initramfs": "/nvx/build/initramfs.cpio.gz", -/// "package_manifest": "/nvx/build/initramfs.cpio.gz.packages.json", "distro_layer": null, -/// "distro_manifest": null}, ...]} -/// ``` -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[non_exhaustive] -pub struct ArtifactPaths { - /// The directory's layout. - pub layout: Layout, - /// The absolute directory. - pub root: PathBuf, - /// The OpenVMM executable. - pub openvmm: PathBuf, - /// The guest kernel. - pub kernel: PathBuf, - /// The artifacts of each guest, in the order of [`Guest::ALL`]. - pub guests: Vec, -} - -/// The paths of the artifacts of one guest. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[non_exhaustive] -pub struct GuestPaths { - /// The guest's name, [`Guest::name`]. - pub name: &'static str, - /// The guest's distribution, [`Guest::distribution`]. - pub distribution: &'static str, - /// The initramfs. - pub initramfs: PathBuf, - /// The package manifest of the initramfs. - pub package_manifest: PathBuf, - /// The distro layer, if the guest provides one. - pub distro_layer: Option, - /// The manifest of the distro layer, if the guest provides one. - pub distro_manifest: Option, -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use super::*; - use crate::ErrorKind; - - const COMPATIBLE_MANIFEST: &str = r#"{"openvmm":{"microvm_abi_version":2, - "control_session_protocol_version":1, - "control_contract_revision":"nvx-microvm-v2-control-v2"}}"#; - - fn release(root: &Path) { - for artifact in [ - Path::new("bin").join(openvmm_executable()), - Path::new("guest").join("vmlinux"), - Path::new("guest").join("initramfs.cpio.gz"), - ] { - let path = root.join(artifact); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, b"artifact").unwrap(); - } - fs::write(root.join(MANIFEST_NAME), COMPATIBLE_MANIFEST).unwrap(); - } - - /// Creates a repository checkout with its tracked guest sources and no build outputs. - fn checkout(root: &Path) { - let sources = root.join("guest").join("common"); - fs::create_dir_all(&sources).unwrap(); - fs::write(sources.join("init"), b"#!/bin/sh").unwrap(); - fs::write(root.join(MANIFEST_NAME), COMPATIBLE_MANIFEST).unwrap(); - } - - fn no_bundle() -> Result> { - Ok(None) - } - - fn variables(pairs: &[(&str, &Path)]) -> impl Fn(&str) -> Option { - let map: HashMap = pairs - .iter() - .map(|(name, value)| ((*name).to_owned(), value.as_os_str().to_owned())) - .collect(); - move |name| map.get(name).cloned() - } - - #[test] - fn release_layout_names_the_guest_artifacts() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let artifacts = Artifacts::from_dir(directory.path()).unwrap(); - let root = std::path::absolute(directory.path()).unwrap(); - assert_eq!( - artifacts.openvmm, - root.join("bin").join(openvmm_executable()) - ); - assert_eq!(artifacts.kernel, root.join("guest").join("vmlinux")); - assert_eq!( - artifacts.initrd, - root.join("guest").join("initramfs.cpio.gz") - ); - // A directory that the caller hands over, such as a `TempDir`, lives until the - // constructor returns, after it reads the manifest. - assert_eq!(Artifacts::from_release_dir(directory).unwrap(), artifacts); - } - - #[test] - fn repository_layout_is_detected_beside_the_guest_sources() { - let directory = tempfile::tempdir().unwrap(); - checkout(directory.path()); - let root = std::path::absolute(directory.path()).unwrap(); - let expected = |artifacts: &Artifacts| { - assert_eq!(artifacts.kernel, root.join("build").join("vmlinux")); - assert_eq!( - artifacts.initrd, - root.join("build").join("initramfs.cpio.gz") - ); - assert_eq!( - artifacts.openvmm, - root.join("openvmm") - .join("target") - .join("release") - .join(openvmm_executable()) - ); - }; - expected(&Artifacts::from_dir(directory.path()).unwrap()); - expected(&Artifacts::from_repo_layout(directory.path()).unwrap()); - - // Build outputs do not turn a checkout into a release. - let build = directory.path().join("build"); - fs::create_dir_all(&build).unwrap(); - fs::write(build.join("vmlinux"), b"kernel").unwrap(); - fs::write(build.join("initramfs.cpio.gz"), b"initrd").unwrap(); - expected(&Artifacts::from_dir(directory.path()).unwrap()); - } - - #[test] - fn the_directory_variable_accepts_a_repository_checkout() { - let directory = tempfile::tempdir().unwrap(); - checkout(directory.path()); - let artifacts = discover_with( - variables(&[(Artifacts::ENV_DIR, directory.path())]), - None, - no_bundle, - ) - .unwrap(); - let build = std::path::absolute(directory.path()).unwrap().join("build"); - assert_eq!(artifacts.kernel, build.join("vmlinux")); - } - - #[test] - fn this_repository_uses_the_repository_layout() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - if root.join(MANIFEST_NAME).is_file() { - let artifacts = Artifacts::from_dir(root).unwrap(); - let kernel = std::path::absolute(root).unwrap().join("build"); - assert_eq!(artifacts.kernel, kernel.join("vmlinux")); - let directory = ArtifactDir::from_dir(root).unwrap(); - assert_eq!(directory.layout(), Layout::Repository); - assert_eq!(directory.kernel(), artifacts.kernel); - } - } - - #[test] - fn explicit_variables_win_and_must_be_complete() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let (openvmm, kernel, initrd) = (Path::new("o"), Path::new("k"), Path::new("i")); - let artifacts = discover_with( - variables(&[ - (Artifacts::ENV_OPENVMM, openvmm), - (Artifacts::ENV_KERNEL, kernel), - (Artifacts::ENV_INITRD, initrd), - (Artifacts::ENV_DIR, directory.path()), - ]), - None, - no_bundle, - ) - .unwrap(); - assert_eq!(artifacts, Artifacts::new(openvmm, kernel, initrd)); - - let error = discover_with( - variables(&[(Artifacts::ENV_OPENVMM, openvmm)]), - None, - no_bundle, - ) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - } - - #[test] - fn the_directory_variable_precedes_the_executable_directory() { - let variable_dir = tempfile::tempdir().unwrap(); - release(variable_dir.path()); - let exe_dir = tempfile::tempdir().unwrap(); - release(&exe_dir.path().join(Artifacts::BESIDE_EXE_DIR)); - - let artifacts = discover_with( - variables(&[(Artifacts::ENV_DIR, variable_dir.path())]), - Some(exe_dir.path()), - no_bundle, - ) - .unwrap(); - assert!( - artifacts - .kernel - .starts_with(std::path::absolute(variable_dir.path()).unwrap()) - ); - - let artifacts = discover_with(variables(&[]), Some(exe_dir.path()), no_bundle).unwrap(); - assert!(artifacts.kernel.starts_with( - std::path::absolute(exe_dir.path().join(Artifacts::BESIDE_EXE_DIR)).unwrap() - )); - } - - #[test] - fn the_bundle_is_the_last_resort() { - let exe_dir = tempfile::tempdir().unwrap(); - let bundle = Artifacts::new("bundled-openvmm", "bundled-kernel", "bundled-initrd"); - let expected = bundle.clone(); - let artifacts = - discover_with(variables(&[]), Some(exe_dir.path()), || Ok(Some(bundle))).unwrap(); - assert_eq!(artifacts, expected); - - let error = discover_with(variables(&[]), Some(exe_dir.path()), no_bundle).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert!(error.message().contains(Artifacts::ENV_DIR)); - assert!( - error - .message() - .contains("build the nvx crate with the bundled feature") - ); - } - - #[test] - fn incompatible_manifests_are_unavailable() { - let directory = tempfile::tempdir().unwrap(); - let manifest = directory.path().join(MANIFEST_NAME); - fs::write( - &manifest, - r#"{"openvmm":{"microvm_abi_version":2,"control_session_protocol_version":2, - "control_contract_revision":"nvx-microvm-v2-control-v2"}}"#, - ) - .unwrap(); - let error = check_manifest(&manifest).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - let error = check_manifest(&directory.path().join("missing.json")).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - } - - #[test] - fn repository_manifest_declares_the_supported_contract() { - let manifest = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join(MANIFEST_NAME); - if manifest.is_file() { - check_manifest(&manifest).unwrap(); - } - } - - #[test] - fn guests_name_their_artifacts_as_nvx_py_does() { - assert_eq!( - Guest::ALL.map(Guest::name), - ["alpine", "ubuntu", "azurelinux"] - ); - for guest in Guest::ALL { - assert_eq!(Guest::from_name(guest.name()).unwrap(), guest); - assert_eq!(guest.to_string(), guest.name()); - } - let error = Guest::from_name("Ubuntu").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert_eq!( - error.message(), - "unsupported guest \"Ubuntu\"; choose alpine, ubuntu, azurelinux" - ); - assert_eq!( - Guest::ALL.map(Guest::distribution), - ["Alpine Linux", "Ubuntu Base", "Azure Linux"] - ); - assert_eq!( - Guest::ALL.map(Guest::package_manifest_name), - [ - "initramfs.cpio.gz.packages.json", - "initramfs-ubuntu.cpio.gz.packages.json", - "initramfs-azurelinux.cpio.gz.packages.json", - ] - ); - assert_eq!( - Guest::ALL.map(Guest::distro_layer_name), - [None, Some("ubuntu-distro.erofs"), None] - ); - assert_eq!( - Guest::Ubuntu.distro_manifest_name().as_deref(), - Some("ubuntu-distro.erofs.manifest.json") - ); - assert_eq!(Guest::Alpine.distro_manifest_name(), None); - assert_eq!(Guest::ALL.map(Guest::default_memory_mib), [128, 512, 512]); - } - - #[test] - fn a_checkout_holds_the_guest_artifacts_in_build() { - let directory = tempfile::tempdir().unwrap(); - checkout(directory.path()); - let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); - let root = std::path::absolute(directory.path()).unwrap(); - let build = root.join("build"); - assert_eq!(artifacts.layout(), Layout::Repository); - assert_eq!(artifacts.root(), root); - assert_eq!( - artifacts.openvmm(), - root.join("openvmm") - .join("target") - .join("release") - .join(openvmm_executable()) - ); - assert_eq!(artifacts.kernel(), build.join("vmlinux")); - assert_eq!( - Guest::ALL.map(|guest| artifacts.initramfs(guest)), - [ - build.join("initramfs.cpio.gz"), - build.join("initramfs-ubuntu.cpio.gz"), - build.join("initramfs-azurelinux.cpio.gz"), - ] - ); - assert_eq!( - artifacts.package_manifest(Guest::Ubuntu), - build.join("initramfs-ubuntu.cpio.gz.packages.json") - ); - assert_eq!( - Guest::ALL.map(|guest| artifacts.distro_layer(guest)), - [None, Some(build.join("ubuntu-distro.erofs")), None] - ); - assert_eq!( - artifacts.distro_manifest(Guest::Ubuntu), - Some(build.join("ubuntu-distro.erofs.manifest.json")) - ); - assert_eq!( - artifacts.artifact("vmlinux.provenance.json"), - build.join("vmlinux.provenance.json") - ); - - // A relative directory is made absolute, and an explicit layout overrides detection. - let relative = ArtifactDir::repository(".").unwrap(); - assert_eq!(relative.root(), std::path::absolute(".").unwrap()); - release(directory.path()); - assert_eq!( - ArtifactDir::repository(directory.path()).unwrap().layout(), - Layout::Repository - ); - } - - #[test] - fn a_release_holds_the_guest_artifacts_in_guest() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); - let root = std::path::absolute(directory.path()).unwrap(); - let guest = root.join("guest"); - assert_eq!(artifacts.layout(), Layout::Release); - assert_eq!(artifacts, ArtifactDir::release(directory.path()).unwrap()); - assert_eq!( - artifacts.openvmm(), - root.join("bin").join(openvmm_executable()) - ); - assert_eq!(artifacts.kernel(), guest.join("vmlinux")); - assert_eq!( - Guest::ALL.map(|guest| artifacts.initramfs(guest)), - [ - guest.join("initramfs.cpio.gz"), - guest.join("initramfs-ubuntu.cpio.gz"), - guest.join("initramfs-azurelinux.cpio.gz"), - ] - ); - assert_eq!( - artifacts.distro_layer(Guest::Ubuntu), - Some(guest.join("ubuntu-distro.erofs")) - ); - - // The release's artifacts are the ones that discovery finds for its directory. - let discovered = Artifacts::from_dir(directory.path()).unwrap(); - assert_eq!(artifacts.sandbox_artifacts().unwrap(), discovered); - } - - #[test] - fn guest_artifacts_are_required_in_the_order_and_words_of_nvx_py() { - let directory = tempfile::tempdir().unwrap(); - checkout(directory.path()); - let artifacts = ArtifactDir::repository(directory.path()).unwrap(); - fn missing(result: Result, description: &str, path: PathBuf) { - let error = result.unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert_eq!( - error.message(), - format!("{description} not found: {}", path.display()) - ); - } - let create = |path: PathBuf| { - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, b"artifact").unwrap(); - }; - missing( - artifacts.sandbox_artifacts(), - "OpenVMM release binary", - artifacts.openvmm(), - ); - create(artifacts.openvmm()); - missing( - artifacts.sandbox_artifacts(), - "Linux direct kernel", - artifacts.kernel(), - ); - create(artifacts.kernel()); - missing( - artifacts.sandbox_artifacts(), - "initramfs", - artifacts.initramfs(Guest::Alpine), - ); - create(artifacts.initramfs(Guest::Alpine)); - let located = artifacts.sandbox_artifacts().unwrap(); - assert_eq!( - [&located.openvmm, &located.kernel, &located.initrd], - [ - &artifacts.openvmm(), - &artifacts.kernel(), - &artifacts.initramfs(Guest::Alpine) - ] - ); - assert_eq!(located.artifact_dir(), Some(&artifacts)); - assert_eq!( - located, - Artifacts::from_repo_layout(directory.path()).unwrap() - ); - - assert_eq!(artifacts.require_openvmm().unwrap(), artifacts.openvmm()); - assert_eq!(artifacts.require_kernel().unwrap(), artifacts.kernel()); - missing( - artifacts.require_initramfs(Guest::Ubuntu), - "Ubuntu Base initramfs", - artifacts.initramfs(Guest::Ubuntu), - ); - missing( - artifacts.require_initramfs(Guest::AzureLinux), - "Azure Linux initramfs", - artifacts.initramfs(Guest::AzureLinux), - ); - create(artifacts.initramfs(Guest::Ubuntu)); - assert_eq!( - artifacts.require_initramfs(Guest::Ubuntu).unwrap(), - artifacts.initramfs(Guest::Ubuntu) - ); - - // A directory is not a regular file. - fs::create_dir_all(artifacts.initramfs(Guest::AzureLinux)).unwrap(); - missing( - artifacts.require_initramfs(Guest::AzureLinux), - "Azure Linux initramfs", - artifacts.initramfs(Guest::AzureLinux), - ); - } - - #[test] - fn artifact_paths_serialize_every_guest_in_order() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); - let text = |path: PathBuf| Value::from(path.to_str().unwrap()); - let value = serde_json::to_value(artifacts.paths()).unwrap(); - assert_eq!(value["layout"], "release"); - assert_eq!(value["root"], text(artifacts.root().to_path_buf())); - assert_eq!(value["openvmm"], text(artifacts.openvmm())); - assert_eq!(value["kernel"], text(artifacts.kernel())); - let guests = value["guests"].as_array().unwrap(); - assert_eq!(guests.len(), Guest::ALL.len()); - for (paths, guest) in guests.iter().zip(Guest::ALL) { - assert_eq!(paths["name"], guest.name()); - assert_eq!(paths["distribution"], guest.distribution()); - assert_eq!(paths["initramfs"], text(artifacts.initramfs(guest))); - assert_eq!( - paths["package_manifest"], - text(artifacts.package_manifest(guest)) - ); - assert_eq!( - paths["distro_layer"], - artifacts.distro_layer(guest).map_or(Value::Null, text) - ); - assert_eq!( - paths["distro_manifest"], - artifacts.distro_manifest(guest).map_or(Value::Null, text) - ); - } - let repository = ArtifactDir::repository(directory.path()).unwrap(); - assert_eq!( - serde_json::to_value(repository.paths()).unwrap()["layout"], - "repository" - ); - } - - /// Returns the identity of artifacts whose SHA-256 digests are `digests`. - fn identity_of(digests: &[String; 3]) -> RuntimeIdentity { - let [openvmm, kernel, initramfs] = digests - .each_ref() - .map(|digest| ArtifactIdentity::sha256(digest).unwrap()); - RuntimeIdentity::new(openvmm, kernel, initramfs) - } - - /// Writes the build provenance of the artifacts of the checkout at `root`, as `nvx.py` - /// records it, with `digests` for the OpenVMM executable, the kernel, and the initramfs. - fn record_provenance(root: &Path, digests: &[String; 3]) { - let build = root.join("build"); - fs::create_dir_all(&build).unwrap(); - for ((name, field), digest) in [ - (OPENVMM_PROVENANCE_NAME, "executable_sha256"), - (KERNEL_PROVENANCE_NAME, "kernel_sha256"), - (INITRAMFS_PROVENANCE_NAME, "initramfs_sha256"), - ] - .into_iter() - .zip(digests) - { - let text = format!("{{\n \"format\": 1,\n \"{field}\": \"{digest}\"\n}}\n"); - fs::write(build.join(name), text).unwrap(); - } - } - - #[test] - fn a_checkout_identifies_its_runtime_by_the_build_provenance_of_its_artifacts() { - let directory = tempfile::tempdir().unwrap(); - checkout(directory.path()); - let artifacts = ArtifactDir::repository(directory.path()).unwrap(); - let digests = ["a", "b", "c"].map(|digit| digit.repeat(64)); - // Only the metadata counts: the artifacts themselves are neither read nor hashed. - record_provenance(directory.path(), &digests); - assert_eq!(artifacts.runtime_identity().unwrap(), identity_of(&digests)); - - let kernel = artifacts.artifact(KERNEL_PROVENANCE_NAME); - let fails = |content: Option<&str>, problem: &str| { - match content { - Some(content) => fs::write(&kernel, content).unwrap(), - None => fs::remove_file(&kernel).unwrap(), - } - let error = artifacts.runtime_identity().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert_eq!( - error.message(), - format!("the kernel build provenance {} {problem}", kernel.display()) - ); - }; - fails( - None, - "is missing, so the runtime cannot be identified: build the artifact with nvx.py, or \ - install a release with nvx.py download", - ); - fails(Some("{"), "is not valid JSON"); - let digest = &digests[1]; - fails( - Some(&format!(r#"{{"format": 2, "kernel_sha256": "{digest}"}}"#)), - "does not have format 1", - ); - fails( - Some(r#"{"format": 1, "initramfs_sha256": "0"}"#), - "has no SHA-256 digest in kernel_sha256", - ); - let upper = digest.to_ascii_uppercase(); - fails( - Some(&format!(r#"{{"format": 1, "kernel_sha256": "{upper}"}}"#)), - "has no SHA-256 digest in kernel_sha256", - ); - } - - #[test] - fn a_release_identifies_its_runtime_by_its_checksum_list() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let artifacts = ArtifactDir::release(directory.path()).unwrap(); - let digests = ["1", "2", "3"].map(|digit| digit.repeat(64)); - let other = "f".repeat(64); - let openvmm = format!("bin/{}", openvmm_executable()); - let list = artifacts.root().join(CHECKSUMS_NAME); - let write = |lines: &[(&str, &str)]| { - let text: String = lines - .iter() - .map(|(digest, name)| format!("{digest} {name}\n")) - .collect(); - fs::write(&list, text).unwrap(); - }; - let complete = [ - (other.as_str(), "SOURCE-MANIFEST.json"), - (digests[0].as_str(), openvmm.as_str()), - (other.as_str(), "guest/initramfs-ubuntu.cpio.gz"), - (digests[2].as_str(), "guest/initramfs.cpio.gz"), - (digests[1].as_str(), "guest/vmlinux"), - ]; - write(&complete); - let identity = artifacts.runtime_identity().unwrap(); - assert_eq!(identity, identity_of(&digests)); - - // A checkout that installed the release has the release's identity. - let installed = tempfile::tempdir().unwrap(); - checkout(installed.path()); - record_provenance(installed.path(), &digests); - let checkout = ArtifactDir::repository(installed.path()).unwrap(); - assert_eq!(checkout.runtime_identity().unwrap(), identity); - - let fails = |problem: String| { - let error = artifacts.runtime_identity().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert_eq!( - error.message(), - format!("the release checksum list {} {problem}", list.display()) - ); - }; - write(&complete[..4]); - fails("does not list guest/vmlinux".to_owned()); - let upper = other.to_ascii_uppercase(); - write(&[(upper.as_str(), "guest/vmlinux")]); - fails(format!( - "has a malformed line: {:?}", - format!("{upper} guest/vmlinux") - )); - write(&[(digests[1].as_str(), "")]); - fails(format!( - "has a malformed line: {:?}", - format!("{} ", digests[1]) - )); - write(&[ - (digests[1].as_str(), "guest/vmlinux"), - (other.as_str(), "guest/vmlinux"), - ]); - fails("lists guest/vmlinux twice".to_owned()); - fs::remove_file(&list).unwrap(); - fails( - "is missing, so the runtime cannot be identified: the release is incomplete".to_owned(), - ); - } - - #[cfg(unix)] - #[test] - fn metadata_that_is_not_a_regular_file_fails_at_once() { - use std::os::unix::fs::OpenOptionsExt; - use std::sync::mpsc; - use std::thread; - use std::time::Duration; - - use nix::sys::stat::Mode; - - let installed = tempfile::tempdir().unwrap(); - checkout(installed.path()); - record_provenance( - installed.path(), - &["a", "b", "c"].map(|digit| digit.repeat(64)), - ); - let repository = ArtifactDir::repository(installed.path()).unwrap(); - let extracted = tempfile::tempdir().unwrap(); - release(extracted.path()); - let released = ArtifactDir::release(extracted.path()).unwrap(); - for (artifacts, path, description) in [ - ( - &repository, - repository.artifact(KERNEL_PROVENANCE_NAME), - "kernel build provenance", - ), - ( - &released, - released.root().join(CHECKSUMS_NAME), - "release checksum list", - ), - ] { - let _ = fs::remove_file(&path); - nix::unistd::mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR).unwrap(); - // An open of a FIFO for reading that waits would wait for a writer, and a read of one - // would wait until its writer closes it, so the identity is read on a thread that the - // test can leave behind. - let refused = || { - let (sender, receiver) = mpsc::channel(); - let artifacts = artifacts.clone(); - thread::spawn(move || sender.send(artifacts.runtime_identity()).unwrap()); - let error = receiver - .recv_timeout(Duration::from_secs(10)) - .unwrap_or_else(|_| panic!("the read of the {description} waits on the FIFO")) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert_eq!( - error.message(), - format!("the {description} {} is not a regular file", path.display()) - ); - }; - refused(); - // A FIFO that has a writer opens at once, and is refused all the same, before a read - // waits for the writer to close it. - let reader = fs::OpenOptions::new() - .read(true) - .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()) - .open(&path) - .unwrap(); - let writer = fs::OpenOptions::new() - .write(true) - .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()) - .open(&path) - .unwrap(); - drop(reader); - refused(); - drop(writer); - } - } - - #[test] - fn located_artifacts_have_the_identity_of_their_directory() { - let directory = tempfile::tempdir().unwrap(); - release(directory.path()); - let digests = ["4", "5", "6"].map(|digit| digit.repeat(64)); - let text: String = [ - (&digests[0], format!("bin/{}", openvmm_executable())), - (&digests[1], "guest/vmlinux".to_owned()), - (&digests[2], "guest/initramfs.cpio.gz".to_owned()), - ] - .iter() - .map(|(digest, name)| format!("{digest} {name}\n")) - .collect(); - fs::write(directory.path().join(CHECKSUMS_NAME), text).unwrap(); - let located = Artifacts::from_dir(directory.path()).unwrap(); - assert_eq!( - located.artifact_dir(), - Some(&ArtifactDir::release(directory.path()).unwrap()) - ); - assert_eq!(located.runtime_identity().unwrap(), identity_of(&digests)); - - // Artifacts named by path have no install metadata. - let named = Artifacts::new(&located.openvmm, &located.kernel, &located.initrd); - assert_eq!(named.artifact_dir(), None); - let error = named.runtime_identity().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert!( - error.message().contains("supply their runtime identity"), - "{error}" - ); - // Nor does an artifact that replaced one of the directory's. - let mut replaced = located.clone(); - replaced.kernel = directory.path().join("vmlinux"); - let error = replaced.runtime_identity().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert_eq!( - error.message(), - format!( - "the guest kernel {} is not the one of {}, whose install metadata identifies the \ - artifacts", - replaced.kernel.display(), - located.artifact_dir().unwrap().root().display() - ) - ); - } - - #[cfg(feature = "bundled")] - #[test] - fn the_bundle_is_a_compatible_release() { - let bundled = Artifacts::bundled().unwrap(); - // A build that was asked to stage a release must have staged it. - let requested = env::var_os("NVX_BUNDLE_DIR").is_some() - && env::var_os("NVX_BUNDLE").is_none_or(|value| value != "skip"); - if requested && (cfg!(target_os = "linux") || cfg!(windows)) { - assert!(bundled.is_some(), "NVX_BUNDLE_DIR was not staged"); - } - if let Some(artifacts) = bundled { - for path in [&artifacts.openvmm, &artifacts.kernel, &artifacts.initrd] { - assert!(path.is_file(), "{} is missing", path.display()); - } - } - } -} diff --git a/nvx-rs/src/host/artifacts/dir.rs b/nvx-rs/src/host/artifacts/dir.rs new file mode 100644 index 000000000..add08194d --- /dev/null +++ b/nvx-rs/src/host/artifacts/dir.rs @@ -0,0 +1,346 @@ +//! The directories of the artifacts that `nvx.py` runs, repository checkouts and extracted +//! releases, and the paths of their artifacts. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use super::identity::{provenance_identity, read_metadata, sha256_identity}; +use super::require::required; +use super::{ + Artifacts, CHECKSUMS_NAME, Guest, INITRAMFS_PROVENANCE_NAME, KERNEL_NAME, + KERNEL_PROVENANCE_NAME, OPENVMM_PROVENANCE_NAME, RELEASE_BIN_DIR, RELEASE_GUEST_DIR, + REPO_BUILD_DIR, REPO_OPENVMM_DIR, absolute, is_release_dir, openvmm_executable, +}; +use crate::error::{Error, Result}; +use crate::host::store::{ArtifactIdentity, RuntimeIdentity}; + +/// The layout of a directory of NVX artifacts. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Layout { + /// A repository checkout: the guest artifacts in `build/`, and OpenVMM in + /// `openvmm/target/release/`, where a local build writes them and where `nvx.py download` + /// installs a release. + Repository, + /// An extracted release package: the guest artifacts in `guest/`, and OpenVMM in `bin/`. + Release, +} + +/// A directory of the artifacts that `nvx.py` runs: a repository checkout or an extracted +/// release, with the paths that `artifact_path` and `openvmm_binary_path` of +/// `scripts/nvx_tools/common.py` give for a checkout. +/// +/// Paths are joined to the directory, made absolute, without checking that they exist; the +/// `require_*` methods check them with the messages of `nvx.py`. Unlike [`Artifacts`], nothing +/// here reads the release's `SOURCE-MANIFEST.json`, which `nvx.py` does not check either. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArtifactDir { + /// The absolute directory. + root: PathBuf, + /// Its layout. + layout: Layout, +} + +impl ArtifactDir { + /// Uses a repository checkout. + /// + /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) if a relative + /// `root` meets an unreadable current directory. + pub fn repository(root: impl AsRef) -> Result { + Self::new(root.as_ref(), Layout::Repository) + } + + /// Uses an extracted release package. + /// + /// Fails as [`repository`](Self::repository) does. + pub fn release(root: impl AsRef) -> Result { + Self::new(root.as_ref(), Layout::Release) + } + + /// Uses a directory in the layout that its built artifacts reveal, as + /// [`Artifacts::from_dir`] tells them apart: a directory that contains `bin/openvmm[.exe]`, + /// `guest/vmlinux`, or `guest/initramfs.cpio.gz` is a release, and any other directory is a + /// repository checkout. + /// + /// Fails as [`repository`](Self::repository) does. + pub fn from_dir(directory: impl AsRef) -> Result { + let directory = directory.as_ref(); + let layout = if is_release_dir(directory) { + Layout::Release + } else { + Layout::Repository + }; + Self::new(directory, layout) + } + + /// Returns the directory of `layout` at `root`, made absolute. + fn new(root: &Path, layout: Layout) -> Result { + Ok(Self { + root: absolute(root)?, + layout, + }) + } + + /// Returns the absolute directory. + pub fn root(&self) -> &Path { + &self.root + } + + /// Returns the directory's layout. + pub fn layout(&self) -> Layout { + self.layout + } + + /// Returns the path of the guest artifact `name`, `artifact_path(name)` of `nvx.py`: + /// `build/NAME` in a checkout, and `guest/NAME` in a release. + pub fn artifact(&self, name: &str) -> PathBuf { + let directory = match self.layout { + Layout::Repository => REPO_BUILD_DIR, + Layout::Release => RELEASE_GUEST_DIR, + }; + self.root.join(directory).join(name) + } + + /// Returns the path of the OpenVMM executable, `openvmm_binary_path()` of `nvx.py`: + /// `openvmm/target/release/openvmm[.exe]` in a checkout, and `bin/openvmm[.exe]` in a + /// release. + pub fn openvmm(&self) -> PathBuf { + let directory = match self.layout { + Layout::Repository => REPO_OPENVMM_DIR.iter().collect(), + Layout::Release => PathBuf::from(RELEASE_BIN_DIR), + }; + self.root.join(directory).join(openvmm_executable()) + } + + /// Returns the path of the guest kernel, which every guest boots. + pub fn kernel(&self) -> PathBuf { + self.artifact(KERNEL_NAME) + } + + /// Returns the path of a guest's initramfs. + pub fn initramfs(&self, guest: Guest) -> PathBuf { + self.artifact(guest.initramfs_name()) + } + + /// Returns the path of the package manifest of a guest's initramfs. + pub fn package_manifest(&self, guest: Guest) -> PathBuf { + self.artifact(&guest.package_manifest_name()) + } + + /// Returns the path of a guest's distro layer, if the guest provides one. + pub fn distro_layer(&self, guest: Guest) -> Option { + guest.distro_layer_name().map(|name| self.artifact(name)) + } + + /// Returns the path of the manifest of a guest's distro layer, if the guest provides one. + pub fn distro_manifest(&self, guest: Guest) -> Option { + guest + .distro_manifest_name() + .map(|name| self.artifact(&name)) + } + + /// Checks that the OpenVMM executable is a regular file and returns its path. + /// + /// Fails with [`ErrorCode::ArtifactMissing`] and the message `OpenVMM release binary not + /// found: PATH`. + /// + /// [`ErrorCode::ArtifactMissing`]: crate::ErrorCode::ArtifactMissing + pub fn require_openvmm(&self) -> Result { + required(self.openvmm(), "OpenVMM release binary") + } + + /// Checks that the guest kernel is a regular file and returns its path. + /// + /// Fails with [`ErrorCode::ArtifactMissing`] and the message `Linux direct kernel not found: + /// PATH`. + /// + /// [`ErrorCode::ArtifactMissing`]: crate::ErrorCode::ArtifactMissing + pub fn require_kernel(&self) -> Result { + required(self.kernel(), "Linux direct kernel") + } + + /// Checks that a guest's initramfs is a regular file and returns its path, as `nvx-rs run` + /// does before it boots the guest. + /// + /// Fails with [`ErrorCode::ArtifactMissing`] and the message `DISTRIBUTION initramfs not + /// found: PATH`, such as `Ubuntu Base initramfs not found: PATH`. + /// + /// [`ErrorCode::ArtifactMissing`]: crate::ErrorCode::ArtifactMissing + pub fn require_initramfs(&self, guest: Guest) -> Result { + required( + self.initramfs(guest), + &format!("{} initramfs", guest.distribution()), + ) + } + + /// Returns the artifacts of a sandbox, as `nvx-rs sandbox run` and `nvx-rs sandbox start` + /// require them: the OpenVMM executable, the kernel, and the Alpine initramfs, in that order, + /// located in this directory. + /// + /// Fails with [`ErrorCode::ArtifactMissing`] for the first one that is not a regular file, + /// with the messages of [`require_openvmm`](Self::require_openvmm) and + /// [`require_kernel`](Self::require_kernel), or `initramfs not found: PATH`. + /// + /// [`ErrorCode::ArtifactMissing`]: crate::ErrorCode::ArtifactMissing + pub fn sandbox_artifacts(&self) -> Result { + self.require_openvmm()?; + self.require_kernel()?; + required(self.initramfs(Guest::Alpine), "initramfs")?; + Ok(Artifacts::located(self)) + } + + /// Returns the paths of every artifact, for a caller to show or record. + pub fn paths(&self) -> ArtifactPaths { + ArtifactPaths { + layout: self.layout, + root: self.root.clone(), + openvmm: self.openvmm(), + kernel: self.kernel(), + guests: Guest::ALL + .iter() + .map(|&guest| GuestPaths { + name: guest.name(), + distribution: guest.distribution(), + initramfs: self.initramfs(guest), + package_manifest: self.package_manifest(guest), + distro_layer: self.distro_layer(guest), + distro_manifest: self.distro_manifest(guest), + }) + .collect(), + } + } + + /// Returns the identity of the sandbox runtime of this directory, the one that + /// [`sandbox_artifacts`](Self::sandbox_artifacts) and [`Artifacts::from_dir`] name: its + /// OpenVMM executable, its kernel, and its Alpine initramfs, as the directory's install + /// metadata names them, under the contracts that this crate speaks. + /// + /// The metadata names each artifact by the SHA-256 digest that its build or its package + /// recorded, so nothing here reads or hashes an artifact: + /// + /// - A release lists the digest of each of its files in its `SHA256SUMS`, under the file's + /// path in the release: `bin/openvmm[.exe]`, `guest/vmlinux`, and + /// `guest/initramfs.cpio.gz`. + /// - A repository checkout keeps the build provenance of each artifact beside its guest + /// artifacts: `build/openvmm.provenance.json`, `build/vmlinux.provenance.json`, and + /// `build/initramfs.provenance.json`, of format 1, whose `executable_sha256`, + /// `kernel_sha256`, and `initramfs_sha256` are the digests. `nvx.py` records them as it + /// builds an artifact, and `nvx.py download` installs those of a release. + /// + /// The artifacts of a release therefore have the same identity in the release and in a + /// checkout that installed it. Fails with + /// [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) when the metadata is missing or + /// unreadable, or names no valid digest of an artifact. A file of metadata that is not a + /// regular file, such as a FIFO, is refused without being waited on. + pub fn runtime_identity(&self) -> Result { + let [openvmm, kernel, initramfs] = match self.layout { + Layout::Release => self.listed_identities()?, + Layout::Repository => [ + provenance_identity( + &self.artifact(OPENVMM_PROVENANCE_NAME), + "OpenVMM", + "executable_sha256", + )?, + provenance_identity( + &self.artifact(KERNEL_PROVENANCE_NAME), + "kernel", + "kernel_sha256", + )?, + provenance_identity( + &self.artifact(INITRAMFS_PROVENANCE_NAME), + "initramfs", + "initramfs_sha256", + )?, + ], + }; + Ok(RuntimeIdentity::new(openvmm, kernel, initramfs)) + } + + /// Returns the identities of the OpenVMM executable, kernel, and Alpine initramfs of a + /// release, as its `SHA256SUMS` lists their digests: one line for each file, its 64 + /// lowercase hexadecimal digits, two spaces, and its path in the release. + fn listed_identities(&self) -> Result<[ArtifactIdentity; 3]> { + let path = self.root.join(CHECKSUMS_NAME); + let description = "release checksum list"; + let text = read_metadata(&path, description, "the release is incomplete")?; + let mut identities = HashMap::new(); + for line in text.lines() { + let malformed = || { + Error::unavailable(format!( + "the {description} {} has a malformed line: {line:?}", + path.display() + )) + }; + let (digest, name) = line.split_once(" ").ok_or_else(malformed)?; + let identity = sha256_identity(digest) + .filter(|_| !name.is_empty()) + .ok_or_else(malformed)?; + if identities.insert(name, identity).is_some() { + return Err(Error::unavailable(format!( + "the {description} {} lists {name} twice", + path.display() + ))); + } + } + let mut listed = |name: String| { + identities.remove(name.as_str()).ok_or_else(|| { + Error::unavailable(format!( + "the {description} {} does not list {name}", + path.display() + )) + }) + }; + Ok([ + listed(format!("{RELEASE_BIN_DIR}/{}", openvmm_executable()))?, + listed(format!("{RELEASE_GUEST_DIR}/{KERNEL_NAME}"))?, + listed(format!( + "{RELEASE_GUEST_DIR}/{}", + Guest::Alpine.initramfs_name() + ))?, + ]) + } +} + +/// The paths of the artifacts of an [`ArtifactDir`], which serialize as a JSON object such as: +/// +/// ```json +/// {"layout": "repository", "root": "/nvx", "openvmm": "/nvx/openvmm/target/release/openvmm", +/// "kernel": "/nvx/build/vmlinux", "guests": [{"name": "alpine", +/// "distribution": "Alpine Linux", "initramfs": "/nvx/build/initramfs.cpio.gz", +/// "package_manifest": "/nvx/build/initramfs.cpio.gz.packages.json", "distro_layer": null, +/// "distro_manifest": null}, ...]} +/// ``` +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct ArtifactPaths { + /// The directory's layout. + pub layout: Layout, + /// The absolute directory. + pub root: PathBuf, + /// The OpenVMM executable. + pub openvmm: PathBuf, + /// The guest kernel. + pub kernel: PathBuf, + /// The artifacts of each guest, in the order of [`Guest::ALL`]. + pub guests: Vec, +} + +/// The paths of the artifacts of one guest. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct GuestPaths { + /// The guest's name, [`Guest::name`]. + pub name: &'static str, + /// The guest's distribution, [`Guest::distribution`]. + pub distribution: &'static str, + /// The initramfs. + pub initramfs: PathBuf, + /// The package manifest of the initramfs. + pub package_manifest: PathBuf, + /// The distro layer, if the guest provides one. + pub distro_layer: Option, + /// The manifest of the distro layer, if the guest provides one. + pub distro_manifest: Option, +} diff --git a/nvx-rs/src/host/artifacts/guest.rs b/nvx-rs/src/host/artifacts/guest.rs new file mode 100644 index 000000000..8b9f7268e --- /dev/null +++ b/nvx-rs/src/host/artifacts/guest.rs @@ -0,0 +1,105 @@ +//! The Linux guests that `nvx.py` builds and runs, and the names of their artifacts. + +use std::fmt; + +use super::{DISTRO_MANIFEST_SUFFIX, PACKAGE_MANIFEST_SUFFIX}; +use crate::error::{Error, Result}; + +/// A Linux guest that `nvx.py` builds and runs, as `scripts/nvx_tools/guests.py` describes it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Guest { + /// Alpine Linux: the default guest of `nvx-rs run`, and the one that runs sandboxes. + Alpine, + /// Ubuntu Base, which also provides the distro layer of sandbox workloads. + Ubuntu, + /// Azure Linux. + AzureLinux, +} + +impl Guest { + /// Every guest, in the order in which `nvx.py` lists them. + pub const ALL: [Self; 3] = [Self::Alpine, Self::Ubuntu, Self::AzureLinux]; + + /// Returns the guest's name in `nvx.py` options: `alpine`, `ubuntu`, or `azurelinux`. + pub const fn name(self) -> &'static str { + match self { + Self::Alpine => "alpine", + Self::Ubuntu => "ubuntu", + Self::AzureLinux => "azurelinux", + } + } + + /// Returns the guest named `name`. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and the message + /// `unsupported guest "NAME"; choose alpine, ubuntu, azurelinux`, which quotes the name with + /// Rust's debug formatting. + pub fn from_name(name: &str) -> Result { + Self::ALL + .into_iter() + .find(|guest| guest.name() == name) + .ok_or_else(|| { + let names: Vec<&str> = Self::ALL.iter().map(|guest| guest.name()).collect(); + Error::unsupported(format!( + "unsupported guest {name:?}; choose {}", + names.join(", ") + )) + }) + } + + /// Returns the name of the guest's distribution, as messages show it: `Alpine Linux`, + /// `Ubuntu Base`, or `Azure Linux`. + pub const fn distribution(self) -> &'static str { + match self { + Self::Alpine => "Alpine Linux", + Self::Ubuntu => "Ubuntu Base", + Self::AzureLinux => "Azure Linux", + } + } + + /// Returns the file name of the guest's initramfs. + pub const fn initramfs_name(self) -> &'static str { + match self { + Self::Alpine => "initramfs.cpio.gz", + Self::Ubuntu => "initramfs-ubuntu.cpio.gz", + Self::AzureLinux => "initramfs-azurelinux.cpio.gz", + } + } + + /// Returns the RAM, in MiB, with which `nvx-rs run` boots the guest without `--memory-mib`: + /// the `default_memory_mib` of its descriptor in `scripts/nvx_tools/guests.py`, which + /// `nvx.py test-microvm` also uses, and which a test of `nvx.py` checks against the help of + /// `nvx-rs run`. + pub const fn default_memory_mib(self) -> u32 { + match self { + Self::Alpine => 128, + Self::Ubuntu | Self::AzureLinux => 512, + } + } + + /// Returns the file name of the package manifest of the guest's initramfs. + pub fn package_manifest_name(self) -> String { + format!("{}{PACKAGE_MANIFEST_SUFFIX}", self.initramfs_name()) + } + + /// Returns the file name of the guest's EROFS distro layer, `GUEST-distro.erofs`, if the + /// guest provides one: only Ubuntu does. + pub const fn distro_layer_name(self) -> Option<&'static str> { + match self { + Self::Ubuntu => Some("ubuntu-distro.erofs"), + Self::Alpine | Self::AzureLinux => None, + } + } + + /// Returns the file name of the manifest of the guest's distro layer, if it has one. + pub fn distro_manifest_name(self) -> Option { + self.distro_layer_name() + .map(|name| format!("{name}{DISTRO_MANIFEST_SUFFIX}")) + } +} + +impl fmt::Display for Guest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.name()) + } +} diff --git a/nvx-rs/src/host/artifacts/identity.rs b/nvx-rs/src/host/artifacts/identity.rs new file mode 100644 index 000000000..17ed45563 --- /dev/null +++ b/nvx-rs/src/host/artifacts/identity.rs @@ -0,0 +1,116 @@ +//! The identities of artifacts, as the install metadata of a release or a repository checkout +//! names them. + +use std::fs; +use std::io::{self, Read}; +use std::path::Path; + +use serde_json::Value; + +use super::{MAX_METADATA_LEN, PROVENANCE_FORMAT}; +use crate::error::{Error, Result}; +use crate::host::store::ArtifactIdentity; + +/// Returns the identity of an artifact whose SHA-256 digest is `digest`, if it holds 64 lowercase +/// hexadecimal digits, as `nvx.py` writes digests and requires them. +pub(super) fn sha256_identity(digest: &str) -> Option { + if digest + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + ArtifactIdentity::sha256(digest).ok() + } else { + None + } +} + +/// Reads the file of install metadata at `path`, which messages call `description`, as text of +/// at most [`MAX_METADATA_LEN`] bytes. A missing file fails with `advice` on how to provide it. +/// +/// The open does not wait, as it would on Unix for a FIFO without a writer, and a file that is +/// not a regular file is refused before anything is read from it, as a FIFO whose writer keeps it +/// open would keep a read waiting: a sandbox's transitions read the metadata while they hold its +/// lock. +pub(super) fn read_metadata(path: &Path, description: &str, advice: &str) -> Result { + let unreadable = |error: io::Error| { + if error.kind() == io::ErrorKind::NotFound { + Error::unavailable(format!( + "the {description} {} is missing, so the runtime cannot be identified: {advice}", + path.display() + )) + } else { + Error::unavailable(format!("cannot read the {description} {}", path.display())) + .with_source(error) + } + }; + let not_regular = || { + Error::unavailable(format!( + "the {description} {} is not a regular file", + path.display() + )) + }; + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + // Reads of a regular file do not depend on the flag. + options.custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()); + } + let opened = options.open(path); + #[cfg(unix)] + { + // A socket refuses an open. + if let Err(error) = &opened + && error.raw_os_error() == Some(nix::errno::Errno::ENXIO as i32) + { + return Err(not_regular()); + } + } + let file = opened.map_err(unreadable)?; + if !file.metadata().map_err(unreadable)?.file_type().is_file() { + return Err(not_regular()); + } + let mut text = String::new(); + file.take(MAX_METADATA_LEN + 1) + .read_to_string(&mut text) + .map_err(unreadable)?; + if text.len() as u64 > MAX_METADATA_LEN { + return Err(Error::unavailable(format!( + "the {description} {} is larger than {MAX_METADATA_LEN} bytes", + path.display() + ))); + } + Ok(text) +} + +/// Returns the identity of the artifact `what` that the build provenance at `path` records: the +/// SHA-256 digest of its `field`, in a provenance of format [`PROVENANCE_FORMAT`]. +pub(super) fn provenance_identity( + path: &Path, + what: &str, + field: &str, +) -> Result { + let description = format!("{what} build provenance"); + let text = read_metadata( + path, + &description, + "build the artifact with nvx.py, or install a release with nvx.py download", + )?; + let invalid = |problem: &str| { + Error::unavailable(format!("the {description} {} {problem}", path.display())) + }; + let provenance: Value = serde_json::from_str(&text) + .map_err(|error| invalid("is not valid JSON").with_source(error))?; + if provenance.get("format").and_then(Value::as_u64) != Some(PROVENANCE_FORMAT) { + return Err(invalid(&format!( + "does not have format {PROVENANCE_FORMAT}" + ))); + } + provenance + .get(field) + .and_then(Value::as_str) + .and_then(sha256_identity) + .ok_or_else(|| invalid(&format!("has no SHA-256 digest in {field}"))) +} diff --git a/nvx-rs/src/host/artifacts/mod.rs b/nvx-rs/src/host/artifacts/mod.rs new file mode 100644 index 000000000..2b148ee3a --- /dev/null +++ b/nvx-rs/src/host/artifacts/mod.rs @@ -0,0 +1,399 @@ +//! Locating the OpenVMM executable and the NVX guest artifacts. +//! +//! [`Artifacts`] names the three files that a VM runs. They come from an extracted NVX release +//! archive, an NVX repository checkout, explicit paths, or a bundle that the build script staged +//! with the `bundled` feature. [`Artifacts::discover`] tries these sources in a fixed order so +//! consumers such as MXC need no NVX-specific configuration. +//! +//! [`ArtifactDir`] names every artifact of the guests that `nvx.py` runs, which [`Guest`] +//! describes, in a repository checkout or an extracted release: +//! +//! | Artifact | Repository layout | Release layout | +//! | --- | --- | --- | +//! | OpenVMM | `openvmm/target/release/openvmm[.exe]` | `bin/openvmm[.exe]` | +//! | Kernel | `build/vmlinux` | `guest/vmlinux` | +//! | Alpine initramfs | `build/initramfs.cpio.gz` | `guest/initramfs.cpio.gz` | +//! | Ubuntu initramfs | `build/initramfs-ubuntu.cpio.gz` | `guest/initramfs-ubuntu.cpio.gz` | +//! | Azure Linux initramfs | `build/initramfs-azurelinux.cpio.gz` | `guest/initramfs-azurelinux.cpio.gz` | +//! | Ubuntu distro layer | `build/ubuntu-distro.erofs` | `guest/ubuntu-distro.erofs` | +//! +//! Each initramfs has a package manifest beside it, `NAME.packages.json`, and the distro layer a +//! manifest, `NAME.manifest.json`. A release package holds the guest artifacts that its +//! `SOURCE-MANIFEST.json` declares, and `nvx.py download` installs them into the repository +//! layout of a checkout, with the release's OpenVMM executable. The paths and messages are those +//! of `artifact_path`, `openvmm_binary_path`, and `require_file` in +//! `scripts/nvx_tools/common.py`, so that `nvx-rs` and the `nvx.py` commands that build, +//! install, benchmark, and test the guests agree on where each artifact is. + +mod dir; +mod guest; +mod identity; +mod require; + +use std::env; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::contract; +use crate::error::{Error, ErrorCode, Result}; +use crate::host::platform; +use crate::host::store::RuntimeIdentity; + +pub use self::dir::{ArtifactDir, ArtifactPaths, GuestPaths, Layout}; +pub use self::guest::Guest; +#[cfg(feature = "cli")] +pub(crate) use self::require::require_artifact; +pub(crate) use self::require::require_existing; + +/// Name of the release manifest at the root of a release archive or repository checkout. +pub const MANIFEST_NAME: &str = "SOURCE-MANIFEST.json"; + +/// Directory staged by the build script when the `bundled` feature is enabled. +#[cfg(feature = "bundled")] +const BUNDLED_DIR: Option<&str> = option_env!("NVX_BUNDLED_ARTIFACTS_DIR"); + +/// The OpenVMM executable and the NVX guest kernel and initramfs. +/// +/// Every path is used as given. Resolve relative paths with [`absolute`] before a VM runs them, +/// and check that the files exist, because nothing here does. +/// +/// Artifacts located in a release or a repository checkout keep its [`ArtifactDir`], whose +/// install metadata names their [`runtime_identity`](Self::runtime_identity). Artifacts named by +/// path, through [`new`](Self::new) or the explicit variables of [`discover`](Self::discover), +/// have none: their caller supplies the runtime identity of a launch. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct Artifacts { + /// The `openvmm` executable. + pub openvmm: PathBuf, + /// The NVX guest kernel (`vmlinux`). + pub kernel: PathBuf, + /// The NVX Alpine initramfs (`initramfs.cpio.gz`): the guest userland and its control agent. + pub initrd: PathBuf, + /// The release or checkout in which the artifacts were located, if they were. + directory: Option, +} + +impl Artifacts { + /// Names the three artifacts that a VM needs. + pub fn new( + openvmm: impl Into, + kernel: impl Into, + initrd: impl Into, + ) -> Self { + Self { + openvmm: openvmm.into(), + kernel: kernel.into(), + initrd: initrd.into(), + directory: None, + } + } + + /// Returns the sandbox artifacts of `directory`, which they remember. + fn located(directory: &ArtifactDir) -> Self { + Self { + directory: Some(directory.clone()), + ..Self::new( + directory.openvmm(), + directory.kernel(), + directory.initramfs(Guest::Alpine), + ) + } + } + + /// Returns the release or repository checkout in which the artifacts were located, or `None` + /// for artifacts named by path. + pub fn artifact_dir(&self) -> Option<&ArtifactDir> { + self.directory.as_ref() + } + + /// Returns the runtime identity of these artifacts, which the install metadata of the release + /// or checkout in which they were located names, as [`ArtifactDir::runtime_identity`] reads + /// it, without reading or hashing an artifact. + /// + /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) for artifacts named by + /// path, whose caller supplies their identity instead, for artifacts whose paths no longer + /// name the ones of their directory, and as [`ArtifactDir::runtime_identity`] fails. + pub fn runtime_identity(&self) -> Result { + let Some(directory) = &self.directory else { + return Err(Error::unavailable( + "the artifacts are named by path, so no install metadata identifies them: locate \ + them in an NVX release or repository checkout, or supply their runtime identity", + )); + }; + let located = Self::located(directory); + for (path, expected, description) in [ + (&self.openvmm, &located.openvmm, "OpenVMM executable"), + (&self.kernel, &located.kernel, "guest kernel"), + (&self.initrd, &located.initrd, "guest initramfs"), + ] { + if path != expected { + return Err(Error::unavailable(format!( + "the {description} {} is not the one of {}, whose install metadata \ + identifies the artifacts", + path.display(), + directory.root().display() + ))); + } + } + directory.runtime_identity() + } + + /// Checks that this host can run NVX VMs and that the three files exist. + /// + /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) otherwise, with the + /// code [`ErrorCode::ArtifactMissing`] for a missing file. It does not check the hypervisor; + /// [`probe_hypervisor`](super::platform::probe_hypervisor) does. + pub fn probe(&self) -> Result<()> { + if !platform::SUPPORTED { + return Err(Error::unavailable( + "the openvmm backend supports Linux and Windows hosts only", + )); + } + for (path, description) in [ + (&self.openvmm, "OpenVMM executable"), + (&self.kernel, "NVX guest kernel"), + (&self.initrd, "NVX guest initramfs"), + ] { + if !path.is_file() { + return Err(Error::new( + ErrorCode::ArtifactMissing, + format!("{description} not found: {}", path.display()), + )); + } + } + Ok(()) + } + + /// Uses an extracted NVX release archive: `bin/openvmm[.exe]`, `guest/vmlinux`, and + /// `guest/initramfs.cpio.gz`. + /// + /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) when the release's + /// `SOURCE-MANIFEST.json` declares an incompatible control contract. + pub fn from_release_dir(release_dir: impl AsRef) -> Result { + Self::from_artifact_dir(&ArtifactDir::release(release_dir.as_ref())?) + } + + /// Uses an NVX repository checkout after `nvx.py download` or a local build: + /// `openvmm/target/release/openvmm[.exe]`, `build/vmlinux`, and `build/initramfs.cpio.gz`. + pub fn from_repo_layout(repo_root: impl AsRef) -> Result { + Self::from_artifact_dir(&ArtifactDir::repository(repo_root.as_ref())?) + } + + /// Uses a directory in either the release or the repository layout. + /// + /// A repository checkout also has a `guest/` directory, which holds the guest sources, so the + /// layouts are told apart by their built artifacts instead: a directory that contains + /// `bin/openvmm[.exe]`, `guest/vmlinux`, or `guest/initramfs.cpio.gz` is a release, and any + /// other directory is a repository checkout. + pub fn from_dir(directory: impl AsRef) -> Result { + Self::from_artifact_dir(&ArtifactDir::from_dir(directory.as_ref())?) + } + + /// Uses the OpenVMM executable, guest kernel, and Alpine initramfs of `directory`, once its + /// `SOURCE-MANIFEST.json` declares the control contract that this crate speaks. + /// + /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) as + /// [`check_manifest`] does. + fn from_artifact_dir(directory: &ArtifactDir) -> Result { + check_manifest(&directory.root().join(MANIFEST_NAME))?; + Ok(Self::located(directory)) + } + + /// Returns the artifacts staged by the build script, if the crate was built with the + /// `bundled` feature for Linux or Windows and the staged directory still exists. + /// + /// The staged directory lives in Cargo's build output, so it suits development and tests on + /// the build host. Deployments copy it next to their executable instead; see + /// [`Artifacts::discover`]. + pub fn bundled() -> Result> { + #[cfg(feature = "bundled")] + if let Some(directory) = BUNDLED_DIR.map(Path::new) + && directory.is_dir() + { + return Self::from_release_dir(directory).map(Some); + } + Ok(None) + } + + /// Locates the artifacts on this host. + /// + /// Sources are tried in this order, and the first one present wins: + /// + /// 1. The [`ENV_OPENVMM`](Self::ENV_OPENVMM), [`ENV_KERNEL`](Self::ENV_KERNEL), and + /// [`ENV_INITRD`](Self::ENV_INITRD) variables, which must be set together. + /// 2. The [`ENV_DIR`](Self::ENV_DIR) variable, naming a release or repository directory. + /// 3. The [`BESIDE_EXE_DIR`](Self::BESIDE_EXE_DIR) directory next to the current executable, + /// in the release layout. Consumers ship the bundle there; with the `bundled` feature the + /// build script exports its staged directory to dependent build scripts as + /// `DEP_NVX_ARTIFACTS_DIR` for this purpose. + /// 4. The directory staged by the build script ([`Artifacts::bundled`]). + /// + /// Fails with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) when no source is + /// present or the chosen source is unusable. It does not check that the files exist. + pub fn discover() -> Result { + let exe_dir = env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(Path::to_path_buf)); + discover_with(|name| env::var_os(name), exe_dir.as_deref(), Self::bundled) + } + + /// Environment variable naming the `openvmm` executable. + pub const ENV_OPENVMM: &'static str = "NVX_OPENVMM"; + /// Environment variable naming the guest kernel. + pub const ENV_KERNEL: &'static str = "NVX_KERNEL"; + /// Environment variable naming the control initramfs. + pub const ENV_INITRD: &'static str = "NVX_INITRD"; + /// Environment variable naming a release or repository directory. + pub const ENV_DIR: &'static str = "NVX_ARTIFACTS_DIR"; + /// Name of the release-layout directory that [`Artifacts::discover`] looks for next to the + /// current executable. + pub const BESIDE_EXE_DIR: &'static str = "nvx"; +} + +/// Implements artifact discovery with injectable environment and bundle sources for tests. +fn discover_with( + variable: impl Fn(&str) -> Option, + exe_dir: Option<&Path>, + bundled: impl FnOnce() -> Result>, +) -> Result { + let explicit = [ + Artifacts::ENV_OPENVMM, + Artifacts::ENV_KERNEL, + Artifacts::ENV_INITRD, + ] + .map(|name| variable(name).filter(|value| !value.is_empty())); + match explicit { + [Some(openvmm), Some(kernel), Some(initrd)] => { + return Ok(Artifacts::new(openvmm, kernel, initrd)); + } + [None, None, None] => {} + _ => { + return Err(Error::unavailable(format!( + "set all or none of {}, {}, and {}", + Artifacts::ENV_OPENVMM, + Artifacts::ENV_KERNEL, + Artifacts::ENV_INITRD + ))); + } + } + if let Some(directory) = variable(Artifacts::ENV_DIR).filter(|value| !value.is_empty()) { + return Artifacts::from_dir(PathBuf::from(directory)); + } + if let Some(directory) = exe_dir.map(|directory| directory.join(Artifacts::BESIDE_EXE_DIR)) + && directory.join(MANIFEST_NAME).is_file() + { + return Artifacts::from_release_dir(directory); + } + if let Some(artifacts) = bundled()? { + return Ok(artifacts); + } + Err(Error::new( + ErrorCode::ArtifactMissing, + format!( + "cannot locate the OpenVMM executable and NVX guest artifacts: set {} or {}, {}, and \ + {}, place an NVX release in a {:?} directory next to the executable, or build the \ + nvx crate with the bundled feature", + Artifacts::ENV_DIR, + Artifacts::ENV_OPENVMM, + Artifacts::ENV_KERNEL, + Artifacts::ENV_INITRD, + Artifacts::BESIDE_EXE_DIR, + ), + )) +} + +/// Returns whether `directory` is in the release layout: whether it contains `bin/openvmm[.exe]`, +/// `guest/vmlinux`, or `guest/initramfs.cpio.gz`. +fn is_release_dir(directory: &Path) -> bool { + let guest = directory.join(RELEASE_GUEST_DIR); + [ + directory.join(RELEASE_BIN_DIR).join(openvmm_executable()), + guest.join(KERNEL_NAME), + guest.join(Guest::Alpine.initramfs_name()), + ] + .iter() + .any(|path| path.is_file()) +} + +/// Returns the file name of the OpenVMM executable on this host. +pub fn openvmm_executable() -> &'static str { + if cfg!(windows) { + "openvmm.exe" + } else { + "openvmm" + } +} + +/// Resolves a relative path against the current directory, failing with +/// [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable). +pub fn absolute(path: &Path) -> Result { + std::path::absolute(path).map_err(|error| { + Error::unavailable(format!("cannot resolve {}", path.display())).with_source(error) + }) +} + +/// Reads and parses the JSON file at `path`, naming it with `description` in errors. +fn read_json(path: &Path, description: &str) -> Result { + let text = fs::read_to_string(path).map_err(|error| { + Error::unavailable(format!("cannot read {description} {}", path.display())) + .with_source(error) + })?; + serde_json::from_str(&text).map_err(|error| { + Error::unavailable(format!( + "{description} {} is not valid JSON", + path.display() + )) + .with_source(error) + }) +} + +/// Checks that an NVX `SOURCE-MANIFEST.json` declares the control contract this crate speaks, +/// failing with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable). +pub fn check_manifest(path: &Path) -> Result<()> { + let manifest = read_json(path, "NVX manifest")?; + if contract::manifest_is_compatible(&manifest) { + Ok(()) + } else { + Err(Error::unavailable(format!( + "NVX manifest {} does not declare the {} control contract", + path.display(), + contract::CONTROL_CONTRACT_REVISION + ))) + } +} + +/// File name of the guest kernel in both layouts. +pub const KERNEL_NAME: &str = "vmlinux"; +/// Suffix of the package manifest that lists the packages of an initramfs. +pub const PACKAGE_MANIFEST_SUFFIX: &str = ".packages.json"; +/// Suffix of the manifest of a distro layer, which binds the layer's digest and packages. +pub const DISTRO_MANIFEST_SUFFIX: &str = ".manifest.json"; +/// Repository-layout directory of guest artifacts. +const REPO_BUILD_DIR: &str = "build"; +/// Repository-layout path of the OpenVMM build output directory. +const REPO_OPENVMM_DIR: [&str; 3] = ["openvmm", "target", "release"]; +/// Release-layout directory of the OpenVMM executable. +const RELEASE_BIN_DIR: &str = "bin"; +/// Release-layout directory of the guest artifacts. +const RELEASE_GUEST_DIR: &str = "guest"; +/// Name of the list of the SHA-256 digests of the files of a release, at its root. +pub const CHECKSUMS_NAME: &str = "SHA256SUMS"; +/// Name of the build provenance of the OpenVMM executable, beside the guest artifacts of a +/// repository checkout. +pub const OPENVMM_PROVENANCE_NAME: &str = "openvmm.provenance.json"; +/// Name of the build provenance of the guest kernel, beside it. +pub const KERNEL_PROVENANCE_NAME: &str = "vmlinux.provenance.json"; +/// Name of the build provenance of the Alpine initramfs, beside it. +pub const INITRAMFS_PROVENANCE_NAME: &str = "initramfs.provenance.json"; +/// Format of the build provenance that this crate reads. +const PROVENANCE_FORMAT: u64 = 1; +/// Longest file of install metadata that this crate reads, in bytes. A release's checksum list +/// holds one line for each of its files, and a build provenance a few fields. +const MAX_METADATA_LEN: u64 = 1 << 20; + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/artifacts/require.rs b/nvx-rs/src/host/artifacts/require.rs new file mode 100644 index 000000000..a941a8b41 --- /dev/null +++ b/nvx-rs/src/host/artifacts/require.rs @@ -0,0 +1,35 @@ +//! The checks that the artifacts that a launch runs are regular files. + +use std::path::{Path, PathBuf}; + +use crate::error::{Error, ErrorCode, Result}; +use crate::host::paths; + +/// Checks that `path` is a regular file, as `require_file` of `nvx.py` does, and returns it, or +/// fails with [`ErrorCode::ArtifactMissing`]. +pub(super) fn required(path: PathBuf, description: &str) -> Result { + require_artifact(&path, description)?; + Ok(path) +} + +/// Checks that an artifact that a launch runs, such as the OpenVMM executable, is a regular +/// file, as [`require_existing`] does, and fails with [`ErrorCode::ArtifactMissing`]. +pub(crate) fn require_artifact(path: &Path, description: &str) -> Result<()> { + require_existing(path, description, ErrorCode::ArtifactMissing) +} + +/// Checks that `path` is a regular file, following links, as `require_file` of `nvx.py` does, +/// and fails with `code` and the message `{description} not found: {path}`, with `path` spelled +/// with its components alone, as [`Path::components`] reads them. +pub(crate) fn require_existing(path: &Path, description: &str, code: ErrorCode) -> Result<()> { + if path.is_file() { + return Ok(()); + } + Err(Error::new( + code, + format!( + "{description} not found: {}", + paths::normalized(path).display() + ), + )) +} diff --git a/nvx-rs/src/host/artifacts/tests.rs b/nvx-rs/src/host/artifacts/tests.rs new file mode 100644 index 000000000..d700e5729 --- /dev/null +++ b/nvx-rs/src/host/artifacts/tests.rs @@ -0,0 +1,728 @@ +//! Tests of the discovery of artifacts, of the directories that hold them, and of their +//! identities. + +use std::collections::HashMap; + +use super::*; +use crate::ErrorKind; +use crate::host::store::ArtifactIdentity; + +const COMPATIBLE_MANIFEST: &str = r#"{"openvmm":{"microvm_abi_version":2, + "control_session_protocol_version":1, + "control_contract_revision":"nvx-microvm-v2-control-v2"}}"#; + +fn release(root: &Path) { + for artifact in [ + Path::new("bin").join(openvmm_executable()), + Path::new("guest").join("vmlinux"), + Path::new("guest").join("initramfs.cpio.gz"), + ] { + let path = root.join(artifact); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, b"artifact").unwrap(); + } + fs::write(root.join(MANIFEST_NAME), COMPATIBLE_MANIFEST).unwrap(); +} + +/// Creates a repository checkout with its tracked guest sources and no build outputs. +fn checkout(root: &Path) { + let sources = root.join("guest").join("common"); + fs::create_dir_all(&sources).unwrap(); + fs::write(sources.join("init"), b"#!/bin/sh").unwrap(); + fs::write(root.join(MANIFEST_NAME), COMPATIBLE_MANIFEST).unwrap(); +} + +fn no_bundle() -> Result> { + Ok(None) +} + +fn variables(pairs: &[(&str, &Path)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(name, value)| ((*name).to_owned(), value.as_os_str().to_owned())) + .collect(); + move |name| map.get(name).cloned() +} + +#[test] +fn release_layout_names_the_guest_artifacts() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let artifacts = Artifacts::from_dir(directory.path()).unwrap(); + let root = std::path::absolute(directory.path()).unwrap(); + assert_eq!( + artifacts.openvmm, + root.join("bin").join(openvmm_executable()) + ); + assert_eq!(artifacts.kernel, root.join("guest").join("vmlinux")); + assert_eq!( + artifacts.initrd, + root.join("guest").join("initramfs.cpio.gz") + ); + // A directory that the caller hands over, such as a `TempDir`, lives until the + // constructor returns, after it reads the manifest. + assert_eq!(Artifacts::from_release_dir(directory).unwrap(), artifacts); +} + +#[test] +fn repository_layout_is_detected_beside_the_guest_sources() { + let directory = tempfile::tempdir().unwrap(); + checkout(directory.path()); + let root = std::path::absolute(directory.path()).unwrap(); + let expected = |artifacts: &Artifacts| { + assert_eq!(artifacts.kernel, root.join("build").join("vmlinux")); + assert_eq!( + artifacts.initrd, + root.join("build").join("initramfs.cpio.gz") + ); + assert_eq!( + artifacts.openvmm, + root.join("openvmm") + .join("target") + .join("release") + .join(openvmm_executable()) + ); + }; + expected(&Artifacts::from_dir(directory.path()).unwrap()); + expected(&Artifacts::from_repo_layout(directory.path()).unwrap()); + + // Build outputs do not turn a checkout into a release. + let build = directory.path().join("build"); + fs::create_dir_all(&build).unwrap(); + fs::write(build.join("vmlinux"), b"kernel").unwrap(); + fs::write(build.join("initramfs.cpio.gz"), b"initrd").unwrap(); + expected(&Artifacts::from_dir(directory.path()).unwrap()); +} + +#[test] +fn the_directory_variable_accepts_a_repository_checkout() { + let directory = tempfile::tempdir().unwrap(); + checkout(directory.path()); + let artifacts = discover_with( + variables(&[(Artifacts::ENV_DIR, directory.path())]), + None, + no_bundle, + ) + .unwrap(); + let build = std::path::absolute(directory.path()).unwrap().join("build"); + assert_eq!(artifacts.kernel, build.join("vmlinux")); +} + +#[test] +fn this_repository_uses_the_repository_layout() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + if root.join(MANIFEST_NAME).is_file() { + let artifacts = Artifacts::from_dir(root).unwrap(); + let kernel = std::path::absolute(root).unwrap().join("build"); + assert_eq!(artifacts.kernel, kernel.join("vmlinux")); + let directory = ArtifactDir::from_dir(root).unwrap(); + assert_eq!(directory.layout(), Layout::Repository); + assert_eq!(directory.kernel(), artifacts.kernel); + } +} + +#[test] +fn explicit_variables_win_and_must_be_complete() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let (openvmm, kernel, initrd) = (Path::new("o"), Path::new("k"), Path::new("i")); + let artifacts = discover_with( + variables(&[ + (Artifacts::ENV_OPENVMM, openvmm), + (Artifacts::ENV_KERNEL, kernel), + (Artifacts::ENV_INITRD, initrd), + (Artifacts::ENV_DIR, directory.path()), + ]), + None, + no_bundle, + ) + .unwrap(); + assert_eq!(artifacts, Artifacts::new(openvmm, kernel, initrd)); + + let error = discover_with( + variables(&[(Artifacts::ENV_OPENVMM, openvmm)]), + None, + no_bundle, + ) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); +} + +#[test] +fn the_directory_variable_precedes_the_executable_directory() { + let variable_dir = tempfile::tempdir().unwrap(); + release(variable_dir.path()); + let exe_dir = tempfile::tempdir().unwrap(); + release(&exe_dir.path().join(Artifacts::BESIDE_EXE_DIR)); + + let artifacts = discover_with( + variables(&[(Artifacts::ENV_DIR, variable_dir.path())]), + Some(exe_dir.path()), + no_bundle, + ) + .unwrap(); + assert!( + artifacts + .kernel + .starts_with(std::path::absolute(variable_dir.path()).unwrap()) + ); + + let artifacts = discover_with(variables(&[]), Some(exe_dir.path()), no_bundle).unwrap(); + assert!( + artifacts.kernel.starts_with( + std::path::absolute(exe_dir.path().join(Artifacts::BESIDE_EXE_DIR)).unwrap() + ) + ); +} + +#[test] +fn the_bundle_is_the_last_resort() { + let exe_dir = tempfile::tempdir().unwrap(); + let bundle = Artifacts::new("bundled-openvmm", "bundled-kernel", "bundled-initrd"); + let expected = bundle.clone(); + let artifacts = + discover_with(variables(&[]), Some(exe_dir.path()), || Ok(Some(bundle))).unwrap(); + assert_eq!(artifacts, expected); + + let error = discover_with(variables(&[]), Some(exe_dir.path()), no_bundle).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert!(error.message().contains(Artifacts::ENV_DIR)); + assert!( + error + .message() + .contains("build the nvx crate with the bundled feature") + ); +} + +#[test] +fn incompatible_manifests_are_unavailable() { + let directory = tempfile::tempdir().unwrap(); + let manifest = directory.path().join(MANIFEST_NAME); + fs::write( + &manifest, + r#"{"openvmm":{"microvm_abi_version":2,"control_session_protocol_version":2, + "control_contract_revision":"nvx-microvm-v2-control-v2"}}"#, + ) + .unwrap(); + let error = check_manifest(&manifest).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + let error = check_manifest(&directory.path().join("missing.json")).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); +} + +#[test] +fn repository_manifest_declares_the_supported_contract() { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join(MANIFEST_NAME); + if manifest.is_file() { + check_manifest(&manifest).unwrap(); + } +} + +#[test] +fn guests_name_their_artifacts_as_nvx_py_does() { + assert_eq!( + Guest::ALL.map(Guest::name), + ["alpine", "ubuntu", "azurelinux"] + ); + for guest in Guest::ALL { + assert_eq!(Guest::from_name(guest.name()).unwrap(), guest); + assert_eq!(guest.to_string(), guest.name()); + } + let error = Guest::from_name("Ubuntu").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert_eq!( + error.message(), + "unsupported guest \"Ubuntu\"; choose alpine, ubuntu, azurelinux" + ); + assert_eq!( + Guest::ALL.map(Guest::distribution), + ["Alpine Linux", "Ubuntu Base", "Azure Linux"] + ); + assert_eq!( + Guest::ALL.map(Guest::package_manifest_name), + [ + "initramfs.cpio.gz.packages.json", + "initramfs-ubuntu.cpio.gz.packages.json", + "initramfs-azurelinux.cpio.gz.packages.json", + ] + ); + assert_eq!( + Guest::ALL.map(Guest::distro_layer_name), + [None, Some("ubuntu-distro.erofs"), None] + ); + assert_eq!( + Guest::Ubuntu.distro_manifest_name().as_deref(), + Some("ubuntu-distro.erofs.manifest.json") + ); + assert_eq!(Guest::Alpine.distro_manifest_name(), None); + assert_eq!(Guest::ALL.map(Guest::default_memory_mib), [128, 512, 512]); +} + +#[test] +fn a_checkout_holds_the_guest_artifacts_in_build() { + let directory = tempfile::tempdir().unwrap(); + checkout(directory.path()); + let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); + let root = std::path::absolute(directory.path()).unwrap(); + let build = root.join("build"); + assert_eq!(artifacts.layout(), Layout::Repository); + assert_eq!(artifacts.root(), root); + assert_eq!( + artifacts.openvmm(), + root.join("openvmm") + .join("target") + .join("release") + .join(openvmm_executable()) + ); + assert_eq!(artifacts.kernel(), build.join("vmlinux")); + assert_eq!( + Guest::ALL.map(|guest| artifacts.initramfs(guest)), + [ + build.join("initramfs.cpio.gz"), + build.join("initramfs-ubuntu.cpio.gz"), + build.join("initramfs-azurelinux.cpio.gz"), + ] + ); + assert_eq!( + artifacts.package_manifest(Guest::Ubuntu), + build.join("initramfs-ubuntu.cpio.gz.packages.json") + ); + assert_eq!( + Guest::ALL.map(|guest| artifacts.distro_layer(guest)), + [None, Some(build.join("ubuntu-distro.erofs")), None] + ); + assert_eq!( + artifacts.distro_manifest(Guest::Ubuntu), + Some(build.join("ubuntu-distro.erofs.manifest.json")) + ); + assert_eq!( + artifacts.artifact("vmlinux.provenance.json"), + build.join("vmlinux.provenance.json") + ); + + // A relative directory is made absolute, and an explicit layout overrides detection. + let relative = ArtifactDir::repository(".").unwrap(); + assert_eq!(relative.root(), std::path::absolute(".").unwrap()); + release(directory.path()); + assert_eq!( + ArtifactDir::repository(directory.path()).unwrap().layout(), + Layout::Repository + ); +} + +#[test] +fn a_release_holds_the_guest_artifacts_in_guest() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); + let root = std::path::absolute(directory.path()).unwrap(); + let guest = root.join("guest"); + assert_eq!(artifacts.layout(), Layout::Release); + assert_eq!(artifacts, ArtifactDir::release(directory.path()).unwrap()); + assert_eq!( + artifacts.openvmm(), + root.join("bin").join(openvmm_executable()) + ); + assert_eq!(artifacts.kernel(), guest.join("vmlinux")); + assert_eq!( + Guest::ALL.map(|guest| artifacts.initramfs(guest)), + [ + guest.join("initramfs.cpio.gz"), + guest.join("initramfs-ubuntu.cpio.gz"), + guest.join("initramfs-azurelinux.cpio.gz"), + ] + ); + assert_eq!( + artifacts.distro_layer(Guest::Ubuntu), + Some(guest.join("ubuntu-distro.erofs")) + ); + + // The release's artifacts are the ones that discovery finds for its directory. + let discovered = Artifacts::from_dir(directory.path()).unwrap(); + assert_eq!(artifacts.sandbox_artifacts().unwrap(), discovered); +} + +#[test] +fn guest_artifacts_are_required_in_the_order_and_words_of_nvx_py() { + let directory = tempfile::tempdir().unwrap(); + checkout(directory.path()); + let artifacts = ArtifactDir::repository(directory.path()).unwrap(); + fn missing(result: Result, description: &str, path: PathBuf) { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert_eq!( + error.message(), + format!("{description} not found: {}", path.display()) + ); + } + let create = |path: PathBuf| { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, b"artifact").unwrap(); + }; + missing( + artifacts.sandbox_artifacts(), + "OpenVMM release binary", + artifacts.openvmm(), + ); + create(artifacts.openvmm()); + missing( + artifacts.sandbox_artifacts(), + "Linux direct kernel", + artifacts.kernel(), + ); + create(artifacts.kernel()); + missing( + artifacts.sandbox_artifacts(), + "initramfs", + artifacts.initramfs(Guest::Alpine), + ); + create(artifacts.initramfs(Guest::Alpine)); + let located = artifacts.sandbox_artifacts().unwrap(); + assert_eq!( + [&located.openvmm, &located.kernel, &located.initrd], + [ + &artifacts.openvmm(), + &artifacts.kernel(), + &artifacts.initramfs(Guest::Alpine) + ] + ); + assert_eq!(located.artifact_dir(), Some(&artifacts)); + assert_eq!( + located, + Artifacts::from_repo_layout(directory.path()).unwrap() + ); + + assert_eq!(artifacts.require_openvmm().unwrap(), artifacts.openvmm()); + assert_eq!(artifacts.require_kernel().unwrap(), artifacts.kernel()); + missing( + artifacts.require_initramfs(Guest::Ubuntu), + "Ubuntu Base initramfs", + artifacts.initramfs(Guest::Ubuntu), + ); + missing( + artifacts.require_initramfs(Guest::AzureLinux), + "Azure Linux initramfs", + artifacts.initramfs(Guest::AzureLinux), + ); + create(artifacts.initramfs(Guest::Ubuntu)); + assert_eq!( + artifacts.require_initramfs(Guest::Ubuntu).unwrap(), + artifacts.initramfs(Guest::Ubuntu) + ); + + // A directory is not a regular file. + fs::create_dir_all(artifacts.initramfs(Guest::AzureLinux)).unwrap(); + missing( + artifacts.require_initramfs(Guest::AzureLinux), + "Azure Linux initramfs", + artifacts.initramfs(Guest::AzureLinux), + ); +} + +#[test] +fn artifact_paths_serialize_every_guest_in_order() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let artifacts = ArtifactDir::from_dir(directory.path()).unwrap(); + let text = |path: PathBuf| Value::from(path.to_str().unwrap()); + let value = serde_json::to_value(artifacts.paths()).unwrap(); + assert_eq!(value["layout"], "release"); + assert_eq!(value["root"], text(artifacts.root().to_path_buf())); + assert_eq!(value["openvmm"], text(artifacts.openvmm())); + assert_eq!(value["kernel"], text(artifacts.kernel())); + let guests = value["guests"].as_array().unwrap(); + assert_eq!(guests.len(), Guest::ALL.len()); + for (paths, guest) in guests.iter().zip(Guest::ALL) { + assert_eq!(paths["name"], guest.name()); + assert_eq!(paths["distribution"], guest.distribution()); + assert_eq!(paths["initramfs"], text(artifacts.initramfs(guest))); + assert_eq!( + paths["package_manifest"], + text(artifacts.package_manifest(guest)) + ); + assert_eq!( + paths["distro_layer"], + artifacts.distro_layer(guest).map_or(Value::Null, text) + ); + assert_eq!( + paths["distro_manifest"], + artifacts.distro_manifest(guest).map_or(Value::Null, text) + ); + } + let repository = ArtifactDir::repository(directory.path()).unwrap(); + assert_eq!( + serde_json::to_value(repository.paths()).unwrap()["layout"], + "repository" + ); +} + +/// Returns the identity of artifacts whose SHA-256 digests are `digests`. +fn identity_of(digests: &[String; 3]) -> RuntimeIdentity { + let [openvmm, kernel, initramfs] = digests + .each_ref() + .map(|digest| ArtifactIdentity::sha256(digest).unwrap()); + RuntimeIdentity::new(openvmm, kernel, initramfs) +} + +/// Writes the build provenance of the artifacts of the checkout at `root`, as `nvx.py` +/// records it, with `digests` for the OpenVMM executable, the kernel, and the initramfs. +fn record_provenance(root: &Path, digests: &[String; 3]) { + let build = root.join("build"); + fs::create_dir_all(&build).unwrap(); + for ((name, field), digest) in [ + (OPENVMM_PROVENANCE_NAME, "executable_sha256"), + (KERNEL_PROVENANCE_NAME, "kernel_sha256"), + (INITRAMFS_PROVENANCE_NAME, "initramfs_sha256"), + ] + .into_iter() + .zip(digests) + { + let text = format!("{{\n \"format\": 1,\n \"{field}\": \"{digest}\"\n}}\n"); + fs::write(build.join(name), text).unwrap(); + } +} + +#[test] +fn a_checkout_identifies_its_runtime_by_the_build_provenance_of_its_artifacts() { + let directory = tempfile::tempdir().unwrap(); + checkout(directory.path()); + let artifacts = ArtifactDir::repository(directory.path()).unwrap(); + let digests = ["a", "b", "c"].map(|digit| digit.repeat(64)); + // Only the metadata counts: the artifacts themselves are neither read nor hashed. + record_provenance(directory.path(), &digests); + assert_eq!(artifacts.runtime_identity().unwrap(), identity_of(&digests)); + + let kernel = artifacts.artifact(KERNEL_PROVENANCE_NAME); + let fails = |content: Option<&str>, problem: &str| { + match content { + Some(content) => fs::write(&kernel, content).unwrap(), + None => fs::remove_file(&kernel).unwrap(), + } + let error = artifacts.runtime_identity().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert_eq!( + error.message(), + format!("the kernel build provenance {} {problem}", kernel.display()) + ); + }; + fails( + None, + "is missing, so the runtime cannot be identified: build the artifact with nvx.py, or \ + install a release with nvx.py download", + ); + fails(Some("{"), "is not valid JSON"); + let digest = &digests[1]; + fails( + Some(&format!(r#"{{"format": 2, "kernel_sha256": "{digest}"}}"#)), + "does not have format 1", + ); + fails( + Some(r#"{"format": 1, "initramfs_sha256": "0"}"#), + "has no SHA-256 digest in kernel_sha256", + ); + let upper = digest.to_ascii_uppercase(); + fails( + Some(&format!(r#"{{"format": 1, "kernel_sha256": "{upper}"}}"#)), + "has no SHA-256 digest in kernel_sha256", + ); +} + +#[test] +fn a_release_identifies_its_runtime_by_its_checksum_list() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let artifacts = ArtifactDir::release(directory.path()).unwrap(); + let digests = ["1", "2", "3"].map(|digit| digit.repeat(64)); + let other = "f".repeat(64); + let openvmm = format!("bin/{}", openvmm_executable()); + let list = artifacts.root().join(CHECKSUMS_NAME); + let write = |lines: &[(&str, &str)]| { + let text: String = lines + .iter() + .map(|(digest, name)| format!("{digest} {name}\n")) + .collect(); + fs::write(&list, text).unwrap(); + }; + let complete = [ + (other.as_str(), "SOURCE-MANIFEST.json"), + (digests[0].as_str(), openvmm.as_str()), + (other.as_str(), "guest/initramfs-ubuntu.cpio.gz"), + (digests[2].as_str(), "guest/initramfs.cpio.gz"), + (digests[1].as_str(), "guest/vmlinux"), + ]; + write(&complete); + let identity = artifacts.runtime_identity().unwrap(); + assert_eq!(identity, identity_of(&digests)); + + // A checkout that installed the release has the release's identity. + let installed = tempfile::tempdir().unwrap(); + checkout(installed.path()); + record_provenance(installed.path(), &digests); + let checkout = ArtifactDir::repository(installed.path()).unwrap(); + assert_eq!(checkout.runtime_identity().unwrap(), identity); + + let fails = |problem: String| { + let error = artifacts.runtime_identity().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert_eq!( + error.message(), + format!("the release checksum list {} {problem}", list.display()) + ); + }; + write(&complete[..4]); + fails("does not list guest/vmlinux".to_owned()); + let upper = other.to_ascii_uppercase(); + write(&[(upper.as_str(), "guest/vmlinux")]); + fails(format!( + "has a malformed line: {:?}", + format!("{upper} guest/vmlinux") + )); + write(&[(digests[1].as_str(), "")]); + fails(format!( + "has a malformed line: {:?}", + format!("{} ", digests[1]) + )); + write(&[ + (digests[1].as_str(), "guest/vmlinux"), + (other.as_str(), "guest/vmlinux"), + ]); + fails("lists guest/vmlinux twice".to_owned()); + fs::remove_file(&list).unwrap(); + fails("is missing, so the runtime cannot be identified: the release is incomplete".to_owned()); +} + +#[cfg(unix)] +#[test] +fn metadata_that_is_not_a_regular_file_fails_at_once() { + use std::os::unix::fs::OpenOptionsExt; + use std::sync::mpsc; + use std::thread; + use std::time::Duration; + + use nix::sys::stat::Mode; + + let installed = tempfile::tempdir().unwrap(); + checkout(installed.path()); + record_provenance( + installed.path(), + &["a", "b", "c"].map(|digit| digit.repeat(64)), + ); + let repository = ArtifactDir::repository(installed.path()).unwrap(); + let extracted = tempfile::tempdir().unwrap(); + release(extracted.path()); + let released = ArtifactDir::release(extracted.path()).unwrap(); + for (artifacts, path, description) in [ + ( + &repository, + repository.artifact(KERNEL_PROVENANCE_NAME), + "kernel build provenance", + ), + ( + &released, + released.root().join(CHECKSUMS_NAME), + "release checksum list", + ), + ] { + let _ = fs::remove_file(&path); + nix::unistd::mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR).unwrap(); + // An open of a FIFO for reading that waits would wait for a writer, and a read of one + // would wait until its writer closes it, so the identity is read on a thread that the + // test can leave behind. + let refused = || { + let (sender, receiver) = mpsc::channel(); + let artifacts = artifacts.clone(); + thread::spawn(move || sender.send(artifacts.runtime_identity()).unwrap()); + let error = receiver + .recv_timeout(Duration::from_secs(10)) + .unwrap_or_else(|_| panic!("the read of the {description} waits on the FIFO")) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert_eq!( + error.message(), + format!("the {description} {} is not a regular file", path.display()) + ); + }; + refused(); + // A FIFO that has a writer opens at once, and is refused all the same, before a read + // waits for the writer to close it. + let reader = fs::OpenOptions::new() + .read(true) + .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()) + .open(&path) + .unwrap(); + let writer = fs::OpenOptions::new() + .write(true) + .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()) + .open(&path) + .unwrap(); + drop(reader); + refused(); + drop(writer); + } +} + +#[test] +fn located_artifacts_have_the_identity_of_their_directory() { + let directory = tempfile::tempdir().unwrap(); + release(directory.path()); + let digests = ["4", "5", "6"].map(|digit| digit.repeat(64)); + let text: String = [ + (&digests[0], format!("bin/{}", openvmm_executable())), + (&digests[1], "guest/vmlinux".to_owned()), + (&digests[2], "guest/initramfs.cpio.gz".to_owned()), + ] + .iter() + .map(|(digest, name)| format!("{digest} {name}\n")) + .collect(); + fs::write(directory.path().join(CHECKSUMS_NAME), text).unwrap(); + let located = Artifacts::from_dir(directory.path()).unwrap(); + assert_eq!( + located.artifact_dir(), + Some(&ArtifactDir::release(directory.path()).unwrap()) + ); + assert_eq!(located.runtime_identity().unwrap(), identity_of(&digests)); + + // Artifacts named by path have no install metadata. + let named = Artifacts::new(&located.openvmm, &located.kernel, &located.initrd); + assert_eq!(named.artifact_dir(), None); + let error = named.runtime_identity().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert!( + error.message().contains("supply their runtime identity"), + "{error}" + ); + // Nor does an artifact that replaced one of the directory's. + let mut replaced = located.clone(); + replaced.kernel = directory.path().join("vmlinux"); + let error = replaced.runtime_identity().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable); + assert_eq!( + error.message(), + format!( + "the guest kernel {} is not the one of {}, whose install metadata identifies the \ + artifacts", + replaced.kernel.display(), + located.artifact_dir().unwrap().root().display() + ) + ); +} + +#[cfg(feature = "bundled")] +#[test] +fn the_bundle_is_a_compatible_release() { + let bundled = Artifacts::bundled().unwrap(); + // A build that was asked to stage a release must have staged it. + let requested = env::var_os("NVX_BUNDLE_DIR").is_some() + && env::var_os("NVX_BUNDLE").is_none_or(|value| value != "skip"); + if requested && (cfg!(target_os = "linux") || cfg!(windows)) { + assert!(bundled.is_some(), "NVX_BUNDLE_DIR was not staged"); + } + if let Some(artifacts) = bundled { + for path in [&artifacts.openvmm, &artifacts.kernel, &artifacts.initrd] { + assert!(path.is_file(), "{} is missing", path.display()); + } + } +} diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index 2004c047a..9fb28857b 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -17,459 +17,36 @@ mod lifecycle; mod lock; +mod one_shot; +mod options; mod state; -use std::path::Path; -#[cfg(not(windows))] -use std::process::{Child, Command}; -use std::thread; -use std::time::{Duration, Instant}; +use std::time::Instant; -use super::args::{Parser, Receiver, Spec, Syntax, choice, integer}; use super::artifacts::{Located, OPENVMM_VARIABLE, locate}; -use super::network::{NativeRules, NetworkOptions}; use super::shell::format_command; use super::{Exit, Result, output}; use crate::ErrorCode; use crate::host::artifacts::{Guest, KERNEL_NAME, require_artifact}; -use crate::host::network::{ - Access, EgressRule, EgressRules, HostLoopback, LoopbackForward, NetworkPolicy, NetworkProxy, -}; -use crate::host::paths; -use crate::host::platform; use crate::host::sandbox::distro::{DISTRO_POLICY_REVISION, DistroBinding}; -use crate::host::sandbox::mount::{MountOwner, PolicyKind, plan_mounts, validate_mounts}; +use crate::host::sandbox::mount::{plan_mounts, validate_mounts}; use crate::host::sandbox::options::{ - PolicyPathOption, SandboxOperation, SandboxOptions, sandbox_mounts, - validate_sandbox_systemd_policy, + SandboxOperation, SandboxOptions, sandbox_mounts, validate_sandbox_systemd_policy, }; -use crate::host::sandbox::plan::guest_gateway; use crate::host::sandbox::scratch::{CLAIM_RELEASE_WAIT, ScratchBinding}; -use crate::host::sandbox::{ - DEFAULT_ENTRYPOINT, DEFAULT_HOSTNAME, DEFAULT_WORKLOAD_IDENTITY, PIDS_MAX_LIMIT, - PreparedLaunch, SandboxLaunch, SandboxLayer, SandboxNetwork, SandboxPlan, SandboxSettings, - parse_workload_identity, -}; +use crate::host::sandbox::{PreparedLaunch, SandboxLaunch, SandboxPlan, SandboxSettings}; use crate::host::store::RuntimeIdentity; -use crate::host::supervisor::Inherited; pub(crate) use self::lifecycle::ProvisionConfig; -use self::state::RecordedDistro; - -/// The usage and help of the parser. -pub(super) const SYNTAX: Syntax = Syntax { - command: "sandbox", - arguments: "[{run,provision,start,exec,stop,deprovision}] [OPTIONS]", - help: "\ -positional arguments: - {run,provision,start,exec,stop,deprovision} - -options: - -h, --help show this help message and exit - --layer ROLE,PATH,EROFS_UUID - --scratch SCRATCH - --state-dir STATE_DIR - --entrypoint ENTRYPOINT - --arg SANDBOX_ARG - --hostname HOSTNAME - --workload-user UID:GID - fixed non-root workload identity (default: - 65534:65534) - --memory-max MEMORY_MAX - --pids-max PIDS_MAX - --memory-mib MEMORY_MIB - --timeout TIMEOUT control operation timeout, and the longest wait for - another lifecycle transition of the sandbox, in - seconds (default: 60) - --exec-timeout-ms EXEC_TIMEOUT_MS - guest workload timeout in milliseconds; zero disables - it - --cwd CWD absolute guest working directory for managed exec - (default: /) - --environment KEY=VALUE - set the exact managed exec environment; repeat for - multiple entries - --environment-file PATH - read the exact managed exec environment from a JSON - string array - --inherit-default-environment - layer the managed exec environment over the guest - default environment instead of replacing it - --hypervisor {auto,whp,kvm,mshv} - --mount GUEST_TARGET,HOST_PATH[,ro|rw] - live-share a host directory, or a single regular host - file without its directory, inside the container - rootfs; the mode follows the last comma, so HOST_PATH - may contain commas when the mode is given; repeat to - attach more shares, each with its own target and mode, - as children of one virtio-fs device - --mount-deny HOST_PATH - hide one existing path inside a --mount host - directory; with several --mount options, it applies to - the --mount before it - --mount-allow HOST_PATH - expose one existing path inside a --mount-deny path of - the same share again; the hidden directories on the - way list only it - --mount-write HOST_PATH - make one existing path one of the only writable parts - of a read-write --mount; the rest of the share is - read-only - --mount-owner {vmm,caller} - host identity of the share's file operations: vmm - (default) or caller, the workload identity with guest - root squashed to the owner of the shared directory or - file (Linux only) - --net IPV4/PREFIX - --network-profile {portable} - --network-egress {allow,deny} - --network-ingress {allow,deny} - --network-egress-allow CIDR[:PROTOCOL[:PORT[-PORT]]] - allow guest egress to an IPv4 or IPv6 CIDR, optionally - only for PROTOCOL tcp, udp, or icmp, and for tcp or - udp only on PORT or on the inclusive range PORT-PORT; - repeat to add rules - --network-egress-deny CIDR[:PROTOCOL[:PORT[-PORT]]] - deny guest egress to an IPv4 or IPv6 CIDR, optionally - only for PROTOCOL tcp, udp, or icmp, and for tcp or - udp only on PORT or on the inclusive range PORT-PORT; - repeat to add rules - --network-egress-policy-file PATH - load bounded IPv4 and IPv6 ranges and rule-local - exclusions for run/provision - --host-loopback {allow,deny} - --network-proxy IPV4:TCP-PORT - --host-loopback-forward HOST_LOOPBACK_FORWARD - --outcome-report OUTCOME_REPORT - write a bounded local JSON outcome report for run or - exec - --cmdline CMDLINE - --dry-run print the one-shot run command without launching it", +pub(super) use self::one_shot::run_openvmm; +use self::options::sandbox_network; +pub(super) use self::options::{ + HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, hypervisor, parse, path_text, }; - -/// The operations, in the order in which usage messages list them. -const OPERATIONS: [&str; 6] = ["run", "provision", "start", "exec", "stop", "deprovision"]; -/// The choices of `--hypervisor`. -pub(super) const HYPERVISORS: [&str; 4] = ["auto", "whp", "kvm", "mshv"]; -/// The choices of `--mount-owner`. -pub(super) const MOUNT_OWNERS: [&str; 2] = ["vmm", "caller"]; -/// The most MiB of guest RAM that `--memory-mib` and the memory options of `run` take: the most -/// whose count of bytes fits in 64 bits, as OpenVMM reads a size of RAM. -pub(super) const MAX_MEMORY_MIB: u64 = u64::MAX >> 20; - -/// Returns the seconds that `value` gives, a finite number greater than 0, as Rust's `f64` -/// parses it: digits with an optional fraction and exponent, after an optional sign. -fn seconds(value: &str) -> std::result::Result { - value - .parse::() - .ok() - .filter(|seconds| seconds.is_finite() && *seconds > 0.0) - .ok_or_else(|| format!("expected a finite number of seconds greater than 0, not {value:?}")) -} - -/// The options of `nvx-rs sandbox`, which parse with the defaults of the former -/// `nvx.py sandbox`. -pub(super) const OPTIONS: [Spec; 35] = [ - Spec::one("--layer"), - Spec::one("--scratch"), - Spec::one("--state-dir"), - Spec::one("--entrypoint"), - Spec::one("--arg"), - Spec::one("--hostname"), - Spec::one("--workload-user"), - Spec::one("--memory-max"), - Spec::one("--pids-max"), - Spec::one("--memory-mib"), - Spec::one("--timeout"), - Spec::one("--exec-timeout-ms"), - Spec::one("--cwd"), - Spec::one("--environment"), - Spec::one("--environment-file"), - Spec::flag("--inherit-default-environment"), - Spec::one("--hypervisor"), - Spec::one("--mount"), - Spec::one("--mount-deny"), - Spec::one("--mount-allow"), - Spec::one("--mount-write"), - Spec::one("--mount-owner"), - Spec::one("--net"), - Spec::one("--network-profile"), - Spec::one("--network-egress"), - Spec::one("--network-ingress"), - Spec::one("--network-egress-allow"), - Spec::one("--network-egress-deny"), - Spec::one("--network-egress-policy-file"), - Spec::one("--host-loopback"), - Spec::one("--network-proxy"), - Spec::one("--host-loopback-forward"), - Spec::one("--outcome-report"), - Spec::one("--cmdline"), - Spec::flag("--dry-run"), -]; - -/// The parsed options of `nvx-rs sandbox`: the namespace of the former `nvx.py sandbox`. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct Options { - /// The operation, `run` by default. - pub(crate) operation: SandboxOperation, - /// `--layer`, parsed as `SandboxLayer.parse` parses it. - pub(crate) layers: Vec, - /// `--scratch`, as given. - pub(crate) scratch: Option, - /// `--state-dir`, as given. - pub(crate) state_dir: Option, - /// `--entrypoint`. - pub(crate) entrypoint: String, - /// The `--arg` options. - pub(crate) args: Vec, - /// `--hostname`. - pub(crate) hostname: String, - /// `--workload-user`. - pub(crate) workload_user: (u32, u32), - /// `--memory-max`. - pub(crate) memory_max: Option, - /// `--pids-max`. - pub(crate) pids_max: Option, - /// `--memory-mib`, 256 by default. - pub(crate) memory_mib: u64, - /// `--timeout` in seconds, 60 by default. - pub(crate) timeout: f64, - /// `--exec-timeout-ms`, 0 by default. - pub(crate) exec_timeout_ms: u32, - /// `--cwd`. - pub(crate) cwd: Option, - /// The `--environment` options. - pub(crate) environment: Vec, - /// `--environment-file`, as given. - pub(crate) environment_file: Option, - /// `--inherit-default-environment`. - pub(crate) inherit_default_environment: bool, - /// `--hypervisor`, `auto` by default. - pub(crate) hypervisor: &'static str, - /// The `--mount` options. - pub(crate) mounts: Vec, - /// The `--mount-deny`, `--mount-allow`, and `--mount-write` options, with the `--mount` - /// before each. - pub(crate) policy_paths: Vec, - /// `--mount-owner`. - pub(crate) mount_owner: Option, - /// The network options. - pub(crate) network: NetworkOptions, - /// `--outcome-report`, as given. - pub(crate) outcome_report: Option, - /// `--cmdline`, empty by default. - pub(crate) cmdline: String, - /// `--dry-run`. - pub(crate) dry_run: bool, -} - -impl Default for Options { - fn default() -> Self { - Self { - operation: SandboxOperation::Run, - layers: Vec::new(), - scratch: None, - state_dir: None, - entrypoint: DEFAULT_ENTRYPOINT.to_owned(), - args: Vec::new(), - hostname: DEFAULT_HOSTNAME.to_owned(), - workload_user: DEFAULT_WORKLOAD_IDENTITY, - memory_max: None, - pids_max: None, - memory_mib: 256, - timeout: 60.0, - exec_timeout_ms: 0, - cwd: None, - environment: Vec::new(), - environment_file: None, - inherit_default_environment: false, - hypervisor: "auto", - mounts: Vec::new(), - policy_paths: Vec::new(), - mount_owner: None, - network: NetworkOptions::default(), - outcome_report: None, - cmdline: String::new(), - dry_run: false, - } - } -} - -impl Receiver for Options { - fn take(&mut self, name: &'static str, value: Option<&str>) -> std::result::Result<(), String> { - let text = || value.unwrap_or_default().to_owned(); - match name { - "sandbox_operation" => { - let operation = choice(value.unwrap_or("run"), &OPERATIONS)?; - self.operation = match operation { - "provision" => SandboxOperation::Provision, - "start" => SandboxOperation::Start, - "exec" => SandboxOperation::Exec, - "stop" => SandboxOperation::Stop, - "deprovision" => SandboxOperation::Deprovision, - _ => SandboxOperation::Run, - }; - } - "--layer" => self - .layers - .push(SandboxLayer::parse(&text()).map_err(|error| error.message().to_owned())?), - "--scratch" => self.scratch = Some(text()), - "--state-dir" => self.state_dir = Some(text()), - "--entrypoint" => self.entrypoint = text(), - "--arg" => self.args.push(text()), - "--hostname" => self.hostname = text(), - "--workload-user" => { - self.workload_user = - parse_workload_identity(&text()).map_err(|error| error.message().to_owned())?; - } - "--memory-max" => self.memory_max = Some(integer(&text(), 1..=u64::MAX)?), - "--pids-max" => self.pids_max = Some(integer(&text(), 1..=PIDS_MAX_LIMIT)?), - "--memory-mib" => self.memory_mib = integer(&text(), 1..=MAX_MEMORY_MIB)?, - "--timeout" => self.timeout = seconds(&text())?, - "--exec-timeout-ms" => self.exec_timeout_ms = integer(&text(), 0..=u32::MAX)?, - "--cwd" => self.cwd = Some(text()), - "--environment" => self.environment.push(text()), - "--environment-file" => self.environment_file = Some(text()), - "--inherit-default-environment" => self.inherit_default_environment = true, - "--hypervisor" => self.hypervisor = choice(&text(), &HYPERVISORS)?, - "--mount" => self.mounts.push(text()), - "--mount-deny" | "--mount-allow" | "--mount-write" => { - let kind = match name { - "--mount-deny" => PolicyKind::Denied, - "--mount-allow" => PolicyKind::Allowed, - _ => PolicyKind::Writable, - }; - self.policy_paths.push(PolicyPathOption { - kind, - preceding_mount: self.mounts.len().checked_sub(1), - path: text(), - }); - } - "--mount-owner" => { - self.mount_owner = Some(match choice(&text(), &MOUNT_OWNERS)? { - "caller" => MountOwner::Caller, - _ => MountOwner::Vmm, - }); - } - "--outcome-report" => self.outcome_report = Some(text()), - "--cmdline" => self.cmdline = text(), - "--dry-run" => self.dry_run = true, - _ => return self.network.take(name, &text()), - } - Ok(()) - } -} - -/// Parses the arguments of `sandbox`, and returns the options and the arguments that the parser -/// does not recognize. -pub(super) fn parse(arguments: &[String]) -> Result<(Options, Vec)> { - let mut options = Options::default(); - let unrecognized = Parser::new(&OPTIONS, &[Spec::optional("sandbox_operation")]) - .parse(arguments, &mut options) - .map_err(|stop| Exit::from_stop(stop, &SYNTAX))?; - Ok((options, unrecognized)) -} - -/// Returns `_hypervisor(selected)`: `auto` selects `whp` on Windows and `kvm` elsewhere. -pub(super) fn hypervisor(selected: &'static str) -> &'static str { - match selected { - "auto" if cfg!(windows) => "whp", - "auto" => "kvm", - other => other, - } -} - -/// Returns the path `value` spelled with its components alone, as [`paths::normalized`] spells -/// it. -pub(super) fn path_text(value: &str) -> String { - paths::normalized(Path::new(value)).display().to_string() -} - -/// Returns `path` as text, which the commands of `nvx.py` hold as `str`. -pub(crate) fn path_string(path: &Path) -> Result { - path.to_str().map(str::to_owned).ok_or_else(|| { - Exit::error( - ErrorCode::Unsupported, - format!("path is not valid Unicode: {}", path.display()), - ) - }) -} - -/// Returns the network that the network options of `options` describe, with `rules`, the egress -/// rules that [`NetworkOptions::egress_rules`] resolved: the typed policy at the guest network of -/// `--net`, which [`SandboxNetwork::new`] checks before any launch, or `None` without `--net`, which -/// no other network option may then accompany. -/// -/// Omitted options take OpenVMM's defaults: egress allowed, ingress denied, and host-loopback -/// access from the guest alone, [`HostLoopback::Outbound`]. As OpenVMM does, the policy takes -/// egress rules only with an explicit `--network-egress`, and reads `--network-egress-allow`, -/// `--network-egress-deny`, and `--host-loopback-forward` in OpenVMM's form. `--network-proxy` must -/// name the guest's gateway, and makes the policy proxy-only, with no scheme in its URL. -fn sandbox_network(options: &Options, rules: &NativeRules) -> Result> { - let network = &options.network; - let Some(net) = &network.net else { - let network_options = network.network_egress.is_some() - || network.network_ingress.is_some() - || network.network_egress_policy_file.is_some() - || network.host_loopback.is_some() - || network.network_proxy.is_some() - || !rules.allow.is_empty() - || !rules.deny.is_empty() - || !network.host_loopback_forward.is_empty(); - if network_options { - return Err(Exit::error( - ErrorCode::Unsupported, - "network policy options require --net", - )); - } - return Ok(None); - }; - let gateway = guest_gateway(net)?; - if network.network_egress.is_none() && (!rules.allow.is_empty() || !rules.deny.is_empty()) { - return Err(Exit::error( - ErrorCode::Unsupported, - "--network-egress is required with --network-egress-allow or --network-egress-deny", - )); - } - let access = |value: Option<&str>, default| match value { - Some("allow") => Access::Allow, - Some(_) => Access::Deny, - None => default, - }; - // A refused value keeps the code of its refusal, after the option that gave it. - let refused = |option: &str, error: crate::Error| { - Exit::error(error.code(), format!("{option} {}", error.message())) - }; - let parsed_rules = |option: &str, native: &[String]| -> Result { - let rules = native - .iter() - .map(|rule| EgressRule::parse_native(rule).map_err(|error| refused(option, error))) - .collect::>()?; - Ok(EgressRules { - name: option.to_owned(), - rules, - }) - }; - let mut policy = NetworkPolicy::new(access(network.network_egress, Access::Allow)); - policy.allow = parsed_rules("--network-egress-allow", &rules.allow)?; - policy.deny = parsed_rules("--network-egress-deny", &rules.deny)?; - policy.ingress = access(network.network_ingress, Access::Deny); - policy.host_loopback = match network.host_loopback { - Some(host_loopback) => access(Some(host_loopback), Access::Deny).into(), - None => HostLoopback::Outbound, - }; - for forward in &network.host_loopback_forward { - let forward = LoopbackForward::parse(forward) - .map_err(|error| refused("--host-loopback-forward", error))?; - policy.host_loopback_forwards.push(forward); - } - if let Some(proxy) = &network.network_proxy { - let proxy = NetworkProxy::parse_endpoint(proxy, gateway) - .map_err(|error| refused("--network-proxy", error))?; - policy.proxy = Some(proxy); - } - Ok(Some(SandboxNetwork::new(net, policy)?)) -} +#[cfg(test)] +pub(super) use self::options::{OPTIONS, SYNTAX}; +pub(crate) use self::options::{Options, path_string}; +use self::state::RecordedDistro; /// The artifacts of a sandbox, found when an operation first needs them. pub(crate) struct Artifacts<'a> { @@ -751,138 +328,5 @@ fn prepare_run( PreparedLaunch::one_shot(plan, distro, Some(scratch), cmdline).map_err(Exit::from) } -/// Runs OpenVMM on the terminal, as `subprocess.run(command)` does, and returns its status. -/// -/// OpenVMM inherits the descriptors or handles `inherit`, under their numbers in this process, and -/// no other of this process beyond its standard streams, as with `subprocess.run`, which closes -/// the others on Linux and passes none on Windows: on Linux, `inherit` names the checked files of -/// the run; on Windows, it names the claim's handle of the run's scratch image, OpenVMM's handle -/// list holds copies of this process's standard handles and that handle alone, and OpenVMM opens -/// the checked files by their final paths, which the checked files hold in place. `nvx-rs run`, -/// which holds no checked files, passes none, as `nvx.py run` passed none to `subprocess.run`. -/// -/// OpenVMM shares the terminal and its process group, so Ctrl-C reaches it too. As -/// `subprocess.run` does, the CLI then gives it 0.25 s to exit on Unix before it kills it, and on -/// Windows, where Python cannot interrupt its wait, waits for it to exit; either way the command -/// ends as interrupted, also when OpenVMM exited first, since Python's wait raises -/// `KeyboardInterrupt` once Ctrl-C arrives. -pub(super) fn run_openvmm(command: &[String], inherit: &[Inherited<'_>]) -> Result { - let mut openvmm = OneShot::spawn(command, inherit)?; - loop { - let status = openvmm.poll()?; - if platform::interrupted() { - if status.is_none() { - end_interrupted(&mut openvmm)?; - } - return Err(Exit::Interrupted); - } - if let Some(status) = status { - return Ok(status); - } - thread::sleep(Duration::from_millis(10)); - } -} - -/// The OpenVMM process of a one-shot run. -struct OneShot { - /// The process, which `std::process` started. - #[cfg(not(windows))] - child: Child, - /// The process, which [`platform::spawn_foreground`] started. - #[cfg(windows)] - process: platform::LaunchedProcess, -} - -impl OneShot { - /// Starts OpenVMM with `command`, with the descriptors or handles `inherit`, as - /// [`run_openvmm`] says. - fn spawn(command: &[String], inherit: &[Inherited<'_>]) -> Result { - let failed = |error: std::io::Error| { - // The host's own refusals of a command, such as of a batch file on Windows, carry no - // OS error. - let refused = - error.kind() == std::io::ErrorKind::InvalidInput && error.raw_os_error().is_none(); - let code = if refused { - ErrorCode::Unsupported - } else { - ErrorCode::Io - }; - Exit::error(code, spawn_error(&error, &command[0])) - }; - #[cfg(windows)] - { - let arguments: Vec = command[1..].iter().map(Into::into).collect(); - platform::spawn_foreground(std::path::Path::new(&command[0]), &arguments, inherit) - .map(|process| Self { process }) - .map_err(failed) - } - #[cfg(not(windows))] - { - let mut child = Command::new(&command[0]); - child.args(&command[1..]); - platform::inherit_only(&mut child, inherit).map_err(failed)?; - child.spawn().map(|child| Self { child }).map_err(failed) - } - } - - /// Returns OpenVMM's status if it has exited: `Popen.returncode`, which on Unix is the - /// negated signal of a process that a signal ended, and on Windows the status of - /// `GetExitCodeProcess`, as `std::process::ExitStatus::code` reads it. - fn poll(&mut self) -> Result> { - #[cfg(windows)] - { - let status = self - .process - .wait_timeout(Duration::ZERO) - .map_err(Exit::from_os_error)?; - Ok(status.map(|status| status as i32)) - } - #[cfg(not(windows))] - { - let status = self.child.try_wait().map_err(Exit::from_os_error)?; - Ok(status.map(|status| { - #[cfg(unix)] - { - use std::os::unix::process::ExitStatusExt; - - status - .code() - .unwrap_or_else(|| -status.signal().unwrap_or_default()) - } - #[cfg(not(unix))] - status.code().unwrap_or_default() - })) - } - } -} - -/// Ends the OpenVMM of an interrupted run as `subprocess.run` does: on Unix, it waits 0.25 s for -/// OpenVMM to exit and then kills it, and on Windows, it waits for OpenVMM to exit. -fn end_interrupted(openvmm: &mut OneShot) -> Result { - #[cfg(not(windows))] - let deadline = Instant::now() + Duration::from_millis(250); - while openvmm.poll()?.is_none() { - #[cfg(not(windows))] - if cfg!(unix) && Instant::now() >= deadline { - let _ = openvmm.child.kill(); - let _ = openvmm.child.wait(); - break; - } - thread::sleep(Duration::from_millis(10)); - } - Ok(()) -} - -/// Returns the message of the I/O error with which the host fails to start `program`: the -/// program, quoted, and the error, as [`io_message`](crate::host::cli::io_message) writes them, -/// or the error alone for a refusal of the host's own, such as of a batch file on Windows, which -/// names the program itself. -pub(crate) fn spawn_error(error: &std::io::Error, program: &str) -> String { - match error.raw_os_error() { - Some(_) => crate::host::cli::io_message(error, std::path::Path::new(program)), - None => error.to_string(), - } -} - #[cfg(test)] mod tests; diff --git a/nvx-rs/src/host/cli/sandbox/one_shot.rs b/nvx-rs/src/host/cli/sandbox/one_shot.rs new file mode 100644 index 000000000..becb993ad --- /dev/null +++ b/nvx-rs/src/host/cli/sandbox/one_shot.rs @@ -0,0 +1,150 @@ +//! The OpenVMM process of a one-shot run, which runs on the terminal until it exits or the run is +//! interrupted. + +#[cfg(not(windows))] +use std::process::{Child, Command}; +use std::thread; +use std::time::Duration; +#[cfg(not(windows))] +use std::time::Instant; + +use crate::ErrorCode; +use crate::host::cli::{Exit, Result}; +use crate::host::platform; +use crate::host::supervisor::Inherited; + +/// Runs OpenVMM on the terminal, as `subprocess.run(command)` does, and returns its status. +/// +/// OpenVMM inherits the descriptors or handles `inherit`, under their numbers in this process, and +/// no other of this process beyond its standard streams, as with `subprocess.run`, which closes +/// the others on Linux and passes none on Windows: on Linux, `inherit` names the checked files of +/// the run; on Windows, it names the claim's handle of the run's scratch image, OpenVMM's handle +/// list holds copies of this process's standard handles and that handle alone, and OpenVMM opens +/// the checked files by their final paths, which the checked files hold in place. `nvx-rs run`, +/// which holds no checked files, passes none, as `nvx.py run` passed none to `subprocess.run`. +/// +/// OpenVMM shares the terminal and its process group, so Ctrl-C reaches it too. As +/// `subprocess.run` does, the CLI then gives it 0.25 s to exit on Unix before it kills it, and on +/// Windows, where Python cannot interrupt its wait, waits for it to exit; either way the command +/// ends as interrupted, also when OpenVMM exited first, since Python's wait raises +/// `KeyboardInterrupt` once Ctrl-C arrives. +pub(in crate::host::cli) fn run_openvmm( + command: &[String], + inherit: &[Inherited<'_>], +) -> Result { + let mut openvmm = OneShot::spawn(command, inherit)?; + loop { + let status = openvmm.poll()?; + if platform::interrupted() { + if status.is_none() { + end_interrupted(&mut openvmm)?; + } + return Err(Exit::Interrupted); + } + if let Some(status) = status { + return Ok(status); + } + thread::sleep(Duration::from_millis(10)); + } +} + +/// The OpenVMM process of a one-shot run. +struct OneShot { + /// The process, which `std::process` started. + #[cfg(not(windows))] + child: Child, + /// The process, which [`platform::spawn_foreground`] started. + #[cfg(windows)] + process: platform::LaunchedProcess, +} + +impl OneShot { + /// Starts OpenVMM with `command`, with the descriptors or handles `inherit`, as + /// [`run_openvmm`] says. + fn spawn(command: &[String], inherit: &[Inherited<'_>]) -> Result { + let failed = |error: std::io::Error| { + // The host's own refusals of a command, such as of a batch file on Windows, carry no + // OS error. + let refused = + error.kind() == std::io::ErrorKind::InvalidInput && error.raw_os_error().is_none(); + let code = if refused { + ErrorCode::Unsupported + } else { + ErrorCode::Io + }; + Exit::error(code, spawn_error(&error, &command[0])) + }; + #[cfg(windows)] + { + let arguments: Vec = command[1..].iter().map(Into::into).collect(); + platform::spawn_foreground(std::path::Path::new(&command[0]), &arguments, inherit) + .map(|process| Self { process }) + .map_err(failed) + } + #[cfg(not(windows))] + { + let mut child = Command::new(&command[0]); + child.args(&command[1..]); + platform::inherit_only(&mut child, inherit).map_err(failed)?; + child.spawn().map(|child| Self { child }).map_err(failed) + } + } + + /// Returns OpenVMM's status if it has exited: `Popen.returncode`, which on Unix is the + /// negated signal of a process that a signal ended, and on Windows the status of + /// `GetExitCodeProcess`, as `std::process::ExitStatus::code` reads it. + fn poll(&mut self) -> Result> { + #[cfg(windows)] + { + let status = self + .process + .wait_timeout(Duration::ZERO) + .map_err(Exit::from_os_error)?; + Ok(status.map(|status| status as i32)) + } + #[cfg(not(windows))] + { + let status = self.child.try_wait().map_err(Exit::from_os_error)?; + Ok(status.map(|status| { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + + status + .code() + .unwrap_or_else(|| -status.signal().unwrap_or_default()) + } + #[cfg(not(unix))] + status.code().unwrap_or_default() + })) + } + } +} + +/// Ends the OpenVMM of an interrupted run as `subprocess.run` does: on Unix, it waits 0.25 s for +/// OpenVMM to exit and then kills it, and on Windows, it waits for OpenVMM to exit. +fn end_interrupted(openvmm: &mut OneShot) -> Result { + #[cfg(not(windows))] + let deadline = Instant::now() + Duration::from_millis(250); + while openvmm.poll()?.is_none() { + #[cfg(not(windows))] + if cfg!(unix) && Instant::now() >= deadline { + let _ = openvmm.child.kill(); + let _ = openvmm.child.wait(); + break; + } + thread::sleep(Duration::from_millis(10)); + } + Ok(()) +} + +/// Returns the message of the I/O error with which the host fails to start `program`: the +/// program, quoted, and the error, as [`io_message`](crate::host::cli::io_message) writes them, +/// or the error alone for a refusal of the host's own, such as of a batch file on Windows, which +/// names the program itself. +pub(crate) fn spawn_error(error: &std::io::Error, program: &str) -> String { + match error.raw_os_error() { + Some(_) => crate::host::cli::io_message(error, std::path::Path::new(program)), + None => error.to_string(), + } +} diff --git a/nvx-rs/src/host/cli/sandbox/options.rs b/nvx-rs/src/host/cli/sandbox/options.rs new file mode 100644 index 000000000..4258a1c19 --- /dev/null +++ b/nvx-rs/src/host/cli/sandbox/options.rs @@ -0,0 +1,438 @@ +//! The parser of `nvx-rs sandbox`, with the options of the former `nvx.py sandbox`, and the +//! network that its network options describe. + +use std::path::Path; + +use crate::ErrorCode; +use crate::host::cli::args::{Parser, Receiver, Spec, Syntax, choice, integer}; +use crate::host::cli::network::{NativeRules, NetworkOptions}; +use crate::host::cli::{Exit, Result}; +use crate::host::network::{ + Access, EgressRule, EgressRules, HostLoopback, LoopbackForward, NetworkPolicy, NetworkProxy, +}; +use crate::host::paths; +use crate::host::sandbox::mount::{MountOwner, PolicyKind}; +use crate::host::sandbox::options::{PolicyPathOption, SandboxOperation}; +use crate::host::sandbox::plan::guest_gateway; +use crate::host::sandbox::{ + DEFAULT_ENTRYPOINT, DEFAULT_HOSTNAME, DEFAULT_WORKLOAD_IDENTITY, PIDS_MAX_LIMIT, SandboxLayer, + SandboxNetwork, parse_workload_identity, +}; + +/// The usage and help of the parser. +pub(in crate::host::cli) const SYNTAX: Syntax = Syntax { + command: "sandbox", + arguments: "[{run,provision,start,exec,stop,deprovision}] [OPTIONS]", + help: "\ +positional arguments: + {run,provision,start,exec,stop,deprovision} + +options: + -h, --help show this help message and exit + --layer ROLE,PATH,EROFS_UUID + --scratch SCRATCH + --state-dir STATE_DIR + --entrypoint ENTRYPOINT + --arg SANDBOX_ARG + --hostname HOSTNAME + --workload-user UID:GID + fixed non-root workload identity (default: + 65534:65534) + --memory-max MEMORY_MAX + --pids-max PIDS_MAX + --memory-mib MEMORY_MIB + --timeout TIMEOUT control operation timeout, and the longest wait for + another lifecycle transition of the sandbox, in + seconds (default: 60) + --exec-timeout-ms EXEC_TIMEOUT_MS + guest workload timeout in milliseconds; zero disables + it + --cwd CWD absolute guest working directory for managed exec + (default: /) + --environment KEY=VALUE + set the exact managed exec environment; repeat for + multiple entries + --environment-file PATH + read the exact managed exec environment from a JSON + string array + --inherit-default-environment + layer the managed exec environment over the guest + default environment instead of replacing it + --hypervisor {auto,whp,kvm,mshv} + --mount GUEST_TARGET,HOST_PATH[,ro|rw] + live-share a host directory, or a single regular host + file without its directory, inside the container + rootfs; the mode follows the last comma, so HOST_PATH + may contain commas when the mode is given; repeat to + attach more shares, each with its own target and mode, + as children of one virtio-fs device + --mount-deny HOST_PATH + hide one existing path inside a --mount host + directory; with several --mount options, it applies to + the --mount before it + --mount-allow HOST_PATH + expose one existing path inside a --mount-deny path of + the same share again; the hidden directories on the + way list only it + --mount-write HOST_PATH + make one existing path one of the only writable parts + of a read-write --mount; the rest of the share is + read-only + --mount-owner {vmm,caller} + host identity of the share's file operations: vmm + (default) or caller, the workload identity with guest + root squashed to the owner of the shared directory or + file (Linux only) + --net IPV4/PREFIX + --network-profile {portable} + --network-egress {allow,deny} + --network-ingress {allow,deny} + --network-egress-allow CIDR[:PROTOCOL[:PORT[-PORT]]] + allow guest egress to an IPv4 or IPv6 CIDR, optionally + only for PROTOCOL tcp, udp, or icmp, and for tcp or + udp only on PORT or on the inclusive range PORT-PORT; + repeat to add rules + --network-egress-deny CIDR[:PROTOCOL[:PORT[-PORT]]] + deny guest egress to an IPv4 or IPv6 CIDR, optionally + only for PROTOCOL tcp, udp, or icmp, and for tcp or + udp only on PORT or on the inclusive range PORT-PORT; + repeat to add rules + --network-egress-policy-file PATH + load bounded IPv4 and IPv6 ranges and rule-local + exclusions for run/provision + --host-loopback {allow,deny} + --network-proxy IPV4:TCP-PORT + --host-loopback-forward HOST_LOOPBACK_FORWARD + --outcome-report OUTCOME_REPORT + write a bounded local JSON outcome report for run or + exec + --cmdline CMDLINE + --dry-run print the one-shot run command without launching it", +}; + +/// The operations, in the order in which usage messages list them. +const OPERATIONS: [&str; 6] = ["run", "provision", "start", "exec", "stop", "deprovision"]; +/// The choices of `--hypervisor`. +pub(in crate::host::cli) const HYPERVISORS: [&str; 4] = ["auto", "whp", "kvm", "mshv"]; +/// The choices of `--mount-owner`. +pub(in crate::host::cli) const MOUNT_OWNERS: [&str; 2] = ["vmm", "caller"]; +/// The most MiB of guest RAM that `--memory-mib` and the memory options of `run` take: the most +/// whose count of bytes fits in 64 bits, as OpenVMM reads a size of RAM. +pub(in crate::host::cli) const MAX_MEMORY_MIB: u64 = u64::MAX >> 20; + +/// Returns the seconds that `value` gives, a finite number greater than 0, as Rust's `f64` +/// parses it: digits with an optional fraction and exponent, after an optional sign. +fn seconds(value: &str) -> std::result::Result { + value + .parse::() + .ok() + .filter(|seconds| seconds.is_finite() && *seconds > 0.0) + .ok_or_else(|| format!("expected a finite number of seconds greater than 0, not {value:?}")) +} + +/// The options of `nvx-rs sandbox`, which parse with the defaults of the former +/// `nvx.py sandbox`. +pub(in crate::host::cli) const OPTIONS: [Spec; 35] = [ + Spec::one("--layer"), + Spec::one("--scratch"), + Spec::one("--state-dir"), + Spec::one("--entrypoint"), + Spec::one("--arg"), + Spec::one("--hostname"), + Spec::one("--workload-user"), + Spec::one("--memory-max"), + Spec::one("--pids-max"), + Spec::one("--memory-mib"), + Spec::one("--timeout"), + Spec::one("--exec-timeout-ms"), + Spec::one("--cwd"), + Spec::one("--environment"), + Spec::one("--environment-file"), + Spec::flag("--inherit-default-environment"), + Spec::one("--hypervisor"), + Spec::one("--mount"), + Spec::one("--mount-deny"), + Spec::one("--mount-allow"), + Spec::one("--mount-write"), + Spec::one("--mount-owner"), + Spec::one("--net"), + Spec::one("--network-profile"), + Spec::one("--network-egress"), + Spec::one("--network-ingress"), + Spec::one("--network-egress-allow"), + Spec::one("--network-egress-deny"), + Spec::one("--network-egress-policy-file"), + Spec::one("--host-loopback"), + Spec::one("--network-proxy"), + Spec::one("--host-loopback-forward"), + Spec::one("--outcome-report"), + Spec::one("--cmdline"), + Spec::flag("--dry-run"), +]; + +/// The parsed options of `nvx-rs sandbox`: the namespace of the former `nvx.py sandbox`. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Options { + /// The operation, `run` by default. + pub(crate) operation: SandboxOperation, + /// `--layer`, parsed as `SandboxLayer.parse` parses it. + pub(crate) layers: Vec, + /// `--scratch`, as given. + pub(crate) scratch: Option, + /// `--state-dir`, as given. + pub(crate) state_dir: Option, + /// `--entrypoint`. + pub(crate) entrypoint: String, + /// The `--arg` options. + pub(crate) args: Vec, + /// `--hostname`. + pub(crate) hostname: String, + /// `--workload-user`. + pub(crate) workload_user: (u32, u32), + /// `--memory-max`. + pub(crate) memory_max: Option, + /// `--pids-max`. + pub(crate) pids_max: Option, + /// `--memory-mib`, 256 by default. + pub(crate) memory_mib: u64, + /// `--timeout` in seconds, 60 by default. + pub(crate) timeout: f64, + /// `--exec-timeout-ms`, 0 by default. + pub(crate) exec_timeout_ms: u32, + /// `--cwd`. + pub(crate) cwd: Option, + /// The `--environment` options. + pub(crate) environment: Vec, + /// `--environment-file`, as given. + pub(crate) environment_file: Option, + /// `--inherit-default-environment`. + pub(crate) inherit_default_environment: bool, + /// `--hypervisor`, `auto` by default. + pub(crate) hypervisor: &'static str, + /// The `--mount` options. + pub(crate) mounts: Vec, + /// The `--mount-deny`, `--mount-allow`, and `--mount-write` options, with the `--mount` + /// before each. + pub(crate) policy_paths: Vec, + /// `--mount-owner`. + pub(crate) mount_owner: Option, + /// The network options. + pub(crate) network: NetworkOptions, + /// `--outcome-report`, as given. + pub(crate) outcome_report: Option, + /// `--cmdline`, empty by default. + pub(crate) cmdline: String, + /// `--dry-run`. + pub(crate) dry_run: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { + operation: SandboxOperation::Run, + layers: Vec::new(), + scratch: None, + state_dir: None, + entrypoint: DEFAULT_ENTRYPOINT.to_owned(), + args: Vec::new(), + hostname: DEFAULT_HOSTNAME.to_owned(), + workload_user: DEFAULT_WORKLOAD_IDENTITY, + memory_max: None, + pids_max: None, + memory_mib: 256, + timeout: 60.0, + exec_timeout_ms: 0, + cwd: None, + environment: Vec::new(), + environment_file: None, + inherit_default_environment: false, + hypervisor: "auto", + mounts: Vec::new(), + policy_paths: Vec::new(), + mount_owner: None, + network: NetworkOptions::default(), + outcome_report: None, + cmdline: String::new(), + dry_run: false, + } + } +} + +impl Receiver for Options { + fn take(&mut self, name: &'static str, value: Option<&str>) -> std::result::Result<(), String> { + let text = || value.unwrap_or_default().to_owned(); + match name { + "sandbox_operation" => { + let operation = choice(value.unwrap_or("run"), &OPERATIONS)?; + self.operation = match operation { + "provision" => SandboxOperation::Provision, + "start" => SandboxOperation::Start, + "exec" => SandboxOperation::Exec, + "stop" => SandboxOperation::Stop, + "deprovision" => SandboxOperation::Deprovision, + _ => SandboxOperation::Run, + }; + } + "--layer" => self + .layers + .push(SandboxLayer::parse(&text()).map_err(|error| error.message().to_owned())?), + "--scratch" => self.scratch = Some(text()), + "--state-dir" => self.state_dir = Some(text()), + "--entrypoint" => self.entrypoint = text(), + "--arg" => self.args.push(text()), + "--hostname" => self.hostname = text(), + "--workload-user" => { + self.workload_user = + parse_workload_identity(&text()).map_err(|error| error.message().to_owned())?; + } + "--memory-max" => self.memory_max = Some(integer(&text(), 1..=u64::MAX)?), + "--pids-max" => self.pids_max = Some(integer(&text(), 1..=PIDS_MAX_LIMIT)?), + "--memory-mib" => self.memory_mib = integer(&text(), 1..=MAX_MEMORY_MIB)?, + "--timeout" => self.timeout = seconds(&text())?, + "--exec-timeout-ms" => self.exec_timeout_ms = integer(&text(), 0..=u32::MAX)?, + "--cwd" => self.cwd = Some(text()), + "--environment" => self.environment.push(text()), + "--environment-file" => self.environment_file = Some(text()), + "--inherit-default-environment" => self.inherit_default_environment = true, + "--hypervisor" => self.hypervisor = choice(&text(), &HYPERVISORS)?, + "--mount" => self.mounts.push(text()), + "--mount-deny" | "--mount-allow" | "--mount-write" => { + let kind = match name { + "--mount-deny" => PolicyKind::Denied, + "--mount-allow" => PolicyKind::Allowed, + _ => PolicyKind::Writable, + }; + self.policy_paths.push(PolicyPathOption { + kind, + preceding_mount: self.mounts.len().checked_sub(1), + path: text(), + }); + } + "--mount-owner" => { + self.mount_owner = Some(match choice(&text(), &MOUNT_OWNERS)? { + "caller" => MountOwner::Caller, + _ => MountOwner::Vmm, + }); + } + "--outcome-report" => self.outcome_report = Some(text()), + "--cmdline" => self.cmdline = text(), + "--dry-run" => self.dry_run = true, + _ => return self.network.take(name, &text()), + } + Ok(()) + } +} + +/// Parses the arguments of `sandbox`, and returns the options and the arguments that the parser +/// does not recognize. +pub(in crate::host::cli) fn parse(arguments: &[String]) -> Result<(Options, Vec)> { + let mut options = Options::default(); + let unrecognized = Parser::new(&OPTIONS, &[Spec::optional("sandbox_operation")]) + .parse(arguments, &mut options) + .map_err(|stop| Exit::from_stop(stop, &SYNTAX))?; + Ok((options, unrecognized)) +} + +/// Returns `_hypervisor(selected)`: `auto` selects `whp` on Windows and `kvm` elsewhere. +pub(in crate::host::cli) fn hypervisor(selected: &'static str) -> &'static str { + match selected { + "auto" if cfg!(windows) => "whp", + "auto" => "kvm", + other => other, + } +} + +/// Returns the path `value` spelled with its components alone, as [`paths::normalized`] spells +/// it. +pub(in crate::host::cli) fn path_text(value: &str) -> String { + paths::normalized(Path::new(value)).display().to_string() +} + +/// Returns `path` as text, which the commands of `nvx.py` hold as `str`. +pub(crate) fn path_string(path: &Path) -> Result { + path.to_str().map(str::to_owned).ok_or_else(|| { + Exit::error( + ErrorCode::Unsupported, + format!("path is not valid Unicode: {}", path.display()), + ) + }) +} + +/// Returns the network that the network options of `options` describe, with `rules`, the egress +/// rules that [`NetworkOptions::egress_rules`] resolved: the typed policy at the guest network of +/// `--net`, which [`SandboxNetwork::new`] checks before any launch, or `None` without `--net`, which +/// no other network option may then accompany. +/// +/// Omitted options take OpenVMM's defaults: egress allowed, ingress denied, and host-loopback +/// access from the guest alone, [`HostLoopback::Outbound`]. As OpenVMM does, the policy takes +/// egress rules only with an explicit `--network-egress`, and reads `--network-egress-allow`, +/// `--network-egress-deny`, and `--host-loopback-forward` in OpenVMM's form. `--network-proxy` must +/// name the guest's gateway, and makes the policy proxy-only, with no scheme in its URL. +pub(super) fn sandbox_network( + options: &Options, + rules: &NativeRules, +) -> Result> { + let network = &options.network; + let Some(net) = &network.net else { + let network_options = network.network_egress.is_some() + || network.network_ingress.is_some() + || network.network_egress_policy_file.is_some() + || network.host_loopback.is_some() + || network.network_proxy.is_some() + || !rules.allow.is_empty() + || !rules.deny.is_empty() + || !network.host_loopback_forward.is_empty(); + if network_options { + return Err(Exit::error( + ErrorCode::Unsupported, + "network policy options require --net", + )); + } + return Ok(None); + }; + let gateway = guest_gateway(net)?; + if network.network_egress.is_none() && (!rules.allow.is_empty() || !rules.deny.is_empty()) { + return Err(Exit::error( + ErrorCode::Unsupported, + "--network-egress is required with --network-egress-allow or --network-egress-deny", + )); + } + let access = |value: Option<&str>, default| match value { + Some("allow") => Access::Allow, + Some(_) => Access::Deny, + None => default, + }; + // A refused value keeps the code of its refusal, after the option that gave it. + let refused = |option: &str, error: crate::Error| { + Exit::error(error.code(), format!("{option} {}", error.message())) + }; + let parsed_rules = |option: &str, native: &[String]| -> Result { + let rules = native + .iter() + .map(|rule| EgressRule::parse_native(rule).map_err(|error| refused(option, error))) + .collect::>()?; + Ok(EgressRules { + name: option.to_owned(), + rules, + }) + }; + let mut policy = NetworkPolicy::new(access(network.network_egress, Access::Allow)); + policy.allow = parsed_rules("--network-egress-allow", &rules.allow)?; + policy.deny = parsed_rules("--network-egress-deny", &rules.deny)?; + policy.ingress = access(network.network_ingress, Access::Deny); + policy.host_loopback = match network.host_loopback { + Some(host_loopback) => access(Some(host_loopback), Access::Deny).into(), + None => HostLoopback::Outbound, + }; + for forward in &network.host_loopback_forward { + let forward = LoopbackForward::parse(forward) + .map_err(|error| refused("--host-loopback-forward", error))?; + policy.host_loopback_forwards.push(forward); + } + if let Some(proxy) = &network.network_proxy { + let proxy = NetworkProxy::parse_endpoint(proxy, gateway) + .map_err(|error| refused("--network-proxy", error))?; + policy.proxy = Some(proxy); + } + Ok(Some(SandboxNetwork::new(net, policy)?)) +} diff --git a/nvx-rs/src/host/cli/sandbox/tests.rs b/nvx-rs/src/host/cli/sandbox/tests.rs index 29b48d7d2..98547eccf 100644 --- a/nvx-rs/src/host/cli/sandbox/tests.rs +++ b/nvx-rs/src/host/cli/sandbox/tests.rs @@ -6,7 +6,9 @@ use proptest::prelude::*; use super::*; use crate::host::paths::resolve; -use crate::host::sandbox::layer::LayerRole; +use crate::host::sandbox::layer::{LayerRole, SandboxLayer}; +use crate::host::sandbox::mount::PolicyKind; +use crate::host::sandbox::options::PolicyPathOption; #[test] fn resource_limits_are_bounded_ascii_decimal() { diff --git a/nvx-rs/src/host/network/arguments.rs b/nvx-rs/src/host/network/arguments.rs new file mode 100644 index 000000000..ce92e5dc0 --- /dev/null +++ b/nvx-rs/src/host/network/arguments.rs @@ -0,0 +1,200 @@ +//! The OpenVMM options that enforce a network posture. + +use std::net::Ipv4Addr; + +use super::address::Interval; +use super::loopback::check_loopback_forwards; +use super::lowering; +use super::rule::parse_rules; +use super::{ + Access, DNS_PORT, HostLoopback, MAX_HOST_LOOPBACK_PREFIX, NetworkPolicy, NetworkProxy, +}; +use crate::error::{Error, Result}; +use crate::net::{self, cidr}; + +/// Returns the OpenVMM options that enforce `policy` for a guest at `guest_network`. +/// +/// Without a policy, or when egress is denied without allow rules, ingress and host-loopback +/// access are denied, and there is no proxy, no network device is attached. A proxy-only policy +/// attaches the portable profile with egress, ingress, and host-loopback access denied, and the +/// proxy at the guest network's gateway (`--network-proxy GATEWAY:PORT`). Otherwise the portable +/// profile applies the egress posture, the ingress default, the host-loopback access, which +/// [`HostLoopback::Outbound`] leaves to OpenVMM's default, and the host-loopback forwards. Each +/// action's rules are lowered as an egress policy file's are ([`policy`]), and fail with its +/// messages, which name the rules after [`EgressRules::name`]. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) when a rule is invalid, +/// when the rules of one action need more than [`MAX_RULES`] OpenVMM rules, when host-loopback +/// access is allowed for a guest network whose prefix is longer than +/// [`MAX_HOST_LOOPBACK_PREFIX`], when the host-loopback forwards break the rules of +/// [`NetworkPolicy::host_loopback_forwards`], or when a policy with a proxy is not proxy-only +/// ([`NetworkPolicy::proxy`]), its proxy's port is one that [`NetworkProxy::port`] excludes, or +/// its guest network has no gateway. +/// +/// [`policy`]: crate::host::network::policy +/// [`EgressRules::name`]: crate::host::network::EgressRules::name +/// [`MAX_RULES`]: crate::host::network::MAX_RULES +pub fn openvmm_arguments( + policy: Option<&NetworkPolicy>, + guest_network: &str, +) -> Result> { + let Some(policy) = policy else { + return Ok(Vec::new()); + }; + if let Some(proxy) = &policy.proxy { + return proxy_arguments(policy, proxy, guest_network); + } + if !policy.host_loopback_forwards.is_empty() && policy.host_loopback != HostLoopback::Allow { + return Err(Error::unsupported( + "host-loopback forwards need host-loopback access allowed", + )); + } + // OpenVMM presents a host peer to the guest at an address of the guest network, which a /30 + // network, holding only the gateway and the guest, cannot spare. + if policy.host_loopback == HostLoopback::Allow + && guest_network + .split_once('/') + .and_then(|(_, prefix)| cidr::parse_prefix_length(prefix)) + .is_some_and(|prefix| prefix > MAX_HOST_LOOPBACK_PREFIX) + { + return Err(Error::unsupported(format!( + "host-loopback access needs a guest network prefix of /{MAX_HOST_LOOPBACK_PREFIX} or \ + shorter, which leaves an address at which OpenVMM presents host peers to the guest" + ))); + } + if policy.egress == Access::Deny + && policy.allow.rules.is_empty() + && policy.ingress == Access::Deny + && policy.host_loopback == HostLoopback::Deny + { + return Ok(Vec::new()); + } + let mut arguments: Vec = [ + "--net", + guest_network, + "--network-profile", + "portable", + "--network-egress", + policy.egress.as_str(), + "--network-ingress", + policy.ingress.as_str(), + ] + .map(str::to_owned) + .to_vec(); + // Without the option, OpenVMM lets the guest alone reach host loopback. + if policy.host_loopback != HostLoopback::Outbound { + arguments.push("--host-loopback".to_owned()); + arguments.push(policy.host_loopback.as_str().to_owned()); + } + // As in an egress policy file, the allow rules are checked and lowered before the deny rules. + let mut deny = Vec::new(); + for (rules, option) in [ + (&policy.allow, "--network-egress-allow"), + (&policy.deny, "--network-egress-deny"), + ] { + let parsed = parse_rules(rules)?; + let lowered = lowering::lower(&parsed.concat(), &rules.name)?; + for rule in lowered { + arguments.push(option.to_owned()); + arguments.push(rule); + } + deny = parsed; + } + check_loopback_forwards(policy, &deny, guest_network)?; + for forward in &policy.host_loopback_forwards { + arguments.push("--host-loopback-forward".to_owned()); + arguments.push(format!( + "{}:{}:{}", + forward.protocol.as_str(), + forward.host_port, + forward.guest_port + )); + } + Ok(arguments) +} + +/// Returns the OpenVMM options of a policy with a proxy, which must be proxy-only, for a guest at +/// `guest_network`: everything denied but the proxy, which the guest reaches at its gateway. +fn proxy_arguments( + policy: &NetworkPolicy, + proxy: &NetworkProxy, + guest_network: &str, +) -> Result> { + check_proxy_only(policy, proxy)?; + let Some(gateway) = net::guest_gateway(guest_network) else { + return Err(Error::unsupported(format!( + "guest network {guest_network:?} is not valid" + ))); + }; + let mut arguments: Vec = [ + "--net", + guest_network, + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-ingress", + "deny", + "--host-loopback", + "deny", + "--network-proxy", + ] + .map(str::to_owned) + .to_vec(); + arguments.push(format!("{gateway}:{}", proxy.port)); + Ok(arguments) +} + +/// Checks that a policy with `proxy` is proxy-only, as [`NetworkPolicy::proxy`] describes it, and +/// that its port is one that [`NetworkProxy::port`] allows. +fn check_proxy_only(policy: &NetworkPolicy, proxy: &NetworkProxy) -> Result<()> { + let refused = |message: String| Err(Error::unsupported(message)); + if proxy.port == 0 { + return refused("a network proxy needs a port between 1 and 65535".to_owned()); + } + // OpenVMM's DNS service at the gateway takes the guest's TCP connections to this port before + // the proxy's relay does: the proxy would receive nothing, and the guest would reach the + // host's resolver instead. + if proxy.port == DNS_PORT { + return refused(format!( + "a network proxy needs a port other than {DNS_PORT}, where OpenVMM answers DNS at the \ + guest's gateway" + )); + } + if policy.egress != Access::Deny { + return refused( + "a network proxy needs egress denied by default, since the proxy is the guest's only \ + way out" + .to_owned(), + ); + } + for rules in [&policy.allow, &policy.deny] { + if !rules.rules.is_empty() { + return refused(format!( + "a network proxy takes no {} rules, since the proxy is the guest's only way out", + rules.name + )); + } + } + if policy.ingress != Access::Deny { + return refused("a network proxy needs ingress denied".to_owned()); + } + if policy.host_loopback != HostLoopback::Deny { + return refused("a network proxy needs host-loopback access denied".to_owned()); + } + if !policy.host_loopback_forwards.is_empty() { + return refused("a network proxy takes no host-loopback forwards".to_owned()); + } + Ok(()) +} + +/// Returns the addresses of the IPv4 network of `guest_network`, a static guest address in +/// `ADDRESS/PREFIX` form. +pub(super) fn guest_subnet(guest_network: &str) -> Option { + let (address, prefix) = guest_network.split_once('/')?; + let address: Ipv4Addr = address.parse().ok()?; + let prefix = cidr::parse_prefix_length(prefix).filter(|prefix| (1..=32).contains(prefix))?; + let mask = u32::MAX << (32 - u32::from(prefix)); + let network = u32::from(address) & mask; + Some((u128::from(network), u128::from(network | !mask))) +} diff --git a/nvx-rs/src/host/network/loopback.rs b/nvx-rs/src/host/network/loopback.rs new file mode 100644 index 000000000..d188e143e --- /dev/null +++ b/nvx-rs/src/host/network/loopback.rs @@ -0,0 +1,191 @@ +//! Access between the guest and host loopback, the host loopback ports that OpenVMM forwards to +//! guest ports, and the checks of those forwards. + +use std::collections::HashSet; + +use super::address::Family; +use super::arguments::guest_subnet; +use super::lowering::Rule; +#[cfg(feature = "cli")] +use super::rule::parse_port; +use super::{Access, MAX_LOOPBACK_FORWARDS, NetworkPolicy}; +use crate::error::{Error, Result}; + +/// Access between the guest and host loopback. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HostLoopback { + /// Neither direction: the guest reaches no host loopback service other than a + /// [`NetworkProxy`], and the host reaches none of the guest's listeners. + /// + /// [`NetworkProxy`]: crate::host::network::NetworkProxy + Deny, + /// From the guest alone: the guest reaches host loopback services through its gateway, + /// subject to the egress policy, and the host reaches none of the guest's listeners. This is + /// OpenVMM's own default, which the lowering keeps by passing no `--host-loopback` option. + Outbound, + /// Both directions: the guest reaches host loopback services through its gateway, subject to + /// the egress policy, and the host reaches the guest's listeners; see + /// [`NetworkPolicy::host_loopback`]. + Allow, +} + +impl HostLoopback { + /// Returns the name of the access: `deny`, `outbound`, or `allow`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Deny => "deny", + Self::Outbound => "outbound", + Self::Allow => "allow", + } + } +} + +impl From for HostLoopback { + /// Returns the access that allows or denies both directions. + fn from(access: Access) -> Self { + match access { + Access::Allow => Self::Allow, + Access::Deny => Self::Deny, + } + } +} + +/// Transport protocol of a [`LoopbackForward`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ForwardProtocol { + /// TCP connections. + Tcp, + /// UDP datagrams. + Udp, +} + +impl ForwardProtocol { + /// Returns OpenVMM's spelling of this protocol. + pub const fn as_str(self) -> &'static str { + match self { + Self::Tcp => "tcp", + Self::Udp => "udp", + } + } +} + +/// A port on host IPv4 loopback that OpenVMM forwards to a guest port. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct LoopbackForward { + /// Transport protocol. + pub protocol: ForwardProtocol, + /// Port on `127.0.0.1` where OpenVMM listens; nonzero. + pub host_port: u16, + /// Guest port that receives the traffic; nonzero. + pub guest_port: u16, +} + +impl LoopbackForward { + /// Parses OpenVMM's `--host-loopback-forward` value: `tcp:HOST_PORT:GUEST_PORT` or + /// `udp:HOST_PORT:GUEST_PORT`, with ports between 1 and 65535. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that + /// starts with `value`. + #[cfg(feature = "cli")] + pub(crate) fn parse(value: &str) -> Result { + let refused = |reason: &str| Err(Error::unsupported(format!("{value}: {reason}"))); + let fields: Vec<&str> = value.split(':').collect(); + let [protocol, host_port, guest_port] = fields[..] else { + return refused("expected tcp:HOST_PORT:GUEST_PORT or udp:HOST_PORT:GUEST_PORT"); + }; + let protocol = match protocol { + "tcp" => ForwardProtocol::Tcp, + "udp" => ForwardProtocol::Udp, + _ => return refused("the protocol must be tcp or udp"), + }; + let (Some(host_port), Some(guest_port)) = (parse_port(host_port), parse_port(guest_port)) + else { + return refused("ports must be between 1 and 65535"); + }; + Ok(Self { + protocol, + host_port, + guest_port, + }) + } +} + +/// Checks the host-loopback forwards of a policy whose rules lower, and whose host-loopback +/// access is allowed when it has forwards, against the rules of +/// [`NetworkPolicy::host_loopback_forwards`]. `deny` holds the parsed deny rules. +pub(super) fn check_loopback_forwards( + policy: &NetworkPolicy, + deny: &[Vec], + guest_network: &str, +) -> Result<()> { + let forwards = &policy.host_loopback_forwards; + if forwards.is_empty() { + return Ok(()); + } + let unsupported = |message: String| Err(Error::unsupported(message)); + if forwards.len() > MAX_LOOPBACK_FORWARDS { + return unsupported(format!( + "the openvmm backend accepts at most {MAX_LOOPBACK_FORWARDS} host-loopback forwards" + )); + } + let mut bindings = HashSet::new(); + let mut targets = HashSet::new(); + for forward in forwards { + if forward.host_port == 0 || forward.guest_port == 0 { + return unsupported( + "host-loopback forward ports must be between 1 and 65535".to_owned(), + ); + } + if !bindings.insert((forward.protocol, forward.host_port)) { + return unsupported(format!( + "host-loopback forwards bind {} host port {} more than once", + forward.protocol.as_str(), + forward.host_port + )); + } + // OpenVMM keys a forward's listener by its guest port, so a second forward to one guest + // port would fail to bind when the VM starts. + if !targets.insert((forward.protocol, forward.guest_port)) { + return unsupported(format!( + "host-loopback forwards reach {} guest port {} more than once", + forward.protocol.as_str(), + forward.guest_port + )); + } + } + if forwards + .iter() + .all(|forward| forward.protocol != ForwardProtocol::Udp) + { + // OpenVMM lets the guest's replies on a forwarded TCP connection through whatever the + // egress policy, since it carries that connection. + return Ok(()); + } + if policy.egress != Access::Allow { + return unsupported( + "host-loopback UDP forwards need egress allowed by default for their replies" + .to_owned(), + ); + } + let Some((first, last)) = guest_subnet(guest_network) else { + return unsupported(format!("guest network {guest_network:?} is not valid")); + }; + // A deny rule covers the guest network if it selects one of its addresses, on any protocol. + for (index, rules) in deny.iter().enumerate() { + let covers = rules.iter().any(|rule| { + rule.family == Family::V4 + && rule + .addresses + .iter() + .any(|&(start, end)| start <= last && first <= end) + }); + if covers { + return unsupported(format!( + "{}[{index}] cannot cover the guest network, which carries host-loopback UDP \ + forward replies", + policy.deny.name + )); + } + } + Ok(()) +} diff --git a/nvx-rs/src/host/network/mod.rs b/nvx-rs/src/host/network/mod.rs index 7c5139f9f..34e7a256e 100644 --- a/nvx-rs/src/host/network/mod.rs +++ b/nvx-rs/src/host/network/mod.rs @@ -26,16 +26,17 @@ //! learn it is the caller's choice. mod address; +mod arguments; +mod loopback; mod lowering; pub mod policy; +mod proxy; +mod rule; -use std::collections::HashSet; -use std::net::Ipv4Addr; - -use self::address::{Family, Interval}; -use self::lowering::{PortValue, Rule, RuleProtocol}; -use crate::error::{Error, Result}; -use crate::net::{self, cidr}; +pub use self::arguments::openvmm_arguments; +pub use self::loopback::{ForwardProtocol, HostLoopback, LoopbackForward}; +pub use self::proxy::{NetworkProxy, ProxyScheme}; +pub use self::rule::{Access, Destination, EgressRule, EgressRules, PortSelector, Protocol}; /// Largest number of allow rules, and of deny rules, that OpenVMM accepts. pub const MAX_RULES: usize = 256; @@ -52,478 +53,6 @@ pub const MAX_HOST_LOOPBACK_PREFIX: u8 = 29; /// UDP, which no [`NetworkProxy`] may take. const DNS_PORT: u16 = 53; -/// Allow or deny decision of a network posture. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Access { - /// Permit the traffic. - Allow, - /// Block the traffic. - Deny, -} - -impl Access { - /// Returns OpenVMM's spelling of this decision. - pub const fn as_str(self) -> &'static str { - match self { - Self::Allow => "allow", - Self::Deny => "deny", - } - } -} - -/// Access between the guest and host loopback. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum HostLoopback { - /// Neither direction: the guest reaches no host loopback service other than a - /// [`NetworkProxy`], and the host reaches none of the guest's listeners. - Deny, - /// From the guest alone: the guest reaches host loopback services through its gateway, - /// subject to the egress policy, and the host reaches none of the guest's listeners. This is - /// OpenVMM's own default, which the lowering keeps by passing no `--host-loopback` option. - Outbound, - /// Both directions: the guest reaches host loopback services through its gateway, subject to - /// the egress policy, and the host reaches the guest's listeners; see - /// [`NetworkPolicy::host_loopback`]. - Allow, -} - -impl HostLoopback { - /// Returns the name of the access: `deny`, `outbound`, or `allow`. - pub const fn as_str(self) -> &'static str { - match self { - Self::Deny => "deny", - Self::Outbound => "outbound", - Self::Allow => "allow", - } - } -} - -impl From for HostLoopback { - /// Returns the access that allows or denies both directions. - fn from(access: Access) -> Self { - match access { - Access::Allow => Self::Allow, - Access::Deny => Self::Deny, - } - } -} - -/// Transport protocol of a [`PortSelector`]. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] -pub enum Protocol { - /// TCP. - Tcp, - /// UDP. - Udp, - /// ICMP, or ICMPv6 for IPv6 networks. - Icmp, - /// Any protocol, or TCP and UDP with a port. - #[default] - Any, -} - -/// A destination network of an [`EgressRule`], without its exceptions. -/// -/// Networks are read as egress policy files read their CIDRs ([`policy`]): host bits are cleared, -/// IPv4 netmasks such as `/255.0.0.0` are accepted, and IPv6 scopes are refused. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Destination { - /// Destination address or CIDR, such as `"10.0.0.0/8"` or `"192.0.2.1"`. - pub cidr: String, - /// Sub-networks of `cidr` that the rule does not match, each within `cidr`. - pub except: Vec, -} - -impl Destination { - /// Creates a destination for `cidr` without exceptions. - pub fn new(cidr: impl Into) -> Self { - Self { - cidr: cidr.into(), - except: Vec::new(), - } - } -} - -/// A destination protocol and port range of an [`EgressRule`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PortSelector { - /// Transport protocol. - pub protocol: Protocol, - /// First destination port. `None` matches every port. - pub port: Option, - /// Last destination port of a range that starts at `port`. - pub end_port: Option, -} - -/// Traffic to some destinations and ports. -/// -/// An empty `to` matches every destination of both families, and an empty `ports` matches every -/// protocol and port. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct EgressRule { - /// Destination networks. - pub to: Vec, - /// Destination protocols and ports. - pub ports: Vec, -} - -impl EgressRule { - /// Creates a rule that matches every protocol and port of `cidr`, such as `"10.0.0.0/8"` or - /// `"192.0.2.1"`. - pub fn to(cidr: impl Into) -> Self { - Self { - to: vec![Destination::new(cidr)], - ports: Vec::new(), - } - } - - /// Restricts the rule to one port of `protocol`. - #[must_use] - pub fn on_port(mut self, protocol: Protocol, port: u16) -> Self { - self.ports.push(PortSelector { - protocol, - port: Some(port), - end_port: None, - }); - self - } - - /// Restricts the rule to the ports of `protocol` from `port` through `end_port`, both - /// included. - #[must_use] - pub fn on_port_range(mut self, protocol: Protocol, port: u16, end_port: u16) -> Self { - self.ports.push(PortSelector { - protocol, - port: Some(port), - end_port: Some(end_port), - }); - self - } - - /// Restricts the rule to `protocol` on every port. - #[must_use] - pub fn on_protocol(mut self, protocol: Protocol) -> Self { - self.ports.push(PortSelector { - protocol, - port: None, - end_port: None, - }); - self - } - - /// Parses one of OpenVMM's native rules, the value of an `--network-egress-allow` or - /// `--network-egress-deny` option, as OpenVMM parses it: `CIDR`, `CIDR:PROTOCOL`, - /// `CIDR:PROTOCOL:PORT`, or `CIDR:PROTOCOL:FIRST-LAST`, where `CIDR` is an IPv4 or IPv6 - /// address with an optional prefix length, whose host bits are cleared, `PROTOCOL` is `tcp`, - /// `udp`, or `icmp`, and only `tcp` and `udp` take ports. The rule's destination is the - /// canonical network. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that - /// starts with `value`. - #[cfg(feature = "cli")] - pub(crate) fn parse_native(value: &str) -> Result { - let refused = |reason: &str| Err(Error::unsupported(format!("{value}: {reason}"))); - let (destination, selector) = split_native_rule(value); - let (protocol, ports) = - match selector.map(|selector| selector.split(':').collect::>()) { - None => (None, None), - Some(fields) => match fields[..] { - [protocol] => (Some(protocol), None), - [protocol, ports] => (Some(protocol), Some(ports)), - _ => return refused("expected CIDR[:PROTOCOL[:PORT[-PORT]]]"), - }, - }; - let protocol = match protocol { - None => None, - Some("tcp") => Some(Protocol::Tcp), - Some("udp") => Some(Protocol::Udp), - Some("icmp") => Some(Protocol::Icmp), - Some(_) => return refused("the protocol must be tcp, udp, or icmp"), - }; - let ports = match ports { - None => None, - Some(_) if protocol == Some(Protocol::Icmp) => return refused("icmp takes no port"), - Some(ports) => match ports.split_once('-') { - None => match parse_port(ports) { - Some(port) => Some((port, port)), - None => return refused("ports must be between 1 and 65535"), - }, - Some((first, last)) - if first.is_empty() || last.is_empty() || last.contains('-') => - { - return refused("a port range must be FIRST-LAST"); - } - Some((first, last)) => match (parse_port(first), parse_port(last)) { - (Some(first), Some(last)) if first <= last => Some((first, last)), - (Some(_), Some(_)) => { - return refused("the port range ends below its first port"); - } - _ => return refused("ports must be between 1 and 65535"), - }, - }, - }; - let Ok(destination) = cidr::Cidr::parse_masked(destination) else { - return refused( - "the destination must be an IPv4 or IPv6 address with an optional prefix", - ); - }; - let rule = Self::to(destination.to_string()); - // A range of one port is that port. - Ok(match (protocol, ports) { - (None, _) => rule, - (Some(protocol), None) => rule.on_protocol(protocol), - (Some(protocol), Some((first, last))) if first == last => rule.on_port(protocol, first), - (Some(protocol), Some((first, last))) => rule.on_port_range(protocol, first, last), - }) - } -} - -/// Splits a native rule into its destination and its optional protocol and ports, where OpenVMM -/// splits it: an IPv4 address holds no `:`, so its first `:` starts the selector; a network with a -/// prefix ends at the first `:` after its `/`; and an IPv6 address without a prefix ends at the -/// last `:` that leaves an IPv6 address before it, since protocol names are not hexadecimal. -#[cfg(feature = "cli")] -fn split_native_rule(value: &str) -> (&str, Option<&str>) { - let split_at = |index: usize| (&value[..index], Some(&value[index + 1..])); - if let Some(slash) = value.find('/') { - return match value[slash..].find(':') { - Some(colon) => split_at(slash + colon), - None => (value, None), - }; - } - match value.find(':') { - None => (value, None), - Some(colon) if value[..colon].contains('.') => split_at(colon), - Some(_) if value.parse::().is_ok() => (value, None), - Some(_) => value - .rmatch_indices(':') - .map(|(index, _)| index) - .find(|&index| value[..index].parse::().is_ok()) - .map_or((value, None), split_at), - } -} - -/// Returns the port that `text` names, read as OpenVMM reads its ports, if it is nonzero. -fn parse_port(text: &str) -> Option { - text.parse().ok().filter(|&port| port != 0) -} - -/// The egress rules of one action, and the name by which messages refer to them, such as -/// `egress.allow`. Messages name a rule by its index and its fields by their MXC names, as in -/// `egress.allow[0].to[1].cidr`, and the action by the name, as in `egress.allow emits at most 256 -/// native rules`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EgressRules { - /// The name of the rules in messages. - pub name: String, - /// The rules. - pub rules: Vec, -} - -impl EgressRules { - /// Creates an empty list of rules that messages call `name`. - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - rules: Vec::new(), - } - } -} - -/// Transport protocol of a [`LoopbackForward`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ForwardProtocol { - /// TCP connections. - Tcp, - /// UDP datagrams. - Udp, -} - -impl ForwardProtocol { - /// Returns OpenVMM's spelling of this protocol. - pub const fn as_str(self) -> &'static str { - match self { - Self::Tcp => "tcp", - Self::Udp => "udp", - } - } -} - -/// A port on host IPv4 loopback that OpenVMM forwards to a guest port. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct LoopbackForward { - /// Transport protocol. - pub protocol: ForwardProtocol, - /// Port on `127.0.0.1` where OpenVMM listens; nonzero. - pub host_port: u16, - /// Guest port that receives the traffic; nonzero. - pub guest_port: u16, -} - -impl LoopbackForward { - /// Parses OpenVMM's `--host-loopback-forward` value: `tcp:HOST_PORT:GUEST_PORT` or - /// `udp:HOST_PORT:GUEST_PORT`, with ports between 1 and 65535. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that - /// starts with `value`. - #[cfg(feature = "cli")] - pub(crate) fn parse(value: &str) -> Result { - let refused = |reason: &str| Err(Error::unsupported(format!("{value}: {reason}"))); - let fields: Vec<&str> = value.split(':').collect(); - let [protocol, host_port, guest_port] = fields[..] else { - return refused("expected tcp:HOST_PORT:GUEST_PORT or udp:HOST_PORT:GUEST_PORT"); - }; - let protocol = match protocol { - "tcp" => ForwardProtocol::Tcp, - "udp" => ForwardProtocol::Udp, - _ => return refused("the protocol must be tcp or udp"), - }; - let (Some(host_port), Some(guest_port)) = (parse_port(host_port), parse_port(guest_port)) - else { - return refused("ports must be between 1 and 65535"); - }; - Ok(Self { - protocol, - host_port, - guest_port, - }) - } -} - -/// The scheme of a [`NetworkProxy`]'s URL. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ProxyScheme { - /// `http`. - Http, - /// `https`. - Https, -} - -impl ProxyScheme { - /// Returns the scheme's name: `http` or `https`. - pub const fn as_str(self) -> &'static str { - match self { - Self::Http => "http", - Self::Https => "https", - } - } - - /// Returns the scheme that `name` names: `http` or `https`, in lowercase. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for any other name. - pub fn from_name(name: &str) -> Result { - match name { - "http" => Ok(Self::Http), - "https" => Ok(Self::Https), - _ => Err(Error::unsupported(format!( - "network proxy scheme {name:?} must be http or https" - ))), - } - } -} - -/// An HTTP or HTTPS proxy that listens on host IPv4 loopback: the one way out of a proxy-only -/// network, which its guest reaches at the same port of its gateway. -/// -/// OpenVMM relays the guest's TCP connections to `port` of its gateway to `127.0.0.1:port` on the -/// host, whatever they carry. The scheme only names the URL that [`url`](Self::url) gives: an -/// HTTPS proxy's certificate must therefore be valid for the gateway's address. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct NetworkProxy { - /// TCP port where the proxy listens on `127.0.0.1`, and where the guest reaches it at its - /// gateway; nonzero, and not 53, where OpenVMM answers DNS at the gateway, over TCP too, - /// before it relays a connection to a proxy. - pub port: u16, - /// Scheme of the proxy's URL, if any. - pub scheme: Option, -} - -impl NetworkProxy { - /// Creates a proxy at `port` of host loopback, whose URL has no scheme. - pub const fn new(port: u16) -> Self { - Self { port, scheme: None } - } - - /// Names the scheme of the proxy's URL. - #[must_use] - pub const fn with_scheme(mut self, scheme: ProxyScheme) -> Self { - self.scheme = Some(scheme); - self - } - - /// Parses the URL of a proxy on host loopback, as the host names it: `http` or `https`, - /// `127.0.0.1` or `localhost`, and an explicit port, such as `http://127.0.0.1:3128`, with at - /// most a `/` after the port. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another scheme, a - /// host other than IPv4 loopback, a missing or zero port, or a path, query, or fragment. - pub fn parse_url(url: &str) -> Result { - let refused = |reason: &str| { - Err(Error::unsupported(format!( - "network proxy URL {url}: {reason}" - ))) - }; - let Some((scheme, rest)) = url.split_once("://") else { - return refused("expected http://HOST:PORT or https://HOST:PORT"); - }; - let scheme = match ProxyScheme::from_name(scheme) { - Ok(scheme) => scheme, - Err(_) => return refused("the scheme must be http or https"), - }; - let authority = rest.strip_suffix('/').unwrap_or(rest); - if authority.contains(['/', '?', '#']) { - return refused("nothing but a / may follow the port"); - } - let Some((host, port)) = authority.rsplit_once(':') else { - return refused("the port must be explicit"); - }; - if !matches!(host, "127.0.0.1" | "localhost") { - return refused("the host must be 127.0.0.1 or localhost"); - } - let port = Some(port) - .filter(|port| port.bytes().all(|byte| byte.is_ascii_digit())) - .and_then(parse_port); - let Some(port) = port else { - return refused("the port must be between 1 and 65535"); - }; - Ok(Self::new(port).with_scheme(scheme)) - } - - /// Parses OpenVMM's `--network-proxy` value, `IPV4:TCP-PORT`, for a guest whose gateway is - /// `gateway`, which the address must be. The proxy's URL has no scheme. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that - /// starts with `endpoint`. - #[cfg(feature = "cli")] - pub(crate) fn parse_endpoint(endpoint: &str, gateway: Ipv4Addr) -> Result { - let refused = |reason: String| Err(Error::unsupported(format!("{endpoint}: {reason}"))); - let Some((address, port)) = endpoint - .rsplit_once(':') - .and_then(|(address, port)| Some((address.parse::().ok()?, port))) - else { - return refused("expected IPV4:TCP-PORT".to_owned()); - }; - let Some(port) = parse_port(port) else { - return refused("the port must be between 1 and 65535".to_owned()); - }; - if address != gateway { - return refused(format!( - "the address must be the guest's gateway, {gateway}" - )); - } - Ok(Self::new(port)) - } - - /// Returns the proxy's URL as the guest sees it, at `gateway`: `SCHEME://GATEWAY:PORT`, or - /// `GATEWAY:PORT` without a scheme. - pub fn url(&self, gateway: Ipv4Addr) -> String { - match self.scheme { - Some(scheme) => format!("{}://{gateway}:{}", scheme.as_str(), self.port), - None => format!("{gateway}:{}", self.port), - } - } -} - /// The network posture of a VM. /// /// A connection matching a `deny` rule is blocked. Otherwise a connection matching an `allow` @@ -610,1307 +139,5 @@ impl NetworkPolicy { } } -/// Returns the OpenVMM options that enforce `policy` for a guest at `guest_network`. -/// -/// Without a policy, or when egress is denied without allow rules, ingress and host-loopback -/// access are denied, and there is no proxy, no network device is attached. A proxy-only policy -/// attaches the portable profile with egress, ingress, and host-loopback access denied, and the -/// proxy at the guest network's gateway (`--network-proxy GATEWAY:PORT`). Otherwise the portable -/// profile applies the egress posture, the ingress default, the host-loopback access, which -/// [`HostLoopback::Outbound`] leaves to OpenVMM's default, and the host-loopback forwards. Each -/// action's rules are lowered as an egress policy file's are ([`policy`]), and fail with its -/// messages, which name the rules after [`EgressRules::name`]. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) when a rule is invalid, -/// when the rules of one action need more than [`MAX_RULES`] OpenVMM rules, when host-loopback -/// access is allowed for a guest network whose prefix is longer than -/// [`MAX_HOST_LOOPBACK_PREFIX`], when the host-loopback forwards break the rules of -/// [`NetworkPolicy::host_loopback_forwards`], or when a policy with a proxy is not proxy-only -/// ([`NetworkPolicy::proxy`]), its proxy's port is one that [`NetworkProxy::port`] excludes, or -/// its guest network has no gateway. -pub fn openvmm_arguments( - policy: Option<&NetworkPolicy>, - guest_network: &str, -) -> Result> { - let Some(policy) = policy else { - return Ok(Vec::new()); - }; - if let Some(proxy) = &policy.proxy { - return proxy_arguments(policy, proxy, guest_network); - } - if !policy.host_loopback_forwards.is_empty() && policy.host_loopback != HostLoopback::Allow { - return Err(Error::unsupported( - "host-loopback forwards need host-loopback access allowed", - )); - } - // OpenVMM presents a host peer to the guest at an address of the guest network, which a /30 - // network, holding only the gateway and the guest, cannot spare. - if policy.host_loopback == HostLoopback::Allow - && guest_network - .split_once('/') - .and_then(|(_, prefix)| cidr::parse_prefix_length(prefix)) - .is_some_and(|prefix| prefix > MAX_HOST_LOOPBACK_PREFIX) - { - return Err(Error::unsupported(format!( - "host-loopback access needs a guest network prefix of /{MAX_HOST_LOOPBACK_PREFIX} or \ - shorter, which leaves an address at which OpenVMM presents host peers to the guest" - ))); - } - if policy.egress == Access::Deny - && policy.allow.rules.is_empty() - && policy.ingress == Access::Deny - && policy.host_loopback == HostLoopback::Deny - { - return Ok(Vec::new()); - } - let mut arguments: Vec = [ - "--net", - guest_network, - "--network-profile", - "portable", - "--network-egress", - policy.egress.as_str(), - "--network-ingress", - policy.ingress.as_str(), - ] - .map(str::to_owned) - .to_vec(); - // Without the option, OpenVMM lets the guest alone reach host loopback. - if policy.host_loopback != HostLoopback::Outbound { - arguments.push("--host-loopback".to_owned()); - arguments.push(policy.host_loopback.as_str().to_owned()); - } - // As in an egress policy file, the allow rules are checked and lowered before the deny rules. - let mut deny = Vec::new(); - for (rules, option) in [ - (&policy.allow, "--network-egress-allow"), - (&policy.deny, "--network-egress-deny"), - ] { - let parsed = parse_rules(rules)?; - let lowered = lowering::lower(&parsed.concat(), &rules.name)?; - for rule in lowered { - arguments.push(option.to_owned()); - arguments.push(rule); - } - deny = parsed; - } - check_loopback_forwards(policy, &deny, guest_network)?; - for forward in &policy.host_loopback_forwards { - arguments.push("--host-loopback-forward".to_owned()); - arguments.push(format!( - "{}:{}:{}", - forward.protocol.as_str(), - forward.host_port, - forward.guest_port - )); - } - Ok(arguments) -} - -/// Returns the OpenVMM options of a policy with a proxy, which must be proxy-only, for a guest at -/// `guest_network`: everything denied but the proxy, which the guest reaches at its gateway. -fn proxy_arguments( - policy: &NetworkPolicy, - proxy: &NetworkProxy, - guest_network: &str, -) -> Result> { - check_proxy_only(policy, proxy)?; - let Some(gateway) = net::guest_gateway(guest_network) else { - return Err(Error::unsupported(format!( - "guest network {guest_network:?} is not valid" - ))); - }; - let mut arguments: Vec = [ - "--net", - guest_network, - "--network-profile", - "portable", - "--network-egress", - "deny", - "--network-ingress", - "deny", - "--host-loopback", - "deny", - "--network-proxy", - ] - .map(str::to_owned) - .to_vec(); - arguments.push(format!("{gateway}:{}", proxy.port)); - Ok(arguments) -} - -/// Checks that a policy with `proxy` is proxy-only, as [`NetworkPolicy::proxy`] describes it, and -/// that its port is one that [`NetworkProxy::port`] allows. -fn check_proxy_only(policy: &NetworkPolicy, proxy: &NetworkProxy) -> Result<()> { - let refused = |message: String| Err(Error::unsupported(message)); - if proxy.port == 0 { - return refused("a network proxy needs a port between 1 and 65535".to_owned()); - } - // OpenVMM's DNS service at the gateway takes the guest's TCP connections to this port before - // the proxy's relay does: the proxy would receive nothing, and the guest would reach the - // host's resolver instead. - if proxy.port == DNS_PORT { - return refused(format!( - "a network proxy needs a port other than {DNS_PORT}, where OpenVMM answers DNS at the \ - guest's gateway" - )); - } - if policy.egress != Access::Deny { - return refused( - "a network proxy needs egress denied by default, since the proxy is the guest's only \ - way out" - .to_owned(), - ); - } - for rules in [&policy.allow, &policy.deny] { - if !rules.rules.is_empty() { - return refused(format!( - "a network proxy takes no {} rules, since the proxy is the guest's only way out", - rules.name - )); - } - } - if policy.ingress != Access::Deny { - return refused("a network proxy needs ingress denied".to_owned()); - } - if policy.host_loopback != HostLoopback::Deny { - return refused("a network proxy needs host-loopback access denied".to_owned()); - } - if !policy.host_loopback_forwards.is_empty() { - return refused("a network proxy takes no host-loopback forwards".to_owned()); - } - Ok(()) -} - -/// Parses the rules of one action, each into the rules that the lowering takes, as an egress -/// policy file's MXC rules are parsed: an empty `to` or `ports` is a rule without that field. -fn parse_rules(rules: &EgressRules) -> Result>> { - rules - .rules - .iter() - .enumerate() - .map(|(index, rule)| parse_rule(rule, &format!("{}[{index}]", rules.name))) - .collect() -} - -/// Parses one rule, which messages call `description`. -fn parse_rule(rule: &EgressRule, description: &str) -> Result> { - let mut destinations = None; - if !rule.to.is_empty() { - let mut parsed = Vec::with_capacity(rule.to.len()); - for (index, peer) in rule.to.iter().enumerate() { - let description = format!("{description}.to[{index}]"); - let network = |value: &str, field: &str| lowering::network(value, field); - let parent = network(&peer.cidr, &format!("{description}.cidr"))?; - parsed.push(lowering::destination( - parent, - peer.except.iter().enumerate().map(|(exception, value)| { - network(value, &format!("{description}.except[{exception}]")) - }), - &description, - )?); - } - destinations = Some(lowering::group_destinations(&parsed)); - } - let mut selectors = None; - if !rule.ports.is_empty() { - let mut parsed = Vec::new(); - for (index, port) in rule.ports.iter().enumerate() { - let protocol = match port.protocol { - Protocol::Tcp => RuleProtocol::Tcp, - Protocol::Udp => RuleProtocol::Udp, - Protocol::Icmp => RuleProtocol::Icmp, - Protocol::Any => RuleProtocol::Any, - }; - let value = |port: u16| PortValue::Integer(i128::from(port)); - parsed.extend(lowering::port_selectors( - protocol, - port.port.map(value), - port.end_port.map(value), - &format!("{description}.ports[{index}]"), - )?); - } - selectors = Some(parsed); - } - Ok(lowering::combine(destinations, selectors)) -} - -/// Checks the host-loopback forwards of a policy whose rules lower, and whose host-loopback -/// access is allowed when it has forwards, against the rules of -/// [`NetworkPolicy::host_loopback_forwards`]. `deny` holds the parsed deny rules. -fn check_loopback_forwards( - policy: &NetworkPolicy, - deny: &[Vec], - guest_network: &str, -) -> Result<()> { - let forwards = &policy.host_loopback_forwards; - if forwards.is_empty() { - return Ok(()); - } - let unsupported = |message: String| Err(Error::unsupported(message)); - if forwards.len() > MAX_LOOPBACK_FORWARDS { - return unsupported(format!( - "the openvmm backend accepts at most {MAX_LOOPBACK_FORWARDS} host-loopback forwards" - )); - } - let mut bindings = HashSet::new(); - let mut targets = HashSet::new(); - for forward in forwards { - if forward.host_port == 0 || forward.guest_port == 0 { - return unsupported( - "host-loopback forward ports must be between 1 and 65535".to_owned(), - ); - } - if !bindings.insert((forward.protocol, forward.host_port)) { - return unsupported(format!( - "host-loopback forwards bind {} host port {} more than once", - forward.protocol.as_str(), - forward.host_port - )); - } - // OpenVMM keys a forward's listener by its guest port, so a second forward to one guest - // port would fail to bind when the VM starts. - if !targets.insert((forward.protocol, forward.guest_port)) { - return unsupported(format!( - "host-loopback forwards reach {} guest port {} more than once", - forward.protocol.as_str(), - forward.guest_port - )); - } - } - if forwards - .iter() - .all(|forward| forward.protocol != ForwardProtocol::Udp) - { - // OpenVMM lets the guest's replies on a forwarded TCP connection through whatever the - // egress policy, since it carries that connection. - return Ok(()); - } - if policy.egress != Access::Allow { - return unsupported( - "host-loopback UDP forwards need egress allowed by default for their replies" - .to_owned(), - ); - } - let Some((first, last)) = guest_subnet(guest_network) else { - return unsupported(format!("guest network {guest_network:?} is not valid")); - }; - // A deny rule covers the guest network if it selects one of its addresses, on any protocol. - for (index, rules) in deny.iter().enumerate() { - let covers = rules.iter().any(|rule| { - rule.family == Family::V4 - && rule - .addresses - .iter() - .any(|&(start, end)| start <= last && first <= end) - }); - if covers { - return unsupported(format!( - "{}[{index}] cannot cover the guest network, which carries host-loopback UDP \ - forward replies", - policy.deny.name - )); - } - } - Ok(()) -} - -/// Returns the addresses of the IPv4 network of `guest_network`, a static guest address in -/// `ADDRESS/PREFIX` form. -fn guest_subnet(guest_network: &str) -> Option { - let (address, prefix) = guest_network.split_once('/')?; - let address: Ipv4Addr = address.parse().ok()?; - let prefix = cidr::parse_prefix_length(prefix).filter(|prefix| (1..=32).contains(prefix))?; - let mask = u32::MAX << (32 - u32::from(prefix)); - let network = u32::from(address) & mask; - Some((u128::from(network), u128::from(network | !mask))) -} - #[cfg(test)] -mod tests { - use super::*; - use crate::ErrorKind; - - fn rules(arguments: &[String], option: &str) -> Vec { - arguments - .windows(2) - .filter(|pair| pair[0] == option) - .map(|pair| pair[1].clone()) - .collect() - } - - #[test] - fn default_postures_map_to_portable_profile_options() { - assert!(openvmm_arguments(None, "10.0.0.2/24").unwrap().is_empty()); - assert!( - openvmm_arguments(Some(&NetworkPolicy::new(Access::Deny)), "10.0.0.2/24") - .unwrap() - .is_empty() - ); - assert_eq!( - openvmm_arguments(Some(&NetworkPolicy::new(Access::Allow)), "10.0.0.2/24").unwrap(), - [ - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--network-egress", - "allow", - "--network-ingress", - "deny", - "--host-loopback", - "deny", - ] - ); - // Deny rules under a deny default change nothing, so no device is attached. - let denied = NetworkPolicy::new(Access::Deny).with_deny(EgressRule::to("1.2.3.4")); - assert!( - openvmm_arguments(Some(&denied), "10.0.0.2/24") - .unwrap() - .is_empty() - ); - // Ingress needs the device even when egress is denied, and host loopback stays denied. - for egress in [Access::Deny, Access::Allow] { - let mut ingress = NetworkPolicy::new(egress); - ingress.ingress = Access::Allow; - assert_eq!( - openvmm_arguments(Some(&ingress), "10.0.0.2/24").unwrap(), - [ - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--network-egress", - egress.as_str(), - "--network-ingress", - "allow", - "--host-loopback", - "deny", - ] - ); - } - // Host-loopback access needs the device even when egress and ingress are denied. - let mut host_loopback = NetworkPolicy::new(Access::Deny); - host_loopback.host_loopback = HostLoopback::Allow; - assert_eq!( - openvmm_arguments(Some(&host_loopback), "10.0.0.2/24").unwrap(), - [ - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--network-egress", - "deny", - "--network-ingress", - "deny", - "--host-loopback", - "allow", - ] - ); - // So does outbound-only access, which OpenVMM's default provides. - host_loopback.host_loopback = HostLoopback::Outbound; - assert_eq!( - openvmm_arguments(Some(&host_loopback), "10.0.0.2/24").unwrap(), - [ - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--network-egress", - "deny", - "--network-ingress", - "deny", - ] - ); - } - - #[test] - fn host_loopback_access_needs_room_for_host_peers() { - let mut policy = NetworkPolicy::new(Access::Deny); - policy.host_loopback = HostLoopback::Allow; - // A /30 network holds only the gateway and the guest. - let error = openvmm_arguments(Some(&policy), "10.0.0.2/30").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert!(error.message().contains("/29 or shorter"), "{error}"); - for guest_network in ["10.0.0.2/29", "10.0.0.2/24"] { - let arguments = openvmm_arguments(Some(&policy), guest_network).unwrap(); - assert!( - has_pair(&arguments, "--host-loopback", "allow"), - "{guest_network}" - ); - } - // Host-loopback access that is denied, or open only to the guest, needs no host peers. - let mut denied = NetworkPolicy::new(Access::Allow); - denied.host_loopback = HostLoopback::Deny; - let arguments = openvmm_arguments(Some(&denied), "10.0.0.2/30").unwrap(); - assert!(has_pair(&arguments, "--host-loopback", "deny")); - denied.host_loopback = HostLoopback::Outbound; - let arguments = openvmm_arguments(Some(&denied), "10.0.0.2/30").unwrap(); - assert!( - !arguments - .iter() - .any(|argument| argument == "--host-loopback") - ); - } - - #[test] - fn host_loopback_forwards_follow_openvmm_rules() { - let forward = |protocol, host_port, guest_port| LoopbackForward { - protocol, - host_port, - guest_port, - }; - let forwarded = |forwards: Vec| { - let mut policy = NetworkPolicy::new(Access::Allow); - policy.host_loopback = HostLoopback::Allow; - policy.host_loopback_forwards = forwards; - policy - }; - let rejected = |policy: &NetworkPolicy, expected: &str| { - let error = openvmm_arguments(Some(policy), "10.0.0.2/24").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert!(error.message().contains(expected), "{error}"); - }; - - let valid = forwarded(vec![ - forward(ForwardProtocol::Tcp, 18080, 8080), - forward(ForwardProtocol::Udp, 18080, 8081), - // One guest port may receive a forward of each protocol. - forward(ForwardProtocol::Udp, 18081, 8080), - ]); - let arguments = openvmm_arguments(Some(&valid), "10.0.0.2/24").unwrap(); - assert_eq!( - rules(&arguments, "--host-loopback-forward"), - ["tcp:18080:8080", "udp:18080:8081", "udp:18081:8080"] - ); - assert!(has_pair(&arguments, "--host-loopback", "allow")); - - let mut denied = valid.clone(); - denied.host_loopback = HostLoopback::Deny; - rejected(&denied, "need host-loopback access allowed"); - // Forwards publish host ports to the guest, which outbound-only access does not. - let mut outbound = valid.clone(); - outbound.host_loopback = HostLoopback::Outbound; - rejected(&outbound, "need host-loopback access allowed"); - let mut closed = valid.clone(); - closed.egress = Access::Deny; - rejected( - &closed, - "host-loopback UDP forwards need egress allowed by default for their replies", - ); - // OpenVMM lets the guest's replies on a forwarded TCP connection through whatever the - // egress policy, so TCP forwards alone need neither egress allowed by default nor a guest - // network that no deny rule covers. - let mut tcp = forwarded(vec![forward(ForwardProtocol::Tcp, 18080, 8080)]); - tcp.egress = Access::Deny; - let tcp = tcp.with_deny(EgressRule::to("10.0.0.0/24")); - let arguments = openvmm_arguments(Some(&tcp), "10.0.0.2/24").unwrap(); - assert!(has_pair(&arguments, "--network-egress", "deny")); - assert_eq!(rules(&arguments, "--network-egress-deny"), ["10.0.0.0/24"]); - assert_eq!( - rules(&arguments, "--host-loopback-forward"), - ["tcp:18080:8080"] - ); - // A UDP forward beside them needs both. - let mut udp = tcp.clone(); - udp.host_loopback_forwards - .push(forward(ForwardProtocol::Udp, 18081, 8081)); - rejected( - &udp, - "host-loopback UDP forwards need egress allowed by default", - ); - udp.egress = Access::Allow; - rejected( - &udp, - "egress.deny[0] cannot cover the guest network, which carries host-loopback UDP \ - forward replies", - ); - rejected( - &forwarded(vec![ - forward(ForwardProtocol::Tcp, 18080, 8080), - forward(ForwardProtocol::Tcp, 18080, 8081), - ]), - "bind tcp host port 18080 more than once", - ); - // OpenVMM could not bind a second forward to one guest port. - rejected( - &forwarded(vec![ - forward(ForwardProtocol::Udp, 18080, 8080), - forward(ForwardProtocol::Udp, 18081, 8080), - ]), - "reach udp guest port 8080 more than once", - ); - rejected( - &forwarded(vec![forward(ForwardProtocol::Udp, 0, 8080)]), - "between 1 and 65535", - ); - rejected( - &forwarded( - (1..=65) - .map(|port| forward(ForwardProtocol::Tcp, port, port)) - .collect(), - ), - "at most 64", - ); - - // The guest's replies to forwarded traffic leave toward the guest network. - for rule in [ - EgressRule::default(), - EgressRule::to("10.0.0.0/8"), - EgressRule::to("10.0.0.0/24"), - EgressRule::to("10.0.0.77").on_port(Protocol::Tcp, 443), - // Networks are read as egress policy files read them, host bits and netmasks included. - EgressRule::to("10.0.0.77/24"), - EgressRule::to("10.0.0.0/255.255.255.0"), - ] { - rejected(&valid.clone().with_deny(rule), "forward replies"); - } - for rule in [ - EgressRule::to("10.0.1.0/24"), - EgressRule::to("fd00::/8"), - EgressRule { - to: vec![Destination { - cidr: "0.0.0.0/0".to_owned(), - except: vec!["10.0.0.0/24".to_owned()], - }], - ports: Vec::new(), - }, - ] { - openvmm_arguments(Some(&valid.clone().with_deny(rule)), "10.0.0.2/24").unwrap(); - } - } - - #[test] - fn rules_expand_exactly() { - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(EgressRule { - to: vec![Destination { - cidr: "10.0.0.0/30".to_owned(), - except: vec!["10.0.0.1".to_owned()], - }], - ports: vec![PortSelector { - protocol: Protocol::Tcp, - port: Some(80), - end_port: Some(81), - }], - }) - .with_allow(EgressRule::to("192.0.2.7").on_port(Protocol::Any, 53)) - .with_allow(EgressRule::to("198.51.100.0/24")); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - assert_eq!( - rules(&arguments, "--network-egress-allow"), - [ - "10.0.0.0/32:tcp:80-81", - "10.0.0.2/31:tcp:80-81", - "192.0.2.7/32:tcp:53", - "192.0.2.7/32:udp:53", - "198.51.100.0/24", - ] - ); - assert!(has_pair(&arguments, "--network-egress", "deny")); - - let egress = NetworkPolicy::new(Access::Allow).with_deny(EgressRule { - to: Vec::new(), - ports: vec![PortSelector { - protocol: Protocol::Udp, - port: Some(53), - end_port: None, - }], - }); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - // A rule without destinations matches both address families. - assert_eq!( - rules(&arguments, "--network-egress-deny"), - ["0.0.0.0/0:udp:53", "::/0:udp:53"] - ); - } - - #[test] - fn ipv6_rules_lower_exactly() { - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(EgressRule::to("2001:db8:1::/64").on_port(Protocol::Tcp, 443)) - .with_allow(EgressRule { - to: vec![Destination { - cidr: "2001:db8:2::/126".to_owned(), - except: vec!["2001:db8:2::1".to_owned()], - }], - ports: vec![PortSelector { - protocol: Protocol::Any, - port: Some(53), - end_port: None, - }], - }) - .with_allow(EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Icmp)) - .with_allow(EgressRule::to("::/0").on_protocol(Protocol::Icmp)) - .with_deny(EgressRule::to("2001:db8:1::123")); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - // Rules sort by family and then by address, so `::/0` leads the IPv6 rules. - assert_eq!( - rules(&arguments, "--network-egress-allow"), - [ - "192.0.2.0/24:icmp", - "::/0:icmp", - "2001:db8:1::/64:tcp:443", - "2001:db8:2::/128:tcp:53", - "2001:db8:2::/128:udp:53", - "2001:db8:2::2/127:tcp:53", - "2001:db8:2::2/127:udp:53", - ] - ); - assert_eq!( - rules(&arguments, "--network-egress-deny"), - ["2001:db8:1::123/128"] - ); - } - - #[test] - fn rules_lower_like_egress_policy_files() { - let lowered = |rules: Vec| { - let mut egress = NetworkPolicy::new(Access::Deny); - egress.allow.rules = rules; - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - self::rules(&arguments, "--network-egress-allow") - }; - let tcp = |cidr: &str, port, end_port| { - EgressRule::to(cidr).on_port_range(Protocol::Tcp, port, end_port) - }; - // Destinations merge into the fewest networks, and port ranges into one range. - assert_eq!( - lowered(vec![EgressRule { - to: (0..300) - .map(|index| Destination::new(format!("10.0.{}.0/24", index % 256))) - .collect(), - ports: vec![PortSelector { - protocol: Protocol::Tcp, - port: Some(1), - end_port: Some(2), - }], - }]), - ["10.0.0.0/16:tcp:1-2"] - ); - assert_eq!( - lowered(vec![ - tcp("192.0.2.7", 8000, 8004), - tcp("192.0.2.7", 8003, 8010), - tcp("192.0.2.6", 8000, 8010), - ]), - ["192.0.2.6/31:tcp:8000-8010"] - ); - // Rules for every port of a protocol, or every protocol, subsume narrower ones. - assert_eq!( - lowered(vec![ - EgressRule::to("192.0.2.7").on_port(Protocol::Tcp, 443), - EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Tcp), - EgressRule::to("198.51.100.0/24").on_port(Protocol::Udp, 53), - EgressRule::to("198.51.100.0/23"), - ]), - ["192.0.2.0/24:tcp", "198.51.100.0/23"] - ); - // Host bits are cleared, as an egress policy file's are. - assert_eq!( - lowered(vec![EgressRule::to("10.0.0.5/255.255.255.0")]), - ["10.0.0.0/24"] - ); - } - - #[test] - fn invalid_rules_are_rejected_with_the_policy_compilers_messages() { - let port = |protocol, port, end_port| EgressRule { - to: vec![Destination::new("10.0.0.0/8")], - ports: vec![PortSelector { - protocol, - port, - end_port, - }], - }; - let excluding = |cidr: &str, except: &str| EgressRule { - to: vec![ - Destination::new("192.0.2.0/24"), - Destination { - cidr: cidr.to_owned(), - except: vec![except.to_owned()], - }, - ], - ports: Vec::new(), - }; - for (rule, message) in [ - ( - port(Protocol::Icmp, Some(8), None), - "egress.allow[0].ports[0].port is not supported with icmp", - ), - // Structural validation rejects these first, unless the backend is called directly. - ( - port(Protocol::Tcp, Some(90), Some(80)), - "egress.allow[0].ports[0].endPort 80 cannot be below port 90", - ), - ( - port(Protocol::Udp, None, Some(80)), - "egress.allow[0].ports[0].endPort requires port", - ), - ( - port(Protocol::Tcp, Some(0), None), - "egress.allow[0].ports[0].port must be between 1 and 65535", - ), - ( - port(Protocol::Any, Some(80), Some(0)), - "egress.allow[0].ports[0].endPort must be between 1 and 65535", - ), - ( - EgressRule::to("example.com"), - "egress.allow[0].to[0].cidr is not a valid IPv4 or IPv6 CIDR: \"example.com\"", - ), - ( - EgressRule::to("fe80::1%eth0/64"), - "egress.allow[0].to[0].cidr must not name an IPv6 scope: \"fe80::1%eth0/64\"", - ), - ( - excluding("10.0.0.0/8", "11.0.0.0/16"), - "egress.allow[0].to[1].except[0] must be contained in 10.0.0.0/8", - ), - ( - excluding("10.0.0.7/8", "10.1.0.0/+16"), - "egress.allow[0].to[1].except[0] is not a valid IPv4 or IPv6 CIDR: \"10.1.0.0/+16\"", - ), - // 129 separate ranges of both TCP and UDP need 258 OpenVMM rules. - ( - EgressRule { - to: vec![Destination::new("10.0.0.0/8")], - ports: (0..129) - .map(|index| PortSelector { - protocol: Protocol::Any, - port: Some(3 * index + 1), - end_port: Some(3 * index + 2), - }) - .collect(), - }, - "egress.allow emits at most 256 native rules", - ), - ] { - let egress = NetworkPolicy::new(Access::Deny).with_allow(rule.clone()); - let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{rule:?}"); - assert_eq!(error.message(), message, "{rule:?}"); - } - // The allow rules are checked and lowered before the deny rules. - let egress = NetworkPolicy::new(Access::Allow) - .with_allow(EgressRule::to("10.0.0.0/33")) - .with_deny(port(Protocol::Icmp, Some(8), None)); - let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); - assert_eq!( - error.message(), - "egress.allow[0].to[0].cidr is not a valid IPv4 or IPv6 CIDR: \"10.0.0.0/33\"" - ); - } - - #[test] - fn exceptions_that_split_a_network_too_finely_are_rejected() { - // Without a limit, these exceptions would split the address space into 512 * 23 networks. - let peer = Destination { - cidr: "0.0.0.0/0".to_owned(), - except: (0..512u32) - .map(|index| std::net::Ipv4Addr::from(index << 23).to_string()) - .collect(), - }; - let rule = EgressRule { - to: vec![peer], - ports: Vec::new(), - }; - let egress = NetworkPolicy::new(Access::Deny).with_allow(rule); - let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert_eq!( - error.message(), - "egress.allow emits at most 256 native rules" - ); - } - - #[test] - fn exclusions_remain_local_to_their_rules() { - let excluding = |cidr: &str, excluded: &str| EgressRule { - to: vec![Destination { - cidr: cidr.to_owned(), - except: vec![excluded.to_owned()], - }], - ports: Vec::new(), - }; - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(excluding("192.0.2.0/24", "192.0.2.128/25")) - .with_allow(EgressRule::to("192.0.2.200")) - .with_deny(excluding("198.51.100.0/24", "198.51.100.128/25")); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - - assert_eq!( - rules(&arguments, "--network-egress-allow"), - ["192.0.2.0/25", "192.0.2.200/32"] - ); - assert_eq!( - rules(&arguments, "--network-egress-deny"), - ["198.51.100.0/25"] - ); - } - - fn has_pair(arguments: &[String], name: &str, value: &str) -> bool { - arguments - .windows(2) - .any(|pair| pair[0] == name && pair[1] == value) - } - - #[test] - fn protocols_without_ports_expand_to_protocol_rules() { - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Tcp)) - .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Udp)) - .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Icmp)) - .with_allow(EgressRule::to("198.51.100.0/24").on_protocol(Protocol::Any)) - .with_allow(EgressRule::to("203.0.113.9").on_port(Protocol::Any, 443)) - .with_deny( - EgressRule::to("192.0.2.7") - .on_protocol(Protocol::Icmp) - .on_port(Protocol::Tcp, 22), - ); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - assert_eq!( - rules(&arguments, "--network-egress-allow"), - [ - "192.0.2.7/32:icmp", - "192.0.2.7/32:tcp", - "192.0.2.7/32:udp", - "198.51.100.0/24", - "203.0.113.9/32:tcp:443", - "203.0.113.9/32:udp:443", - ] - ); - assert_eq!( - rules(&arguments, "--network-egress-deny"), - ["192.0.2.7/32:icmp", "192.0.2.7/32:tcp:22"] - ); - } - - #[test] - fn port_ranges_expand_to_one_rule_per_network_and_protocol() { - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(EgressRule::to("192.0.2.7").on_port_range(Protocol::Tcp, 8000, 8010)) - .with_allow(EgressRule::to("192.0.2.7").on_port_range(Protocol::Udp, 5000, 5010)) - .with_allow(EgressRule::to("198.51.100.0/24").on_port_range(Protocol::Any, 1, 65535)) - // A range of one port is that port's rule. - .with_allow(EgressRule::to("203.0.113.9").on_port_range(Protocol::Tcp, 443, 443)) - .with_deny(EgressRule::to("192.0.2.7").on_port(Protocol::Tcp, 8005)); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - assert_eq!( - rules(&arguments, "--network-egress-allow"), - [ - "192.0.2.7/32:tcp:8000-8010", - "192.0.2.7/32:udp:5000-5010", - "198.51.100.0/24:tcp:1-65535", - "198.51.100.0/24:udp:1-65535", - "203.0.113.9/32:tcp:443", - ] - ); - assert_eq!( - rules(&arguments, "--network-egress-deny"), - ["192.0.2.7/32:tcp:8005"] - ); - } - - /// The proxy-only policy of a proxy at port 3128 of host loopback. - fn proxy_only() -> NetworkPolicy { - NetworkPolicy::proxy_only(NetworkProxy::new(3128)) - } - - #[test] - fn proxy_only_policies_reach_the_proxy_at_the_gateway() { - for (guest_network, endpoint) in [ - ("10.0.0.2/24", "10.0.0.1:3128"), - ("172.16.40.9/16", "172.16.0.1:3128"), - // A /30 network holds the gateway and the guest, which is all that a proxy needs. - ("192.168.7.6/30", "192.168.7.5:3128"), - ] { - assert_eq!( - openvmm_arguments(Some(&proxy_only()), guest_network).unwrap(), - [ - "--net", - guest_network, - "--network-profile", - "portable", - "--network-egress", - "deny", - "--network-ingress", - "deny", - "--host-loopback", - "deny", - "--network-proxy", - endpoint, - ] - ); - } - // The scheme names only the URL. - let https = - NetworkPolicy::proxy_only(NetworkProxy::new(3128).with_scheme(ProxyScheme::Https)); - assert_eq!( - openvmm_arguments(Some(&https), "10.0.0.2/24").unwrap(), - openvmm_arguments(Some(&proxy_only()), "10.0.0.2/24").unwrap() - ); - let mut built = NetworkPolicy::new(Access::Deny); - built.proxy = Some(NetworkProxy::new(3128)); - assert_eq!(built, proxy_only()); - } - - #[test] - fn proxies_refuse_every_other_way_out() { - let refused = |edit: &dyn Fn(&mut NetworkPolicy), message: &str| { - let mut policy = proxy_only(); - edit(&mut policy); - let error = openvmm_arguments(Some(&policy), "10.0.0.2/24").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert_eq!(error.message(), message); - }; - let only_way_out = "since the proxy is the guest's only way out"; - refused( - &|policy| policy.egress = Access::Allow, - &format!("a network proxy needs egress denied by default, {only_way_out}"), - ); - refused( - &|policy| policy.allow.rules.push(EgressRule::to("192.0.2.0/24")), - &format!("a network proxy takes no egress.allow rules, {only_way_out}"), - ); - refused( - &|policy| policy.deny.rules.push(EgressRule::to("192.0.2.0/24")), - &format!("a network proxy takes no egress.deny rules, {only_way_out}"), - ); - refused( - &|policy| policy.ingress = Access::Allow, - "a network proxy needs ingress denied", - ); - for host_loopback in [HostLoopback::Allow, HostLoopback::Outbound] { - refused( - &|policy| policy.host_loopback = host_loopback, - "a network proxy needs host-loopback access denied", - ); - } - refused( - &|policy| { - policy.host_loopback_forwards.push(LoopbackForward { - protocol: ForwardProtocol::Tcp, - host_port: 18080, - guest_port: 8080, - }); - }, - "a network proxy takes no host-loopback forwards", - ); - refused( - &|policy| policy.proxy = Some(NetworkProxy::new(0)), - "a network proxy needs a port between 1 and 65535", - ); - // OpenVMM's DNS service at the gateway takes the guest's TCP connections to port 53 - // before the proxy's relay does, whatever the proxy's scheme. - refused( - &|policy| policy.proxy = Some(NetworkProxy::new(53).with_scheme(ProxyScheme::Http)), - "a network proxy needs a port other than 53, where OpenVMM answers DNS at the guest's \ - gateway", - ); - // The proxy's checks come first: a proxy beside allowed forwards is refused as a proxy. - refused( - &|policy| { - policy.egress = Access::Allow; - policy.host_loopback = HostLoopback::Allow; - policy.host_loopback_forwards.push(LoopbackForward { - protocol: ForwardProtocol::Tcp, - host_port: 18080, - guest_port: 8080, - }); - }, - &format!("a network proxy needs egress denied by default, {only_way_out}"), - ); - // The guest reaches the proxy at its gateway, which an invalid guest network lacks. - for guest_network in [ - "10.0.0.1/24", - "10.0.0.0/24", - "10.0.0.255/24", - "10.0.0.2/31", - "10.0.0.2", - "fd00::2/64", - ] { - let error = openvmm_arguments(Some(&proxy_only()), guest_network).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert_eq!( - error.message(), - format!("guest network {guest_network:?} is not valid") - ); - } - } - - #[test] - fn proxy_urls_name_the_gateway_as_the_guest_sees_it() { - let gateway = Ipv4Addr::new(10, 0, 0, 1); - assert_eq!(NetworkProxy::new(3128).url(gateway), "10.0.0.1:3128"); - assert_eq!( - NetworkProxy::new(3128) - .with_scheme(ProxyScheme::Http) - .url(gateway), - "http://10.0.0.1:3128" - ); - assert_eq!( - NetworkProxy::new(8443) - .with_scheme(ProxyScheme::Https) - .url(gateway), - "https://10.0.0.1:8443" - ); - } - - #[test] - fn proxy_urls_name_a_port_of_host_loopback() { - let http = |port| NetworkProxy::new(port).with_scheme(ProxyScheme::Http); - let https = |port| NetworkProxy::new(port).with_scheme(ProxyScheme::Https); - for (url, proxy) in [ - ("http://127.0.0.1:3128", http(3128)), - ("http://localhost:3128/", http(3128)), - ("https://127.0.0.1:65535", https(65535)), - ("https://localhost:1", https(1)), - ] { - assert_eq!(NetworkProxy::parse_url(url).unwrap(), proxy, "{url}"); - } - for (url, reason) in [ - ( - "127.0.0.1:3128", - "expected http://HOST:PORT or https://HOST:PORT", - ), - ("ftp://127.0.0.1:3128", "the scheme must be http or https"), - ("HTTP://127.0.0.1:3128", "the scheme must be http or https"), - ( - "http://10.0.0.1:3128", - "the host must be 127.0.0.1 or localhost", - ), - ( - "http://[::1]:3128", - "the host must be 127.0.0.1 or localhost", - ), - ( - "http://user@localhost:3128", - "the host must be 127.0.0.1 or localhost", - ), - ("http://127.0.0.1", "the port must be explicit"), - ("http://127.0.0.1:", "the port must be between 1 and 65535"), - ("http://127.0.0.1:0", "the port must be between 1 and 65535"), - ( - "http://127.0.0.1:65536", - "the port must be between 1 and 65535", - ), - ( - "http://127.0.0.1:+3128", - "the port must be between 1 and 65535", - ), - ( - "http://127.0.0.1:3128/path", - "nothing but a / may follow the port", - ), - ( - "http://127.0.0.1:3128?query", - "nothing but a / may follow the port", - ), - ( - "http://127.0.0.1:3128//", - "nothing but a / may follow the port", - ), - ] { - let error = NetworkProxy::parse_url(url).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{url}"); - assert_eq!( - error.message(), - format!("network proxy URL {url}: {reason}") - ); - } - assert_eq!(ProxyScheme::from_name("http").unwrap(), ProxyScheme::Http); - assert_eq!(ProxyScheme::from_name("https").unwrap(), ProxyScheme::Https); - let error = ProxyScheme::from_name("ftp").unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert_eq!( - error.message(), - "network proxy scheme \"ftp\" must be http or https" - ); - } - - #[test] - #[cfg(feature = "cli")] - fn proxy_endpoints_name_the_guest_gateway() { - let gateway = Ipv4Addr::new(10, 0, 0, 1); - assert_eq!( - NetworkProxy::parse_endpoint("10.0.0.1:3128", gateway).unwrap(), - NetworkProxy::new(3128) - ); - assert_eq!( - NetworkProxy::parse_endpoint("192.168.7.5:8080", Ipv4Addr::new(192, 168, 7, 5)) - .unwrap(), - NetworkProxy::new(8080) - ); - for (endpoint, reason) in [ - ( - "198.51.100.1:3128", - "the address must be the guest's gateway, 10.0.0.1", - ), - ( - "10.0.0.2:3128", - "the address must be the guest's gateway, 10.0.0.1", - ), - ("10.0.0.1", "expected IPV4:TCP-PORT"), - ("proxy:3128", "expected IPV4:TCP-PORT"), - ("[::1]:3128", "expected IPV4:TCP-PORT"), - ("10.0.0.1:", "the port must be between 1 and 65535"), - ("10.0.0.1:0", "the port must be between 1 and 65535"), - ("10.0.0.1:65536", "the port must be between 1 and 65535"), - ] { - let error = NetworkProxy::parse_endpoint(endpoint, gateway).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{endpoint}"); - assert_eq!(error.message(), format!("{endpoint}: {reason}")); - } - } - - #[test] - #[cfg(feature = "cli")] - fn native_rules_parse_as_openvmm_parses_them() { - let parsed = |value: &str| EgressRule::parse_native(value).unwrap(); - for (value, rule) in [ - ("10.0.0.0/8", EgressRule::to("10.0.0.0/8")), - // Host bits are cleared, and an address is a network of one address. - ( - "10.0.0.5/8:tcp", - EgressRule::to("10.0.0.0/8").on_protocol(Protocol::Tcp), - ), - ( - "192.0.2.7:udp:53", - EgressRule::to("192.0.2.7/32").on_port(Protocol::Udp, 53), - ), - ( - "192.0.2.7:tcp:8000-8010", - EgressRule::to("192.0.2.7/32").on_port_range(Protocol::Tcp, 8000, 8010), - ), - // A range of one port is that port. - ( - "192.0.2.7:tcp:443-443", - EgressRule::to("192.0.2.7/32").on_port(Protocol::Tcp, 443), - ), - ( - "192.0.2.0/24:icmp", - EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Icmp), - ), - ( - "::/0:icmp", - EgressRule::to("::/0").on_protocol(Protocol::Icmp), - ), - ( - "2001:db8::/32:tcp:443", - EgressRule::to("2001:db8::/32").on_port(Protocol::Tcp, 443), - ), - ("2001:db8::1", EgressRule::to("2001:db8::1/128")), - // Protocol names are not hexadecimal, so they never belong to an IPv6 address. - ( - "2001:db8::1:tcp:443", - EgressRule::to("2001:db8::1/128").on_port(Protocol::Tcp, 443), - ), - ] { - assert_eq!(parsed(value), rule, "{value}"); - } - let destination = "the destination must be an IPv4 or IPv6 address with an optional prefix"; - let ports = "ports must be between 1 and 65535"; - for (value, reason) in [ - ( - "10.0.0.0/8:tcp:80:90", - "expected CIDR[:PROTOCOL[:PORT[-PORT]]]", - ), - ("10.0.0.0/8:sctp", "the protocol must be tcp, udp, or icmp"), - ("10.0.0.0/8:TCP", "the protocol must be tcp, udp, or icmp"), - ("10.0.0.0/8:", "the protocol must be tcp, udp, or icmp"), - ("10.0.0.0/8:icmp:8", "icmp takes no port"), - ("10.0.0.0/8:tcp:0", ports), - ("10.0.0.0/8:tcp:65536", ports), - ("10.0.0.0/8:tcp:http", ports), - ("10.0.0.0/8:udp:0-80", ports), - ( - "10.0.0.0/8:tcp:90-80", - "the port range ends below its first port", - ), - ("10.0.0.0/8:tcp:-80", "a port range must be FIRST-LAST"), - ("10.0.0.0/8:tcp:80-", "a port range must be FIRST-LAST"), - ( - "10.0.0.0/8:tcp:80-90-100", - "a port range must be FIRST-LAST", - ), - ("10.0.0.0/33", destination), - ("10.0.0.0/255.0.0.0", destination), - ("example.com", destination), - ("example.com:tcp:443", destination), - ("fe80::1%eth0", destination), - ] { - let error = EgressRule::parse_native(value).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{value}"); - assert_eq!(error.message(), format!("{value}: {reason}")); - } - } - - #[test] - #[cfg(feature = "cli")] - fn lowered_rules_parse_back_to_the_same_rules() { - let egress = NetworkPolicy::new(Access::Deny) - .with_allow(EgressRule { - to: vec![Destination { - cidr: "10.0.0.0/30".to_owned(), - except: vec!["10.0.0.1".to_owned()], - }], - ports: vec![PortSelector { - protocol: Protocol::Any, - port: Some(80), - end_port: Some(81), - }], - }) - .with_allow(EgressRule::to("2001:db8::/32").on_protocol(Protocol::Icmp)) - .with_allow(EgressRule::to("198.51.100.0/24")) - .with_deny(EgressRule::to("2001:db8::123").on_port(Protocol::Udp, 53)); - let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); - let mut parsed = NetworkPolicy::new(Access::Deny); - for (option, rules) in [ - ("--network-egress-allow", &mut parsed.allow), - ("--network-egress-deny", &mut parsed.deny), - ] { - for rule in self::rules(&arguments, option) { - rules.rules.push(EgressRule::parse_native(&rule).unwrap()); - } - } - assert_eq!( - openvmm_arguments(Some(&parsed), "10.0.0.2/24").unwrap(), - arguments - ); - } - - #[test] - #[cfg(feature = "cli")] - fn loopback_forwards_parse_as_openvmm_parses_them() { - for (value, protocol, host_port, guest_port) in [ - ("tcp:18080:8080", ForwardProtocol::Tcp, 18080, 8080), - ("udp:1:65535", ForwardProtocol::Udp, 1, 65535), - ] { - assert_eq!( - LoopbackForward::parse(value).unwrap(), - LoopbackForward { - protocol, - host_port, - guest_port - } - ); - } - let format = "expected tcp:HOST_PORT:GUEST_PORT or udp:HOST_PORT:GUEST_PORT"; - let ports = "ports must be between 1 and 65535"; - for (value, reason) in [ - ("8080", format), - ("tcp:8080", format), - ("tcp:8080:80:90", format), - ("sctp:8080:80", "the protocol must be tcp or udp"), - ("tcp:0:80", ports), - ("tcp:8080:0", ports), - ("udp:65536:80", ports), - ("udp:8080:http", ports), - ] { - let error = LoopbackForward::parse(value).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{value}"); - assert_eq!(error.message(), format!("{value}: {reason}")); - } - } -} +mod tests; diff --git a/nvx-rs/src/host/network/proxy.rs b/nvx-rs/src/host/network/proxy.rs new file mode 100644 index 000000000..a81cbeed2 --- /dev/null +++ b/nvx-rs/src/host/network/proxy.rs @@ -0,0 +1,140 @@ +//! The HTTP or HTTPS proxy on host IPv4 loopback that is the one way out of a proxy-only network. + +use std::net::Ipv4Addr; + +use super::rule::parse_port; +use crate::error::{Error, Result}; + +/// The scheme of a [`NetworkProxy`]'s URL. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProxyScheme { + /// `http`. + Http, + /// `https`. + Https, +} + +impl ProxyScheme { + /// Returns the scheme's name: `http` or `https`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + } + } + + /// Returns the scheme that `name` names: `http` or `https`, in lowercase. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for any other name. + pub fn from_name(name: &str) -> Result { + match name { + "http" => Ok(Self::Http), + "https" => Ok(Self::Https), + _ => Err(Error::unsupported(format!( + "network proxy scheme {name:?} must be http or https" + ))), + } + } +} + +/// An HTTP or HTTPS proxy that listens on host IPv4 loopback: the one way out of a proxy-only +/// network, which its guest reaches at the same port of its gateway. +/// +/// OpenVMM relays the guest's TCP connections to `port` of its gateway to `127.0.0.1:port` on the +/// host, whatever they carry. The scheme only names the URL that [`url`](Self::url) gives: an +/// HTTPS proxy's certificate must therefore be valid for the gateway's address. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NetworkProxy { + /// TCP port where the proxy listens on `127.0.0.1`, and where the guest reaches it at its + /// gateway; nonzero, and not 53, where OpenVMM answers DNS at the gateway, over TCP too, + /// before it relays a connection to a proxy. + pub port: u16, + /// Scheme of the proxy's URL, if any. + pub scheme: Option, +} + +impl NetworkProxy { + /// Creates a proxy at `port` of host loopback, whose URL has no scheme. + pub const fn new(port: u16) -> Self { + Self { port, scheme: None } + } + + /// Names the scheme of the proxy's URL. + #[must_use] + pub const fn with_scheme(mut self, scheme: ProxyScheme) -> Self { + self.scheme = Some(scheme); + self + } + + /// Parses the URL of a proxy on host loopback, as the host names it: `http` or `https`, + /// `127.0.0.1` or `localhost`, and an explicit port, such as `http://127.0.0.1:3128`, with at + /// most a `/` after the port. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another scheme, a + /// host other than IPv4 loopback, a missing or zero port, or a path, query, or fragment. + pub fn parse_url(url: &str) -> Result { + let refused = |reason: &str| { + Err(Error::unsupported(format!( + "network proxy URL {url}: {reason}" + ))) + }; + let Some((scheme, rest)) = url.split_once("://") else { + return refused("expected http://HOST:PORT or https://HOST:PORT"); + }; + let scheme = match ProxyScheme::from_name(scheme) { + Ok(scheme) => scheme, + Err(_) => return refused("the scheme must be http or https"), + }; + let authority = rest.strip_suffix('/').unwrap_or(rest); + if authority.contains(['/', '?', '#']) { + return refused("nothing but a / may follow the port"); + } + let Some((host, port)) = authority.rsplit_once(':') else { + return refused("the port must be explicit"); + }; + if !matches!(host, "127.0.0.1" | "localhost") { + return refused("the host must be 127.0.0.1 or localhost"); + } + let port = Some(port) + .filter(|port| port.bytes().all(|byte| byte.is_ascii_digit())) + .and_then(parse_port); + let Some(port) = port else { + return refused("the port must be between 1 and 65535"); + }; + Ok(Self::new(port).with_scheme(scheme)) + } + + /// Parses OpenVMM's `--network-proxy` value, `IPV4:TCP-PORT`, for a guest whose gateway is + /// `gateway`, which the address must be. The proxy's URL has no scheme. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that + /// starts with `endpoint`. + #[cfg(feature = "cli")] + pub(crate) fn parse_endpoint(endpoint: &str, gateway: Ipv4Addr) -> Result { + let refused = |reason: String| Err(Error::unsupported(format!("{endpoint}: {reason}"))); + let Some((address, port)) = endpoint + .rsplit_once(':') + .and_then(|(address, port)| Some((address.parse::().ok()?, port))) + else { + return refused("expected IPV4:TCP-PORT".to_owned()); + }; + let Some(port) = parse_port(port) else { + return refused("the port must be between 1 and 65535".to_owned()); + }; + if address != gateway { + return refused(format!( + "the address must be the guest's gateway, {gateway}" + )); + } + Ok(Self::new(port)) + } + + /// Returns the proxy's URL as the guest sees it, at `gateway`: `SCHEME://GATEWAY:PORT`, or + /// `GATEWAY:PORT` without a scheme. + pub fn url(&self, gateway: Ipv4Addr) -> String { + match self.scheme { + Some(scheme) => format!("{}://{gateway}:{}", scheme.as_str(), self.port), + None => format!("{gateway}:{}", self.port), + } + } +} diff --git a/nvx-rs/src/host/network/rule.rs b/nvx-rs/src/host/network/rule.rs new file mode 100644 index 000000000..bc0aeeba4 --- /dev/null +++ b/nvx-rs/src/host/network/rule.rs @@ -0,0 +1,305 @@ +//! The allow and deny decision, the egress rules of a network posture and their native form, and +//! the parsing of the rules of an action into the rules that the lowering takes. + +use super::lowering::{self, PortValue, Rule, RuleProtocol}; +#[cfg(feature = "cli")] +use crate::error::Error; +use crate::error::Result; +#[cfg(feature = "cli")] +use crate::net::cidr; + +/// Allow or deny decision of a network posture. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Access { + /// Permit the traffic. + Allow, + /// Block the traffic. + Deny, +} + +impl Access { + /// Returns OpenVMM's spelling of this decision. + pub const fn as_str(self) -> &'static str { + match self { + Self::Allow => "allow", + Self::Deny => "deny", + } + } +} + +/// Transport protocol of a [`PortSelector`]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum Protocol { + /// TCP. + Tcp, + /// UDP. + Udp, + /// ICMP, or ICMPv6 for IPv6 networks. + Icmp, + /// Any protocol, or TCP and UDP with a port. + #[default] + Any, +} + +/// A destination network of an [`EgressRule`], without its exceptions. +/// +/// Networks are read as egress policy files read their CIDRs ([`policy`]): host bits are cleared, +/// IPv4 netmasks such as `/255.0.0.0` are accepted, and IPv6 scopes are refused. +/// +/// [`policy`]: crate::host::network::policy +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Destination { + /// Destination address or CIDR, such as `"10.0.0.0/8"` or `"192.0.2.1"`. + pub cidr: String, + /// Sub-networks of `cidr` that the rule does not match, each within `cidr`. + pub except: Vec, +} + +impl Destination { + /// Creates a destination for `cidr` without exceptions. + pub fn new(cidr: impl Into) -> Self { + Self { + cidr: cidr.into(), + except: Vec::new(), + } + } +} + +/// A destination protocol and port range of an [`EgressRule`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PortSelector { + /// Transport protocol. + pub protocol: Protocol, + /// First destination port. `None` matches every port. + pub port: Option, + /// Last destination port of a range that starts at `port`. + pub end_port: Option, +} + +/// Traffic to some destinations and ports. +/// +/// An empty `to` matches every destination of both families, and an empty `ports` matches every +/// protocol and port. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EgressRule { + /// Destination networks. + pub to: Vec, + /// Destination protocols and ports. + pub ports: Vec, +} + +impl EgressRule { + /// Creates a rule that matches every protocol and port of `cidr`, such as `"10.0.0.0/8"` or + /// `"192.0.2.1"`. + pub fn to(cidr: impl Into) -> Self { + Self { + to: vec![Destination::new(cidr)], + ports: Vec::new(), + } + } + + /// Restricts the rule to one port of `protocol`. + #[must_use] + pub fn on_port(mut self, protocol: Protocol, port: u16) -> Self { + self.ports.push(PortSelector { + protocol, + port: Some(port), + end_port: None, + }); + self + } + + /// Restricts the rule to the ports of `protocol` from `port` through `end_port`, both + /// included. + #[must_use] + pub fn on_port_range(mut self, protocol: Protocol, port: u16, end_port: u16) -> Self { + self.ports.push(PortSelector { + protocol, + port: Some(port), + end_port: Some(end_port), + }); + self + } + + /// Restricts the rule to `protocol` on every port. + #[must_use] + pub fn on_protocol(mut self, protocol: Protocol) -> Self { + self.ports.push(PortSelector { + protocol, + port: None, + end_port: None, + }); + self + } + + /// Parses one of OpenVMM's native rules, the value of an `--network-egress-allow` or + /// `--network-egress-deny` option, as OpenVMM parses it: `CIDR`, `CIDR:PROTOCOL`, + /// `CIDR:PROTOCOL:PORT`, or `CIDR:PROTOCOL:FIRST-LAST`, where `CIDR` is an IPv4 or IPv6 + /// address with an optional prefix length, whose host bits are cleared, `PROTOCOL` is `tcp`, + /// `udp`, or `icmp`, and only `tcp` and `udp` take ports. The rule's destination is the + /// canonical network. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) and a message that + /// starts with `value`. + #[cfg(feature = "cli")] + pub(crate) fn parse_native(value: &str) -> Result { + let refused = |reason: &str| Err(Error::unsupported(format!("{value}: {reason}"))); + let (destination, selector) = split_native_rule(value); + let (protocol, ports) = + match selector.map(|selector| selector.split(':').collect::>()) { + None => (None, None), + Some(fields) => match fields[..] { + [protocol] => (Some(protocol), None), + [protocol, ports] => (Some(protocol), Some(ports)), + _ => return refused("expected CIDR[:PROTOCOL[:PORT[-PORT]]]"), + }, + }; + let protocol = match protocol { + None => None, + Some("tcp") => Some(Protocol::Tcp), + Some("udp") => Some(Protocol::Udp), + Some("icmp") => Some(Protocol::Icmp), + Some(_) => return refused("the protocol must be tcp, udp, or icmp"), + }; + let ports = match ports { + None => None, + Some(_) if protocol == Some(Protocol::Icmp) => return refused("icmp takes no port"), + Some(ports) => match ports.split_once('-') { + None => match parse_port(ports) { + Some(port) => Some((port, port)), + None => return refused("ports must be between 1 and 65535"), + }, + Some((first, last)) + if first.is_empty() || last.is_empty() || last.contains('-') => + { + return refused("a port range must be FIRST-LAST"); + } + Some((first, last)) => match (parse_port(first), parse_port(last)) { + (Some(first), Some(last)) if first <= last => Some((first, last)), + (Some(_), Some(_)) => { + return refused("the port range ends below its first port"); + } + _ => return refused("ports must be between 1 and 65535"), + }, + }, + }; + let Ok(destination) = cidr::Cidr::parse_masked(destination) else { + return refused( + "the destination must be an IPv4 or IPv6 address with an optional prefix", + ); + }; + let rule = Self::to(destination.to_string()); + // A range of one port is that port. + Ok(match (protocol, ports) { + (None, _) => rule, + (Some(protocol), None) => rule.on_protocol(protocol), + (Some(protocol), Some((first, last))) if first == last => rule.on_port(protocol, first), + (Some(protocol), Some((first, last))) => rule.on_port_range(protocol, first, last), + }) + } +} + +/// Splits a native rule into its destination and its optional protocol and ports, where OpenVMM +/// splits it: an IPv4 address holds no `:`, so its first `:` starts the selector; a network with a +/// prefix ends at the first `:` after its `/`; and an IPv6 address without a prefix ends at the +/// last `:` that leaves an IPv6 address before it, since protocol names are not hexadecimal. +#[cfg(feature = "cli")] +fn split_native_rule(value: &str) -> (&str, Option<&str>) { + let split_at = |index: usize| (&value[..index], Some(&value[index + 1..])); + if let Some(slash) = value.find('/') { + return match value[slash..].find(':') { + Some(colon) => split_at(slash + colon), + None => (value, None), + }; + } + match value.find(':') { + None => (value, None), + Some(colon) if value[..colon].contains('.') => split_at(colon), + Some(_) if value.parse::().is_ok() => (value, None), + Some(_) => value + .rmatch_indices(':') + .map(|(index, _)| index) + .find(|&index| value[..index].parse::().is_ok()) + .map_or((value, None), split_at), + } +} + +/// Returns the port that `text` names, read as OpenVMM reads its ports, if it is nonzero. +pub(super) fn parse_port(text: &str) -> Option { + text.parse().ok().filter(|&port| port != 0) +} + +/// The egress rules of one action, and the name by which messages refer to them, such as +/// `egress.allow`. Messages name a rule by its index and its fields by their MXC names, as in +/// `egress.allow[0].to[1].cidr`, and the action by the name, as in `egress.allow emits at most 256 +/// native rules`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EgressRules { + /// The name of the rules in messages. + pub name: String, + /// The rules. + pub rules: Vec, +} + +impl EgressRules { + /// Creates an empty list of rules that messages call `name`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + rules: Vec::new(), + } + } +} + +/// Parses the rules of one action, each into the rules that the lowering takes, as an egress +/// policy file's MXC rules are parsed: an empty `to` or `ports` is a rule without that field. +pub(super) fn parse_rules(rules: &EgressRules) -> Result>> { + rules + .rules + .iter() + .enumerate() + .map(|(index, rule)| parse_rule(rule, &format!("{}[{index}]", rules.name))) + .collect() +} + +/// Parses one rule, which messages call `description`. +fn parse_rule(rule: &EgressRule, description: &str) -> Result> { + let mut destinations = None; + if !rule.to.is_empty() { + let mut parsed = Vec::with_capacity(rule.to.len()); + for (index, peer) in rule.to.iter().enumerate() { + let description = format!("{description}.to[{index}]"); + let network = |value: &str, field: &str| lowering::network(value, field); + let parent = network(&peer.cidr, &format!("{description}.cidr"))?; + parsed.push(lowering::destination( + parent, + peer.except.iter().enumerate().map(|(exception, value)| { + network(value, &format!("{description}.except[{exception}]")) + }), + &description, + )?); + } + destinations = Some(lowering::group_destinations(&parsed)); + } + let mut selectors = None; + if !rule.ports.is_empty() { + let mut parsed = Vec::new(); + for (index, port) in rule.ports.iter().enumerate() { + let protocol = match port.protocol { + Protocol::Tcp => RuleProtocol::Tcp, + Protocol::Udp => RuleProtocol::Udp, + Protocol::Icmp => RuleProtocol::Icmp, + Protocol::Any => RuleProtocol::Any, + }; + let value = |port: u16| PortValue::Integer(i128::from(port)); + parsed.extend(lowering::port_selectors( + protocol, + port.port.map(value), + port.end_port.map(value), + &format!("{description}.ports[{index}]"), + )?); + } + selectors = Some(parsed); + } + Ok(lowering::combine(destinations, selectors)) +} diff --git a/nvx-rs/src/host/network/tests.rs b/nvx-rs/src/host/network/tests.rs new file mode 100644 index 000000000..9e9a25163 --- /dev/null +++ b/nvx-rs/src/host/network/tests.rs @@ -0,0 +1,988 @@ +//! Tests of the OpenVMM options of network postures, and of the parsers of their rules, +//! forwards, and proxies. + +use std::net::Ipv4Addr; + +use super::*; +use crate::ErrorKind; + +fn rules(arguments: &[String], option: &str) -> Vec { + arguments + .windows(2) + .filter(|pair| pair[0] == option) + .map(|pair| pair[1].clone()) + .collect() +} + +#[test] +fn default_postures_map_to_portable_profile_options() { + assert!(openvmm_arguments(None, "10.0.0.2/24").unwrap().is_empty()); + assert!( + openvmm_arguments(Some(&NetworkPolicy::new(Access::Deny)), "10.0.0.2/24") + .unwrap() + .is_empty() + ); + assert_eq!( + openvmm_arguments(Some(&NetworkPolicy::new(Access::Allow)), "10.0.0.2/24").unwrap(), + [ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "allow", + "--network-ingress", + "deny", + "--host-loopback", + "deny", + ] + ); + // Deny rules under a deny default change nothing, so no device is attached. + let denied = NetworkPolicy::new(Access::Deny).with_deny(EgressRule::to("1.2.3.4")); + assert!( + openvmm_arguments(Some(&denied), "10.0.0.2/24") + .unwrap() + .is_empty() + ); + // Ingress needs the device even when egress is denied, and host loopback stays denied. + for egress in [Access::Deny, Access::Allow] { + let mut ingress = NetworkPolicy::new(egress); + ingress.ingress = Access::Allow; + assert_eq!( + openvmm_arguments(Some(&ingress), "10.0.0.2/24").unwrap(), + [ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + egress.as_str(), + "--network-ingress", + "allow", + "--host-loopback", + "deny", + ] + ); + } + // Host-loopback access needs the device even when egress and ingress are denied. + let mut host_loopback = NetworkPolicy::new(Access::Deny); + host_loopback.host_loopback = HostLoopback::Allow; + assert_eq!( + openvmm_arguments(Some(&host_loopback), "10.0.0.2/24").unwrap(), + [ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-ingress", + "deny", + "--host-loopback", + "allow", + ] + ); + // So does outbound-only access, which OpenVMM's default provides. + host_loopback.host_loopback = HostLoopback::Outbound; + assert_eq!( + openvmm_arguments(Some(&host_loopback), "10.0.0.2/24").unwrap(), + [ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-ingress", + "deny", + ] + ); +} + +#[test] +fn host_loopback_access_needs_room_for_host_peers() { + let mut policy = NetworkPolicy::new(Access::Deny); + policy.host_loopback = HostLoopback::Allow; + // A /30 network holds only the gateway and the guest. + let error = openvmm_arguments(Some(&policy), "10.0.0.2/30").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.message().contains("/29 or shorter"), "{error}"); + for guest_network in ["10.0.0.2/29", "10.0.0.2/24"] { + let arguments = openvmm_arguments(Some(&policy), guest_network).unwrap(); + assert!( + has_pair(&arguments, "--host-loopback", "allow"), + "{guest_network}" + ); + } + // Host-loopback access that is denied, or open only to the guest, needs no host peers. + let mut denied = NetworkPolicy::new(Access::Allow); + denied.host_loopback = HostLoopback::Deny; + let arguments = openvmm_arguments(Some(&denied), "10.0.0.2/30").unwrap(); + assert!(has_pair(&arguments, "--host-loopback", "deny")); + denied.host_loopback = HostLoopback::Outbound; + let arguments = openvmm_arguments(Some(&denied), "10.0.0.2/30").unwrap(); + assert!( + !arguments + .iter() + .any(|argument| argument == "--host-loopback") + ); +} + +#[test] +fn host_loopback_forwards_follow_openvmm_rules() { + let forward = |protocol, host_port, guest_port| LoopbackForward { + protocol, + host_port, + guest_port, + }; + let forwarded = |forwards: Vec| { + let mut policy = NetworkPolicy::new(Access::Allow); + policy.host_loopback = HostLoopback::Allow; + policy.host_loopback_forwards = forwards; + policy + }; + let rejected = |policy: &NetworkPolicy, expected: &str| { + let error = openvmm_arguments(Some(policy), "10.0.0.2/24").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.message().contains(expected), "{error}"); + }; + + let valid = forwarded(vec![ + forward(ForwardProtocol::Tcp, 18080, 8080), + forward(ForwardProtocol::Udp, 18080, 8081), + // One guest port may receive a forward of each protocol. + forward(ForwardProtocol::Udp, 18081, 8080), + ]); + let arguments = openvmm_arguments(Some(&valid), "10.0.0.2/24").unwrap(); + assert_eq!( + rules(&arguments, "--host-loopback-forward"), + ["tcp:18080:8080", "udp:18080:8081", "udp:18081:8080"] + ); + assert!(has_pair(&arguments, "--host-loopback", "allow")); + + let mut denied = valid.clone(); + denied.host_loopback = HostLoopback::Deny; + rejected(&denied, "need host-loopback access allowed"); + // Forwards publish host ports to the guest, which outbound-only access does not. + let mut outbound = valid.clone(); + outbound.host_loopback = HostLoopback::Outbound; + rejected(&outbound, "need host-loopback access allowed"); + let mut closed = valid.clone(); + closed.egress = Access::Deny; + rejected( + &closed, + "host-loopback UDP forwards need egress allowed by default for their replies", + ); + // OpenVMM lets the guest's replies on a forwarded TCP connection through whatever the + // egress policy, so TCP forwards alone need neither egress allowed by default nor a guest + // network that no deny rule covers. + let mut tcp = forwarded(vec![forward(ForwardProtocol::Tcp, 18080, 8080)]); + tcp.egress = Access::Deny; + let tcp = tcp.with_deny(EgressRule::to("10.0.0.0/24")); + let arguments = openvmm_arguments(Some(&tcp), "10.0.0.2/24").unwrap(); + assert!(has_pair(&arguments, "--network-egress", "deny")); + assert_eq!(rules(&arguments, "--network-egress-deny"), ["10.0.0.0/24"]); + assert_eq!( + rules(&arguments, "--host-loopback-forward"), + ["tcp:18080:8080"] + ); + // A UDP forward beside them needs both. + let mut udp = tcp.clone(); + udp.host_loopback_forwards + .push(forward(ForwardProtocol::Udp, 18081, 8081)); + rejected( + &udp, + "host-loopback UDP forwards need egress allowed by default", + ); + udp.egress = Access::Allow; + rejected( + &udp, + "egress.deny[0] cannot cover the guest network, which carries host-loopback UDP \ + forward replies", + ); + rejected( + &forwarded(vec![ + forward(ForwardProtocol::Tcp, 18080, 8080), + forward(ForwardProtocol::Tcp, 18080, 8081), + ]), + "bind tcp host port 18080 more than once", + ); + // OpenVMM could not bind a second forward to one guest port. + rejected( + &forwarded(vec![ + forward(ForwardProtocol::Udp, 18080, 8080), + forward(ForwardProtocol::Udp, 18081, 8080), + ]), + "reach udp guest port 8080 more than once", + ); + rejected( + &forwarded(vec![forward(ForwardProtocol::Udp, 0, 8080)]), + "between 1 and 65535", + ); + rejected( + &forwarded( + (1..=65) + .map(|port| forward(ForwardProtocol::Tcp, port, port)) + .collect(), + ), + "at most 64", + ); + + // The guest's replies to forwarded traffic leave toward the guest network. + for rule in [ + EgressRule::default(), + EgressRule::to("10.0.0.0/8"), + EgressRule::to("10.0.0.0/24"), + EgressRule::to("10.0.0.77").on_port(Protocol::Tcp, 443), + // Networks are read as egress policy files read them, host bits and netmasks included. + EgressRule::to("10.0.0.77/24"), + EgressRule::to("10.0.0.0/255.255.255.0"), + ] { + rejected(&valid.clone().with_deny(rule), "forward replies"); + } + for rule in [ + EgressRule::to("10.0.1.0/24"), + EgressRule::to("fd00::/8"), + EgressRule { + to: vec![Destination { + cidr: "0.0.0.0/0".to_owned(), + except: vec!["10.0.0.0/24".to_owned()], + }], + ports: Vec::new(), + }, + ] { + openvmm_arguments(Some(&valid.clone().with_deny(rule)), "10.0.0.2/24").unwrap(); + } +} + +#[test] +fn rules_expand_exactly() { + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule { + to: vec![Destination { + cidr: "10.0.0.0/30".to_owned(), + except: vec!["10.0.0.1".to_owned()], + }], + ports: vec![PortSelector { + protocol: Protocol::Tcp, + port: Some(80), + end_port: Some(81), + }], + }) + .with_allow(EgressRule::to("192.0.2.7").on_port(Protocol::Any, 53)) + .with_allow(EgressRule::to("198.51.100.0/24")); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + assert_eq!( + rules(&arguments, "--network-egress-allow"), + [ + "10.0.0.0/32:tcp:80-81", + "10.0.0.2/31:tcp:80-81", + "192.0.2.7/32:tcp:53", + "192.0.2.7/32:udp:53", + "198.51.100.0/24", + ] + ); + assert!(has_pair(&arguments, "--network-egress", "deny")); + + let egress = NetworkPolicy::new(Access::Allow).with_deny(EgressRule { + to: Vec::new(), + ports: vec![PortSelector { + protocol: Protocol::Udp, + port: Some(53), + end_port: None, + }], + }); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + // A rule without destinations matches both address families. + assert_eq!( + rules(&arguments, "--network-egress-deny"), + ["0.0.0.0/0:udp:53", "::/0:udp:53"] + ); +} + +#[test] +fn ipv6_rules_lower_exactly() { + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule::to("2001:db8:1::/64").on_port(Protocol::Tcp, 443)) + .with_allow(EgressRule { + to: vec![Destination { + cidr: "2001:db8:2::/126".to_owned(), + except: vec!["2001:db8:2::1".to_owned()], + }], + ports: vec![PortSelector { + protocol: Protocol::Any, + port: Some(53), + end_port: None, + }], + }) + .with_allow(EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Icmp)) + .with_allow(EgressRule::to("::/0").on_protocol(Protocol::Icmp)) + .with_deny(EgressRule::to("2001:db8:1::123")); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + // Rules sort by family and then by address, so `::/0` leads the IPv6 rules. + assert_eq!( + rules(&arguments, "--network-egress-allow"), + [ + "192.0.2.0/24:icmp", + "::/0:icmp", + "2001:db8:1::/64:tcp:443", + "2001:db8:2::/128:tcp:53", + "2001:db8:2::/128:udp:53", + "2001:db8:2::2/127:tcp:53", + "2001:db8:2::2/127:udp:53", + ] + ); + assert_eq!( + rules(&arguments, "--network-egress-deny"), + ["2001:db8:1::123/128"] + ); +} + +#[test] +fn rules_lower_like_egress_policy_files() { + let lowered = |rules: Vec| { + let mut egress = NetworkPolicy::new(Access::Deny); + egress.allow.rules = rules; + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + self::rules(&arguments, "--network-egress-allow") + }; + let tcp = |cidr: &str, port, end_port| { + EgressRule::to(cidr).on_port_range(Protocol::Tcp, port, end_port) + }; + // Destinations merge into the fewest networks, and port ranges into one range. + assert_eq!( + lowered(vec![EgressRule { + to: (0..300) + .map(|index| Destination::new(format!("10.0.{}.0/24", index % 256))) + .collect(), + ports: vec![PortSelector { + protocol: Protocol::Tcp, + port: Some(1), + end_port: Some(2), + }], + }]), + ["10.0.0.0/16:tcp:1-2"] + ); + assert_eq!( + lowered(vec![ + tcp("192.0.2.7", 8000, 8004), + tcp("192.0.2.7", 8003, 8010), + tcp("192.0.2.6", 8000, 8010), + ]), + ["192.0.2.6/31:tcp:8000-8010"] + ); + // Rules for every port of a protocol, or every protocol, subsume narrower ones. + assert_eq!( + lowered(vec![ + EgressRule::to("192.0.2.7").on_port(Protocol::Tcp, 443), + EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Tcp), + EgressRule::to("198.51.100.0/24").on_port(Protocol::Udp, 53), + EgressRule::to("198.51.100.0/23"), + ]), + ["192.0.2.0/24:tcp", "198.51.100.0/23"] + ); + // Host bits are cleared, as an egress policy file's are. + assert_eq!( + lowered(vec![EgressRule::to("10.0.0.5/255.255.255.0")]), + ["10.0.0.0/24"] + ); +} + +#[test] +fn invalid_rules_are_rejected_with_the_policy_compilers_messages() { + let port = |protocol, port, end_port| EgressRule { + to: vec![Destination::new("10.0.0.0/8")], + ports: vec![PortSelector { + protocol, + port, + end_port, + }], + }; + let excluding = |cidr: &str, except: &str| EgressRule { + to: vec![ + Destination::new("192.0.2.0/24"), + Destination { + cidr: cidr.to_owned(), + except: vec![except.to_owned()], + }, + ], + ports: Vec::new(), + }; + for (rule, message) in [ + ( + port(Protocol::Icmp, Some(8), None), + "egress.allow[0].ports[0].port is not supported with icmp", + ), + // Structural validation rejects these first, unless the backend is called directly. + ( + port(Protocol::Tcp, Some(90), Some(80)), + "egress.allow[0].ports[0].endPort 80 cannot be below port 90", + ), + ( + port(Protocol::Udp, None, Some(80)), + "egress.allow[0].ports[0].endPort requires port", + ), + ( + port(Protocol::Tcp, Some(0), None), + "egress.allow[0].ports[0].port must be between 1 and 65535", + ), + ( + port(Protocol::Any, Some(80), Some(0)), + "egress.allow[0].ports[0].endPort must be between 1 and 65535", + ), + ( + EgressRule::to("example.com"), + "egress.allow[0].to[0].cidr is not a valid IPv4 or IPv6 CIDR: \"example.com\"", + ), + ( + EgressRule::to("fe80::1%eth0/64"), + "egress.allow[0].to[0].cidr must not name an IPv6 scope: \"fe80::1%eth0/64\"", + ), + ( + excluding("10.0.0.0/8", "11.0.0.0/16"), + "egress.allow[0].to[1].except[0] must be contained in 10.0.0.0/8", + ), + ( + excluding("10.0.0.7/8", "10.1.0.0/+16"), + "egress.allow[0].to[1].except[0] is not a valid IPv4 or IPv6 CIDR: \"10.1.0.0/+16\"", + ), + // 129 separate ranges of both TCP and UDP need 258 OpenVMM rules. + ( + EgressRule { + to: vec![Destination::new("10.0.0.0/8")], + ports: (0..129) + .map(|index| PortSelector { + protocol: Protocol::Any, + port: Some(3 * index + 1), + end_port: Some(3 * index + 2), + }) + .collect(), + }, + "egress.allow emits at most 256 native rules", + ), + ] { + let egress = NetworkPolicy::new(Access::Deny).with_allow(rule.clone()); + let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{rule:?}"); + assert_eq!(error.message(), message, "{rule:?}"); + } + // The allow rules are checked and lowered before the deny rules. + let egress = NetworkPolicy::new(Access::Allow) + .with_allow(EgressRule::to("10.0.0.0/33")) + .with_deny(port(Protocol::Icmp, Some(8), None)); + let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); + assert_eq!( + error.message(), + "egress.allow[0].to[0].cidr is not a valid IPv4 or IPv6 CIDR: \"10.0.0.0/33\"" + ); +} + +#[test] +fn exceptions_that_split_a_network_too_finely_are_rejected() { + // Without a limit, these exceptions would split the address space into 512 * 23 networks. + let peer = Destination { + cidr: "0.0.0.0/0".to_owned(), + except: (0..512u32) + .map(|index| std::net::Ipv4Addr::from(index << 23).to_string()) + .collect(), + }; + let rule = EgressRule { + to: vec![peer], + ports: Vec::new(), + }; + let egress = NetworkPolicy::new(Access::Deny).with_allow(rule); + let error = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert_eq!( + error.message(), + "egress.allow emits at most 256 native rules" + ); +} + +#[test] +fn exclusions_remain_local_to_their_rules() { + let excluding = |cidr: &str, excluded: &str| EgressRule { + to: vec![Destination { + cidr: cidr.to_owned(), + except: vec![excluded.to_owned()], + }], + ports: Vec::new(), + }; + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(excluding("192.0.2.0/24", "192.0.2.128/25")) + .with_allow(EgressRule::to("192.0.2.200")) + .with_deny(excluding("198.51.100.0/24", "198.51.100.128/25")); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + + assert_eq!( + rules(&arguments, "--network-egress-allow"), + ["192.0.2.0/25", "192.0.2.200/32"] + ); + assert_eq!( + rules(&arguments, "--network-egress-deny"), + ["198.51.100.0/25"] + ); +} + +fn has_pair(arguments: &[String], name: &str, value: &str) -> bool { + arguments + .windows(2) + .any(|pair| pair[0] == name && pair[1] == value) +} + +#[test] +fn protocols_without_ports_expand_to_protocol_rules() { + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Tcp)) + .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Udp)) + .with_allow(EgressRule::to("192.0.2.7").on_protocol(Protocol::Icmp)) + .with_allow(EgressRule::to("198.51.100.0/24").on_protocol(Protocol::Any)) + .with_allow(EgressRule::to("203.0.113.9").on_port(Protocol::Any, 443)) + .with_deny( + EgressRule::to("192.0.2.7") + .on_protocol(Protocol::Icmp) + .on_port(Protocol::Tcp, 22), + ); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + assert_eq!( + rules(&arguments, "--network-egress-allow"), + [ + "192.0.2.7/32:icmp", + "192.0.2.7/32:tcp", + "192.0.2.7/32:udp", + "198.51.100.0/24", + "203.0.113.9/32:tcp:443", + "203.0.113.9/32:udp:443", + ] + ); + assert_eq!( + rules(&arguments, "--network-egress-deny"), + ["192.0.2.7/32:icmp", "192.0.2.7/32:tcp:22"] + ); +} + +#[test] +fn port_ranges_expand_to_one_rule_per_network_and_protocol() { + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule::to("192.0.2.7").on_port_range(Protocol::Tcp, 8000, 8010)) + .with_allow(EgressRule::to("192.0.2.7").on_port_range(Protocol::Udp, 5000, 5010)) + .with_allow(EgressRule::to("198.51.100.0/24").on_port_range(Protocol::Any, 1, 65535)) + // A range of one port is that port's rule. + .with_allow(EgressRule::to("203.0.113.9").on_port_range(Protocol::Tcp, 443, 443)) + .with_deny(EgressRule::to("192.0.2.7").on_port(Protocol::Tcp, 8005)); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + assert_eq!( + rules(&arguments, "--network-egress-allow"), + [ + "192.0.2.7/32:tcp:8000-8010", + "192.0.2.7/32:udp:5000-5010", + "198.51.100.0/24:tcp:1-65535", + "198.51.100.0/24:udp:1-65535", + "203.0.113.9/32:tcp:443", + ] + ); + assert_eq!( + rules(&arguments, "--network-egress-deny"), + ["192.0.2.7/32:tcp:8005"] + ); +} + +/// The proxy-only policy of a proxy at port 3128 of host loopback. +fn proxy_only() -> NetworkPolicy { + NetworkPolicy::proxy_only(NetworkProxy::new(3128)) +} + +#[test] +fn proxy_only_policies_reach_the_proxy_at_the_gateway() { + for (guest_network, endpoint) in [ + ("10.0.0.2/24", "10.0.0.1:3128"), + ("172.16.40.9/16", "172.16.0.1:3128"), + // A /30 network holds the gateway and the guest, which is all that a proxy needs. + ("192.168.7.6/30", "192.168.7.5:3128"), + ] { + assert_eq!( + openvmm_arguments(Some(&proxy_only()), guest_network).unwrap(), + [ + "--net", + guest_network, + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-ingress", + "deny", + "--host-loopback", + "deny", + "--network-proxy", + endpoint, + ] + ); + } + // The scheme names only the URL. + let https = NetworkPolicy::proxy_only(NetworkProxy::new(3128).with_scheme(ProxyScheme::Https)); + assert_eq!( + openvmm_arguments(Some(&https), "10.0.0.2/24").unwrap(), + openvmm_arguments(Some(&proxy_only()), "10.0.0.2/24").unwrap() + ); + let mut built = NetworkPolicy::new(Access::Deny); + built.proxy = Some(NetworkProxy::new(3128)); + assert_eq!(built, proxy_only()); +} + +#[test] +fn proxies_refuse_every_other_way_out() { + let refused = |edit: &dyn Fn(&mut NetworkPolicy), message: &str| { + let mut policy = proxy_only(); + edit(&mut policy); + let error = openvmm_arguments(Some(&policy), "10.0.0.2/24").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert_eq!(error.message(), message); + }; + let only_way_out = "since the proxy is the guest's only way out"; + refused( + &|policy| policy.egress = Access::Allow, + &format!("a network proxy needs egress denied by default, {only_way_out}"), + ); + refused( + &|policy| policy.allow.rules.push(EgressRule::to("192.0.2.0/24")), + &format!("a network proxy takes no egress.allow rules, {only_way_out}"), + ); + refused( + &|policy| policy.deny.rules.push(EgressRule::to("192.0.2.0/24")), + &format!("a network proxy takes no egress.deny rules, {only_way_out}"), + ); + refused( + &|policy| policy.ingress = Access::Allow, + "a network proxy needs ingress denied", + ); + for host_loopback in [HostLoopback::Allow, HostLoopback::Outbound] { + refused( + &|policy| policy.host_loopback = host_loopback, + "a network proxy needs host-loopback access denied", + ); + } + refused( + &|policy| { + policy.host_loopback_forwards.push(LoopbackForward { + protocol: ForwardProtocol::Tcp, + host_port: 18080, + guest_port: 8080, + }); + }, + "a network proxy takes no host-loopback forwards", + ); + refused( + &|policy| policy.proxy = Some(NetworkProxy::new(0)), + "a network proxy needs a port between 1 and 65535", + ); + // OpenVMM's DNS service at the gateway takes the guest's TCP connections to port 53 + // before the proxy's relay does, whatever the proxy's scheme. + refused( + &|policy| policy.proxy = Some(NetworkProxy::new(53).with_scheme(ProxyScheme::Http)), + "a network proxy needs a port other than 53, where OpenVMM answers DNS at the guest's \ + gateway", + ); + // The proxy's checks come first: a proxy beside allowed forwards is refused as a proxy. + refused( + &|policy| { + policy.egress = Access::Allow; + policy.host_loopback = HostLoopback::Allow; + policy.host_loopback_forwards.push(LoopbackForward { + protocol: ForwardProtocol::Tcp, + host_port: 18080, + guest_port: 8080, + }); + }, + &format!("a network proxy needs egress denied by default, {only_way_out}"), + ); + // The guest reaches the proxy at its gateway, which an invalid guest network lacks. + for guest_network in [ + "10.0.0.1/24", + "10.0.0.0/24", + "10.0.0.255/24", + "10.0.0.2/31", + "10.0.0.2", + "fd00::2/64", + ] { + let error = openvmm_arguments(Some(&proxy_only()), guest_network).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert_eq!( + error.message(), + format!("guest network {guest_network:?} is not valid") + ); + } +} + +#[test] +fn proxy_urls_name_the_gateway_as_the_guest_sees_it() { + let gateway = Ipv4Addr::new(10, 0, 0, 1); + assert_eq!(NetworkProxy::new(3128).url(gateway), "10.0.0.1:3128"); + assert_eq!( + NetworkProxy::new(3128) + .with_scheme(ProxyScheme::Http) + .url(gateway), + "http://10.0.0.1:3128" + ); + assert_eq!( + NetworkProxy::new(8443) + .with_scheme(ProxyScheme::Https) + .url(gateway), + "https://10.0.0.1:8443" + ); +} + +#[test] +fn proxy_urls_name_a_port_of_host_loopback() { + let http = |port| NetworkProxy::new(port).with_scheme(ProxyScheme::Http); + let https = |port| NetworkProxy::new(port).with_scheme(ProxyScheme::Https); + for (url, proxy) in [ + ("http://127.0.0.1:3128", http(3128)), + ("http://localhost:3128/", http(3128)), + ("https://127.0.0.1:65535", https(65535)), + ("https://localhost:1", https(1)), + ] { + assert_eq!(NetworkProxy::parse_url(url).unwrap(), proxy, "{url}"); + } + for (url, reason) in [ + ( + "127.0.0.1:3128", + "expected http://HOST:PORT or https://HOST:PORT", + ), + ("ftp://127.0.0.1:3128", "the scheme must be http or https"), + ("HTTP://127.0.0.1:3128", "the scheme must be http or https"), + ( + "http://10.0.0.1:3128", + "the host must be 127.0.0.1 or localhost", + ), + ( + "http://[::1]:3128", + "the host must be 127.0.0.1 or localhost", + ), + ( + "http://user@localhost:3128", + "the host must be 127.0.0.1 or localhost", + ), + ("http://127.0.0.1", "the port must be explicit"), + ("http://127.0.0.1:", "the port must be between 1 and 65535"), + ("http://127.0.0.1:0", "the port must be between 1 and 65535"), + ( + "http://127.0.0.1:65536", + "the port must be between 1 and 65535", + ), + ( + "http://127.0.0.1:+3128", + "the port must be between 1 and 65535", + ), + ( + "http://127.0.0.1:3128/path", + "nothing but a / may follow the port", + ), + ( + "http://127.0.0.1:3128?query", + "nothing but a / may follow the port", + ), + ( + "http://127.0.0.1:3128//", + "nothing but a / may follow the port", + ), + ] { + let error = NetworkProxy::parse_url(url).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{url}"); + assert_eq!( + error.message(), + format!("network proxy URL {url}: {reason}") + ); + } + assert_eq!(ProxyScheme::from_name("http").unwrap(), ProxyScheme::Http); + assert_eq!(ProxyScheme::from_name("https").unwrap(), ProxyScheme::Https); + let error = ProxyScheme::from_name("ftp").unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert_eq!( + error.message(), + "network proxy scheme \"ftp\" must be http or https" + ); +} + +#[test] +#[cfg(feature = "cli")] +fn proxy_endpoints_name_the_guest_gateway() { + let gateway = Ipv4Addr::new(10, 0, 0, 1); + assert_eq!( + NetworkProxy::parse_endpoint("10.0.0.1:3128", gateway).unwrap(), + NetworkProxy::new(3128) + ); + assert_eq!( + NetworkProxy::parse_endpoint("192.168.7.5:8080", Ipv4Addr::new(192, 168, 7, 5)).unwrap(), + NetworkProxy::new(8080) + ); + for (endpoint, reason) in [ + ( + "198.51.100.1:3128", + "the address must be the guest's gateway, 10.0.0.1", + ), + ( + "10.0.0.2:3128", + "the address must be the guest's gateway, 10.0.0.1", + ), + ("10.0.0.1", "expected IPV4:TCP-PORT"), + ("proxy:3128", "expected IPV4:TCP-PORT"), + ("[::1]:3128", "expected IPV4:TCP-PORT"), + ("10.0.0.1:", "the port must be between 1 and 65535"), + ("10.0.0.1:0", "the port must be between 1 and 65535"), + ("10.0.0.1:65536", "the port must be between 1 and 65535"), + ] { + let error = NetworkProxy::parse_endpoint(endpoint, gateway).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{endpoint}"); + assert_eq!(error.message(), format!("{endpoint}: {reason}")); + } +} + +#[test] +#[cfg(feature = "cli")] +fn native_rules_parse_as_openvmm_parses_them() { + let parsed = |value: &str| EgressRule::parse_native(value).unwrap(); + for (value, rule) in [ + ("10.0.0.0/8", EgressRule::to("10.0.0.0/8")), + // Host bits are cleared, and an address is a network of one address. + ( + "10.0.0.5/8:tcp", + EgressRule::to("10.0.0.0/8").on_protocol(Protocol::Tcp), + ), + ( + "192.0.2.7:udp:53", + EgressRule::to("192.0.2.7/32").on_port(Protocol::Udp, 53), + ), + ( + "192.0.2.7:tcp:8000-8010", + EgressRule::to("192.0.2.7/32").on_port_range(Protocol::Tcp, 8000, 8010), + ), + // A range of one port is that port. + ( + "192.0.2.7:tcp:443-443", + EgressRule::to("192.0.2.7/32").on_port(Protocol::Tcp, 443), + ), + ( + "192.0.2.0/24:icmp", + EgressRule::to("192.0.2.0/24").on_protocol(Protocol::Icmp), + ), + ( + "::/0:icmp", + EgressRule::to("::/0").on_protocol(Protocol::Icmp), + ), + ( + "2001:db8::/32:tcp:443", + EgressRule::to("2001:db8::/32").on_port(Protocol::Tcp, 443), + ), + ("2001:db8::1", EgressRule::to("2001:db8::1/128")), + // Protocol names are not hexadecimal, so they never belong to an IPv6 address. + ( + "2001:db8::1:tcp:443", + EgressRule::to("2001:db8::1/128").on_port(Protocol::Tcp, 443), + ), + ] { + assert_eq!(parsed(value), rule, "{value}"); + } + let destination = "the destination must be an IPv4 or IPv6 address with an optional prefix"; + let ports = "ports must be between 1 and 65535"; + for (value, reason) in [ + ( + "10.0.0.0/8:tcp:80:90", + "expected CIDR[:PROTOCOL[:PORT[-PORT]]]", + ), + ("10.0.0.0/8:sctp", "the protocol must be tcp, udp, or icmp"), + ("10.0.0.0/8:TCP", "the protocol must be tcp, udp, or icmp"), + ("10.0.0.0/8:", "the protocol must be tcp, udp, or icmp"), + ("10.0.0.0/8:icmp:8", "icmp takes no port"), + ("10.0.0.0/8:tcp:0", ports), + ("10.0.0.0/8:tcp:65536", ports), + ("10.0.0.0/8:tcp:http", ports), + ("10.0.0.0/8:udp:0-80", ports), + ( + "10.0.0.0/8:tcp:90-80", + "the port range ends below its first port", + ), + ("10.0.0.0/8:tcp:-80", "a port range must be FIRST-LAST"), + ("10.0.0.0/8:tcp:80-", "a port range must be FIRST-LAST"), + ( + "10.0.0.0/8:tcp:80-90-100", + "a port range must be FIRST-LAST", + ), + ("10.0.0.0/33", destination), + ("10.0.0.0/255.0.0.0", destination), + ("example.com", destination), + ("example.com:tcp:443", destination), + ("fe80::1%eth0", destination), + ] { + let error = EgressRule::parse_native(value).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{value}"); + assert_eq!(error.message(), format!("{value}: {reason}")); + } +} + +#[test] +#[cfg(feature = "cli")] +fn lowered_rules_parse_back_to_the_same_rules() { + let egress = NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule { + to: vec![Destination { + cidr: "10.0.0.0/30".to_owned(), + except: vec!["10.0.0.1".to_owned()], + }], + ports: vec![PortSelector { + protocol: Protocol::Any, + port: Some(80), + end_port: Some(81), + }], + }) + .with_allow(EgressRule::to("2001:db8::/32").on_protocol(Protocol::Icmp)) + .with_allow(EgressRule::to("198.51.100.0/24")) + .with_deny(EgressRule::to("2001:db8::123").on_port(Protocol::Udp, 53)); + let arguments = openvmm_arguments(Some(&egress), "10.0.0.2/24").unwrap(); + let mut parsed = NetworkPolicy::new(Access::Deny); + for (option, rules) in [ + ("--network-egress-allow", &mut parsed.allow), + ("--network-egress-deny", &mut parsed.deny), + ] { + for rule in self::rules(&arguments, option) { + rules.rules.push(EgressRule::parse_native(&rule).unwrap()); + } + } + assert_eq!( + openvmm_arguments(Some(&parsed), "10.0.0.2/24").unwrap(), + arguments + ); +} + +#[test] +#[cfg(feature = "cli")] +fn loopback_forwards_parse_as_openvmm_parses_them() { + for (value, protocol, host_port, guest_port) in [ + ("tcp:18080:8080", ForwardProtocol::Tcp, 18080, 8080), + ("udp:1:65535", ForwardProtocol::Udp, 1, 65535), + ] { + assert_eq!( + LoopbackForward::parse(value).unwrap(), + LoopbackForward { + protocol, + host_port, + guest_port + } + ); + } + let format = "expected tcp:HOST_PORT:GUEST_PORT or udp:HOST_PORT:GUEST_PORT"; + let ports = "ports must be between 1 and 65535"; + for (value, reason) in [ + ("8080", format), + ("tcp:8080", format), + ("tcp:8080:80:90", format), + ("sctp:8080:80", "the protocol must be tcp or udp"), + ("tcp:0:80", ports), + ("tcp:8080:0", ports), + ("udp:65536:80", ports), + ("udp:8080:http", ports), + ] { + let error = LoopbackForward::parse(value).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{value}"); + assert_eq!(error.message(), format!("{value}: {reason}")); + } +} diff --git a/nvx-rs/src/host/sandbox/mount.rs b/nvx-rs/src/host/sandbox/mount.rs deleted file mode 100644 index 8554887bc..000000000 --- a/nvx-rs/src/host/sandbox/mount.rs +++ /dev/null @@ -1,1142 +0,0 @@ -//! Live virtio-fs shares of host directories and files inside the sandbox's container rootfs. -//! -//! A [`SandboxMount`] is one `--mount` request: a host directory or file at a guest target, with -//! its access mode, owner, and access-policy paths. [`plan_mounts`] plans the shares of a sandbox -//! through the planner of every NVX caller, [`filesystem::plan`], for the container root, whose -//! runtime keeps the [`RESERVED_MOUNT_TARGETS`] and the [`RESERVED_EXACT_MOUNT_TARGETS`], and the -//! sandbox records the resulting [`ExportPlan`]. -//! -//! [`boot_shares`] renders such a plan in the form that the guest's shell reads at boot, as the -//! one-shot lifecycle delivers it. OpenVMM attaches one virtio-fs device, with the tag -//! [`MOUNT_TAG`]. One directory uses it directly, and the guest mounts it at its target. Several -//! directories or files, or a file, share it as the children of an aggregate, each named by -//! [`aggregate_child_name`], which the guest mounts at [`AGGREGATE_MOUNT_TARGET`], a directory -//! that only the guest's root can enter, before it binds each child at its target, as the -//! `nvx_share=` kernel parameters tell it. The managed lifecycle exports the same plan at -//! [`GUEST_EXPORT`] instead, from which the guest agent maps it as the plan's mapping table -//! orders; see [`SandboxPlan`](super::SandboxPlan). -//! -//! A share's access-policy paths are host paths: `--mount-deny` hides a path, `--mount-allow` -//! exposes a path inside a denied path again, and `--mount-write` makes a path one of the only -//! writable parts of a read-write share. A relative path names a path in its share, and each -//! must lie in the host directory of its share. - -use std::fmt; -use std::io; -use std::path::{Component, Path, PathBuf}; - -use sha2::{Digest, Sha256}; - -use super::{has_kernel_separator, refused}; -use crate::error::{Error, Result}; -use crate::host::filesystem::{ - self, ExportPlan, ExportRequest, GUEST_EXPORT, Mapping, Owner, PathList, ReservedTargets, - Target, within, -}; -use crate::host::{paths, platform}; -use crate::protocol::share::{self, RESERVED_TARGET_CHARACTERS, Reservation}; - -pub use crate::protocol::share::{RESERVED_EXACT_MOUNT_TARGETS, RESERVED_MOUNT_TARGETS}; - -/// The tag of OpenVMM's virtio-fs device. -pub const MOUNT_TAG: &str = "microvm"; -/// The guest directory at which the guest mounts the aggregate of several shares. -pub const AGGREGATE_MOUNT_TARGET: &str = "/run/nvx/shares"; -/// The most denied, allowed, and writable paths each that OpenVMM accepts for a share. -pub const MAX_MOUNT_POLICY_PATHS: usize = 128; -/// The longest guest target, in UTF-8 bytes. -pub const MAX_MOUNT_TARGET_BYTES: usize = 4096; - -/// The bootstrap tokens that OpenVMM appends to the kernel command line for its share, with the -/// guest directory, the tag, and the mode. -const MOUNT_COMMAND_LINE_FRAGMENT: [&str; 3] = [" virtfs_dir=", " virtfs_tag=", " virtfs_mode="]; -/// The token that OpenVMM appends after those of an aggregate. -const AGGREGATE_COMMAND_LINE_SUFFIX: &str = " virtfs_aggregate=1"; - -/// The access mode of a share. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum MountAccess { - /// `ro`, the default. - ReadOnly, - /// `rw`. - ReadWrite, -} - -impl MountAccess { - /// Returns the mode's name: `ro` or `rw`. - pub const fn as_str(self) -> &'static str { - match self { - Self::ReadOnly => "ro", - Self::ReadWrite => "rw", - } - } - - /// Returns the mode that `name` names, as a recorded share gives it. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another name. - pub fn from_name(name: &str) -> Result { - setting_from_name( - "mode", - [Self::ReadOnly, Self::ReadWrite], - Self::as_str, - name, - ) - } -} - -impl fmt::Display for MountAccess { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -/// The host identity of a share's file operations. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum MountOwner { - /// `vmm`, the default: every operation runs as OpenVMM. - Vmm, - /// `caller`: each operation runs as the guest caller's identity, with guest root squashed to - /// the owner of the shared directory or file. Linux hosts only. - Caller, -} - -impl MountOwner { - /// Returns the owner's name: `vmm` or `caller`. - pub const fn as_str(self) -> &'static str { - match self { - Self::Vmm => "vmm", - Self::Caller => "caller", - } - } - - /// Returns the owner that `name` names, as a recorded share gives it. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another name. - pub fn from_name(name: &str) -> Result { - setting_from_name("owner", [Self::Vmm, Self::Caller], Self::as_str, name) - } -} - -impl fmt::Display for MountOwner { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -/// What a share binds: a host directory, or one regular host file without its directory, which -/// only the child of an aggregate can share. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum MountKind { - /// `directory`, the default. - Directory, - /// `file`. - File, -} - -impl MountKind { - /// Returns the kind's name: `directory` or `file`. - pub const fn as_str(self) -> &'static str { - match self { - Self::Directory => "directory", - Self::File => "file", - } - } - - /// Returns the kind that `name` names, as a recorded share gives it. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another name. - pub fn from_name(name: &str) -> Result { - setting_from_name("kind", [Self::Directory, Self::File], Self::as_str, name) - } -} - -impl fmt::Display for MountKind { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -/// Returns the one of `values`, the two values of a share's `setting`, whose name, as `as_str` -/// gives it, is `name`, as a recorded share gives it. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another name, with a -/// message that names the setting and its two values. -fn setting_from_name( - setting: &str, - values: [T; 2], - as_str: fn(T) -> &'static str, - name: &str, -) -> Result { - values - .into_iter() - .find(|&value| as_str(value) == name) - .ok_or_else(|| { - let [first, second] = values.map(as_str); - refused(format!( - "unsupported sandbox mount {setting} {name:?}; choose {first} or {second}", - )) - }) -} - -/// A kind of access-policy path. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum PolicyKind { - /// A path that the share hides: `--mount-deny`. - Denied, - /// A path inside a denied path that the share exposes again: `--mount-allow`. - Allowed, - /// One of the only writable paths of a read-write share: `--mount-write`. - Writable, -} - -/// The kinds of access-policy paths, in the order in which OpenVMM's options carry them. -pub const POLICY_KINDS: [PolicyKind; 3] = [ - PolicyKind::Denied, - PolicyKind::Allowed, - PolicyKind::Writable, -]; - -impl PolicyKind { - /// Returns the kind's name in messages: `denied`, `allowed`, or `writable`. - pub const fn as_str(self) -> &'static str { - match self { - Self::Denied => "denied", - Self::Allowed => "allowed", - Self::Writable => "writable", - } - } - - /// Returns the option that requests a path of this kind: `--mount-deny`, `--mount-allow`, or - /// `--mount-write`. - pub const fn option(self) -> &'static str { - match self { - Self::Denied => "--mount-deny", - Self::Allowed => "--mount-allow", - Self::Writable => "--mount-write", - } - } -} - -impl fmt::Display for PolicyKind { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -/// The access-policy paths of a share, as given: host paths, relative to the share or absolute. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct MountPolicy { - /// Paths that the share hides. - pub denied: Vec, - /// Paths inside denied paths that the share exposes again. - pub allowed: Vec, - /// The only paths of a read-write share that the workload can modify, if any. - pub writable: Vec, -} - -impl MountPolicy { - /// Returns the paths of `kind`. - pub fn paths(&self, kind: PolicyKind) -> &[String] { - match kind { - PolicyKind::Denied => &self.denied, - PolicyKind::Allowed => &self.allowed, - PolicyKind::Writable => &self.writable, - } - } - - /// Returns whether the policy has no paths. - pub fn is_empty(&self) -> bool { - POLICY_KINDS.iter().all(|&kind| self.paths(kind).is_empty()) - } -} - -/// Splits a `--mount` value, `GUEST_TARGET,HOST_PATH[,ro|rw]`, into its guest target, host path, -/// and access mode, which defaults to `ro`. -/// -/// The guest target ends at the first comma, and with another comma the mode follows the last -/// one, so a host path may contain commas when the mode is given. OpenVMM reads its `--mount` -/// and `--mount-child` values the same way. Fails with -/// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). -pub fn parse_mount_value(value: &str) -> Result<(String, String, MountAccess)> { - let Some((target, rest)) = value.split_once(',') else { - return Err(refused("--mount must be GUEST_TARGET,HOST_PATH[,ro|rw]")); - }; - let (host_path, access) = rest.rsplit_once(',').unwrap_or((rest, "ro")); - if host_path.is_empty() { - return Err(refused("--mount host path is empty")); - } - let access = match access { - "ro" => MountAccess::ReadOnly, - "rw" => MountAccess::ReadWrite, - _ => { - return Err(refused(format!( - "unsupported --mount mode {access:?}; choose ro or rw (if HOST_PATH contains commas, end \ - the value with ,ro or ,rw)", - ))); - } - }; - Ok((target.to_owned(), host_path.to_owned(), access)) -} - -/// Returns whether `target` cannot stay one kernel token or holds one of the -/// [`RESERVED_TARGET_CHARACTERS`], NUL, `\`, `=`, `,`, and `"`. -pub(crate) fn has_forbidden_character(target: &str) -> bool { - has_kernel_separator(target) - || target - .chars() - .any(|character| RESERVED_TARGET_CHARACTERS.contains(character)) -} - -/// Returns whether a guest target has an empty, `.`, or `..` component after its leading slash. -pub(crate) fn has_irregular_component(target: &str) -> bool { - target - .split('/') - .skip(1) - .any(|component| matches!(component, "" | "." | "..")) -} - -/// Checks a live share's guest target inside the sandbox container rootfs: an absolute non-root -/// path of at most [`MAX_MOUNT_TARGET_BYTES`] bytes, without empty, dot, or parent components, -/// whitespace, control characters, quotes, `\`, `=`, or `,`, and outside the -/// [`RESERVED_MOUNT_TARGETS`] trees and the -/// [`RESERVED_EXACT_MOUNT_TARGETS`]. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). -pub fn validate_mount_target(target: &str) -> Result<()> { - if !target.starts_with('/') - || target == "/" - || target.len() > MAX_MOUNT_TARGET_BYTES - || has_forbidden_character(target) - || has_irregular_component(target) - { - return Err(refused(format!( - "invalid sandbox mount target {target:?}: expected an absolute non-root path without empty, \ - dot, or parent components, whitespace, control characters, quotes, '\\', '=', or ','", - ))); - } - match share::reservation(target.as_bytes()) { - Some(Reservation::Tree(reserved)) => Err(refused(format!( - "sandbox mount target {target} overlaps the reserved {reserved} tree" - ))), - Some(Reservation::Exact) => Err(refused(format!( - "sandbox mount target {target} is reserved" - ))), - None => Ok(()), - } -} - -/// Refuses an owner that this host cannot enforce: `caller` on Windows, which has no -/// per-request POSIX identity for OpenVMM to switch to. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). -pub fn require_mount_owner_supported(owner: MountOwner) -> Result<()> { - require_mount_owner_supported_on(owner, cfg!(windows)) -} - -/// Implements [`require_mount_owner_supported`] for a Windows host or another. -pub(crate) fn require_mount_owner_supported_on(owner: MountOwner, windows: bool) -> Result<()> { - if owner == MountOwner::Caller && windows { - return Err(refused( - "--mount-owner caller requires a Linux host; Windows has no per-request POSIX \ - identity for OpenVMM to switch to", - )); - } - Ok(()) -} - -/// Returns whether two guest targets overlap: whether they are equal, or one contains the other. -pub(crate) fn targets_overlap(left: &str, right: &str) -> bool { - within(left, right) || within(right, left) -} - -/// Returns the kind of share that `host_path` requests: a file for a regular file, and -/// otherwise a directory, which [`SandboxMount::validated`] then requires. -pub fn mount_kind(host_path: &Path) -> MountKind { - if host_path.is_file() { - MountKind::File - } else { - MountKind::Directory - } -} - -/// Returns a native I/O error with its path, when available. -pub(crate) fn os_error(error: &io::Error, path: Option<&Path>) -> Error { - Error::io(match path { - Some(path) => format!("{path:?}: {error}"), - None => error.to_string(), - }) -} - -/// A live virtio-fs share mounted inside the sandbox container rootfs. -/// -/// Its denied paths hide paths in the share, its allowed paths expose paths inside denied paths -/// again, and its writable paths, when present, are the only paths of a read-write share that -/// the workload can modify. A share of a file binds one regular host file, whose mode applies to -/// the whole file, so it has none of them. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SandboxMount { - /// The guest directory or file at which the share appears. - guest_target: String, - /// The host directory or file, with its components normalized. - host_path: PathBuf, - /// The access mode. - access: MountAccess, - /// The access-policy paths. - policy: MountPolicy, - /// The identity of the share's file operations. - owner: MountOwner, - /// Whether the share binds a directory or a file. - kind: MountKind, -} - -impl SandboxMount { - /// Returns the share of `host_path` at `guest_target`, with `host_path` spelled with its - /// components alone, as [`Path::components`] reads them. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a guest target - /// that [`validate_mount_target`] refuses, a host path with a NUL character or a `..` - /// component, policy paths for a file, more than [`MAX_MOUNT_POLICY_PATHS`] policy paths of a - /// kind, empty or repeated ones, allowed paths without denied paths, and writable paths of a - /// read-only share. - pub fn new( - guest_target: &str, - host_path: &str, - access: MountAccess, - policy: MountPolicy, - owner: MountOwner, - kind: MountKind, - ) -> Result { - Self::checked(Self { - guest_target: guest_target.to_owned(), - host_path: paths::normalized(Path::new(host_path)), - access, - policy, - owner, - kind, - }) - } - - /// Parses a `--mount` value with [`parse_mount_value`] into a share of the given policy, - /// owner, and kind. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), as - /// [`parse_mount_value`] and [`new`](Self::new) do. - pub fn parse( - value: &str, - policy: MountPolicy, - owner: MountOwner, - kind: MountKind, - ) -> Result { - let (guest_target, host_path, access) = parse_mount_value(value)?; - Self::new(&guest_target, &host_path, access, policy, owner, kind) - } - - /// Checks the target, host path, kind, and access policy, in that order. - fn checked(self) -> Result { - validate_mount_target(&self.guest_target)?; - let raw_path = self.host_path.as_os_str(); - if raw_path.as_encoded_bytes().contains(&0) { - return Err(refused(format!( - "sandbox mount host path contains a NUL character: {raw_path:?}", - ))); - } - if self - .host_path - .components() - .any(|component| component == Component::ParentDir) - { - return Err(refused(format!( - "sandbox mount host path contains a parent component: {}", - self.host_path.display() - ))); - } - if self.kind == MountKind::File && !self.policy.is_empty() { - return Err(refused(format!( - "--mount-deny, --mount-allow, and --mount-write name paths inside a shared \ - directory, but --mount {} shares a file, whose mode applies to the whole file", - self.guest_target - ))); - } - for kind in POLICY_KINDS { - let paths = self.policy.paths(kind); - if paths.len() > MAX_MOUNT_POLICY_PATHS { - return Err(refused(format!( - "a sandbox mount permits at most {MAX_MOUNT_POLICY_PATHS} {kind} paths" - ))); - } - if paths - .iter() - .any(|path| path.is_empty() || path.contains('\0')) - { - return Err(refused(format!( - "sandbox mount {kind} paths must be nonempty" - ))); - } - let unique: std::collections::HashSet<&String> = paths.iter().collect(); - if unique.len() != paths.len() { - return Err(refused(format!( - "sandbox mount {kind} paths must be unique" - ))); - } - } - if !self.policy.allowed.is_empty() && self.policy.denied.is_empty() { - return Err(refused( - "--mount-allow exposes a path inside a --mount-deny path of the same share, \ - which has none", - )); - } - if !self.policy.writable.is_empty() && self.access != MountAccess::ReadWrite { - return Err(refused(format!( - "--mount-write narrows the writes of a read-write share; --mount {} is read-only", - self.guest_target - ))); - } - Ok(self) - } - - /// Returns the share with another kind, checked again, as `dataclasses.replace` does. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a file share - /// with policy paths. - pub fn with_kind(&self, kind: MountKind) -> Result { - Self::checked(Self { - kind, - ..self.clone() - }) - } - - /// Returns the guest target. - pub fn guest_target(&self) -> &str { - &self.guest_target - } - - /// Returns the host directory or file. - pub fn host_path(&self) -> &Path { - &self.host_path - } - - /// Returns the access mode. - pub fn access(&self) -> MountAccess { - self.access - } - - /// Returns the access-policy paths. - pub fn policy(&self) -> &MountPolicy { - &self.policy - } - - /// Returns the identity of the share's file operations. - pub fn owner(&self) -> MountOwner { - self.owner - } - - /// Returns whether the share binds a directory or a file. - pub fn kind(&self) -> MountKind { - self.kind - } - - /// Checks the share against the host: its owner must be one that the host supports, its - /// host path must be a regular file or a plain directory, as its kind requires, without - /// being a symbolic link, which on Windows includes a junction, as [`Path::is_symlink`] tells - /// them, and with the `caller` owner on Linux, it must not belong to UID 0 or GID 0, to which - /// guest root would be squashed. - /// - /// The kind was fixed when the share was requested, so a directory that replaced a shared - /// file is not shared in its place. Fails with - /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or - /// [`ErrorKind::Io`](crate::ErrorKind::Io) if the host path cannot be examined. - pub fn validated(&self) -> Result<()> { - require_mount_owner_supported(self.owner)?; - let host_path = self.host_path.as_path(); - let kind_matches = match self.kind { - MountKind::File => host_path.is_file(), - MountKind::Directory => host_path.is_dir(), - }; - if host_path.is_symlink() || !kind_matches { - return Err(self.kind_refusal()); - } - #[cfg(unix)] - if self.owner == MountOwner::Caller { - use std::os::unix::fs::MetadataExt; - - let metadata = - std::fs::metadata(host_path).map_err(|error| os_error(&error, Some(host_path)))?; - if metadata.uid() == 0 || metadata.gid() == 0 { - return Err(refused(format!( - "--mount-owner caller squashes guest root to the owner of the shared {}, so \ - {} must not be owned by UID 0 or GID 0", - self.kind, - host_path.display() - ))); - } - } - Ok(()) - } - - /// Returns the error for a host path that is not of the share's kind. - fn kind_refusal(&self) -> Error { - let shown = self.host_path.display(); - refused(match self.kind { - MountKind::File => format!("sandbox mount host path is not a regular file: {shown}"), - MountKind::Directory => { - format!("sandbox mount host path is not a plain directory: {shown}") - } - }) - } - - /// Returns the share with its host path made absolute, as the one function of NVX that makes - /// host paths absolute makes it: joined to the current directory if it is relative, without - /// resolving it, so OpenVMM still sees, and refuses, every symbolic-link component of the - /// path that was requested. On Windows, a path with another drive and no root, such as - /// `D:relative`, is below the current directory of its drive. - /// - /// Fails with [`ErrorKind::Io`](crate::ErrorKind::Io) if a relative host path meets an - /// unreadable current directory, or Windows cannot qualify a drive-relative path. - pub fn absolute(&self) -> Result { - self.absolute_with(&paths::current_dir) - } - - /// Implements [`absolute`](Self::absolute) with `cwd` as the current directory. - pub(crate) fn absolute_with(&self, cwd: &dyn Fn() -> io::Result) -> Result { - if self.host_path.is_absolute() { - return Ok(self.clone()); - } - let directory = cwd().map_err(|error| os_error(&error, None))?; - Self::checked(Self { - host_path: absolute_host_path(&directory, &self.host_path)?, - ..self.clone() - }) - } - - /// Returns each kind of access-policy path with its requested paths, relative paths joined - /// to the absolute host path of the share, as OpenVMM attributes them to the children of an - /// aggregate. - /// - /// Fails as [`absolute`](Self::absolute) does. - pub fn absolute_policy_paths(&self) -> Result)>> { - self.absolute_policy_paths_with(&paths::current_dir) - } - - /// Implements [`absolute_policy_paths`](Self::absolute_policy_paths) with `cwd` as the - /// current directory. - pub(crate) fn absolute_policy_paths_with( - &self, - cwd: &dyn Fn() -> io::Result, - ) -> Result)>> { - let host_path = self.absolute_with(cwd)?.host_path; - POLICY_KINDS - .iter() - .map(|&kind| { - let paths = self - .policy - .paths(kind) - .iter() - .map(|path| { - if Path::new(path).is_absolute() { - return Ok(path.clone()); - } - let joined = - paths::normalized(&absolute_host_path(&host_path, Path::new(path))?); - paths::text(&joined) - .map(str::to_owned) - .map_err(|error| os_error(&error, None)) - }) - .collect::>>()?; - Ok((kind, paths)) - }) - .collect() - } -} - -/// Returns `path` joined to `directory` without resolving links, as [`paths::absolute_in`] joins -/// them. -fn absolute_host_path(directory: &Path, path: &Path) -> Result { - paths::absolute_in(directory, path).map_err(|error| os_error(&error, Some(path))) -} - -/// Returns the bootstrap tokens of a share at `target` with `access`. -fn command_line_fragment(target: &str, access: MountAccess) -> String { - let [directory, tag, mode] = MOUNT_COMMAND_LINE_FRAGMENT; - format!("{directory}{target}{tag}{MOUNT_TAG}{mode}{access}") -} - -/// Checks the live shares that one sandbox attaches, in child order: no target may overlap -/// another, so that no share hides another, and every share has the same owner. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). -pub fn validate_mounts(mounts: &[SandboxMount]) -> Result<()> { - for (index, mount) in mounts.iter().enumerate() { - for other in &mounts[..index] { - if targets_overlap(&other.guest_target, &mount.guest_target) { - return Err(refused(format!( - "sandbox mount targets {} and {} overlap; a share cannot hide another", - other.guest_target, mount.guest_target - ))); - } - } - } - if mounts.iter().any(|mount| mount.owner != mounts[0].owner) { - return Err(refused( - "every sandbox --mount must use the same --mount-owner", - )); - } - Ok(()) -} - -/// The queries of the host that [`validate_mount_host_paths`] makes, which tests replace. -pub(crate) struct HostQueries<'a> { - /// The current directory, which [`paths::current_dir`] gives. - pub(crate) cwd: &'a dyn Fn() -> io::Result, - /// A path with its links resolved, as [`paths::resolve`] resolves them. - pub(crate) resolve: &'a dyn Fn(&Path) -> io::Result, - /// The identity of the file or directory at a path, following links, which - /// [`platform::file_identity`] gives. - pub(crate) identity: &'a dyn Fn(&Path) -> io::Result<(u64, u64)>, -} - -impl HostQueries<'_> { - /// Returns the queries of this host. - pub(crate) fn native() -> HostQueries<'static> { - HostQueries { - cwd: &paths::current_dir, - resolve: &paths::resolve, - identity: &platform::file_identity, - } - } -} - -/// Refuses shares whose host paths overlap, and policy paths outside their share. -/// -/// Two host paths overlap if one contains the other once both are resolved, or, since resolving -/// follows symbolic links but not bind mounts, if one has the file identity of the other or of -/// one of its ancestors, as a hard link or a path inside a bind mount of it does. A shared file -/// therefore overlaps a shared directory that contains it, or another share of the same file, -/// but not a file beside it. Each policy path must name a path inside the host directory of its -/// share, and only a denied path may name the share itself. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or -/// [`ErrorKind::Io`](crate::ErrorKind::Io) if a path cannot be resolved or examined. -pub fn validate_mount_host_paths(mounts: &[SandboxMount]) -> Result<()> { - validate_mount_host_paths_with(mounts, &HostQueries::native()) -} - -/// Implements [`validate_mount_host_paths`] with the given host queries. -pub(crate) fn validate_mount_host_paths_with( - mounts: &[SandboxMount], - host: &HostQueries<'_>, -) -> Result<()> { - let roots = mounts - .iter() - .map(|mount| { - (host.resolve)(&mount.host_path) - .map_err(|error| os_error(&error, Some(&mount.host_path))) - }) - .collect::>>()?; - if mounts.len() > 1 { - check_distinct_host_paths(&roots, host)?; - } - for (mount, root) in mounts.iter().zip(&roots) { - for (kind, paths) in mount.absolute_policy_paths_with(host.cwd)? { - let option = kind.option(); - for path in paths { - let resolved = (host.resolve)(Path::new(&path)) - .map_err(|error| os_error(&error, Some(Path::new(&path))))?; - // A denied root hides the share behind its allowed paths, which OpenVMM requires; - // it accepts no other policy path at a root. - if resolved.starts_with(root) && (resolved != *root || kind == PolicyKind::Denied) { - continue; - } - let several = if mounts.len() > 1 { - format!( - "; with several shares, each {option} follows the --mount whose \ - directory it names" - ) - } else { - String::new() - }; - return Err(refused(format!( - "{option} {path} is not inside the host directory of --mount {}{several}", - mount.guest_target - ))); - } - } - } - Ok(()) -} - -/// Refuses directories and files of `export` that came to overlap since it was planned: one that -/// a hard link or bind mount made the same object as another, or put inside another, so that a -/// share could reach the files of another under its access mode. [`plan_mounts`] refused such -/// host paths when it planned them, and a launch checks them again, since the host may have -/// changed meanwhile. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or -/// [`ErrorKind::Io`](crate::ErrorKind::Io) if a path cannot be examined. -pub(crate) fn check_distinct_export_roots( - export: &ExportPlan, - host: &HostQueries<'_>, -) -> Result<()> { - if export.children.len() < 2 { - return Ok(()); - } - let roots = export - .children - .iter() - .map(|child| path_text(&child.root).map(PathBuf::from)) - .collect::>>()?; - check_distinct_host_paths(&roots, host) -} - -/// Refuses resolved share roots that overlap, by path or by the file identity of a root and its -/// ancestors. -fn check_distinct_host_paths(roots: &[PathBuf], host: &HostQueries<'_>) -> Result<()> { - // The identity of each root, then of each of its ancestors. - let lineages = roots - .iter() - .map(|root| { - root.ancestors() - .map(|path| (host.identity)(path).map_err(|error| os_error(&error, Some(path)))) - .collect::>>() - }) - .collect::>>()?; - for (index, root) in roots.iter().enumerate() { - for (other_index, other) in roots[..index].iter().enumerate() { - let (shown, other_shown) = (root.display(), other.display()); - if root.starts_with(other) || other.starts_with(root) { - return Err(refused(format!( - "sandbox mount host paths {other_shown} and {shown} overlap; a share could \ - reach files of another under its access mode" - ))); - } - if lineages[other_index].contains(&lineages[index][0]) - || lineages[index].contains(&lineages[other_index][0]) - { - return Err(refused(format!( - "sandbox mount host paths {other_shown} and {shown} name the same directory \ - or file, or one is inside the other through a bind mount" - ))); - } - } - } - Ok(()) -} - -/// Returns the name of the aggregate child at `index` that the guest binds at `target`: the -/// index, a hyphen, and the first 32 hexadecimal digits of the SHA-256 digest of the target. -/// -/// A snapshot records every child's name, so it pins each target, and OpenVMM refuses a restore -/// that requests other targets before the guest runs, rather than keep the snapshot's binds. -pub fn aggregate_child_name(index: usize, target: &str) -> String { - let digest = Sha256::digest(target.as_bytes()); - let hex: String = digest[..16] - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); - format!("{index}-{hex}") -} - -/// Returns the `--mount-child` value that attaches `host_path` as the child `name`. It always -/// gives the mode, which OpenVMM reads after the last comma or before the `file` flag, so the -/// host path may contain commas. A file child carries OpenVMM's `file` flag, so OpenVMM refuses -/// a directory that replaced the file instead of exporting all of it. -pub fn aggregate_child_argument( - name: &str, - host_path: &str, - access: MountAccess, - kind: MountKind, -) -> String { - let file = if kind == MountKind::File { ",file" } else { "" }; - format!("{name},{host_path},{access}{file}") -} - -/// Returns the `--mount` value that attaches `host_path` at `target`. It always gives the mode, -/// which OpenVMM reads after the last comma, so the host path may contain commas. -pub fn mount_argument(target: &str, host_path: &str, access: MountAccess) -> String { - format!("{target},{host_path},{access}") -} - -/// Plans the export of `mounts`, the live shares of one sandbox, through the planner of every -/// NVX caller, [`filesystem::plan`], after it checks them with [`validate_mounts`], each share -/// with [`SandboxMount::validated`], and their host paths with [`validate_mount_host_paths`]. -/// -/// Each share maps its host path at its guest target, read-only or read-write, and its denied, -/// allowed, and writable paths, joined to its absolute host path when relative, become the -/// export's denied, allowed, and write paths. The owner of the shares, which [`validate_mounts`] -/// requires to be one, becomes the export's, and the container root reserves the trees of its -/// runtime. Each share keeps the kind with which it was requested: a host path that the planner -/// finds to be of the other kind is refused, as [`SandboxMount::validated`] refuses it. Returns -/// `None` without shares. -/// -/// Fails as those checks, [`SandboxMount::absolute`], and [`filesystem::plan`] do. -pub fn plan_mounts(mounts: &[SandboxMount]) -> Result> { - validate_mounts(mounts)?; - for mount in mounts { - mount.validated()?; - } - validate_mount_host_paths(mounts)?; - plan_mounts_with(mounts, &paths::current_dir) -} - -/// Plans the export of `mounts` as [`plan_mounts`] does, without checking them against the host -/// first, with `cwd` as the current directory. -pub(crate) fn plan_mounts_with( - mounts: &[SandboxMount], - cwd: &dyn Fn() -> io::Result, -) -> Result> { - let Some(first) = mounts.first() else { - return Ok(None); - }; - let mut hosts = Vec::with_capacity(mounts.len()); - let (mut denied, mut allowed, mut write) = (Vec::new(), Vec::new(), Vec::new()); - for mount in mounts { - hosts.push(mount.absolute_with(cwd)?.host_path); - for (kind, paths) in mount.absolute_policy_paths_with(cwd)? { - match kind { - PolicyKind::Denied => &mut denied, - PolicyKind::Allowed => &mut allowed, - PolicyKind::Writable => &mut write, - } - .extend(paths.into_iter().map(PathBuf::from)); - } - } - let mappings: Vec> = mounts - .iter() - .zip(&hosts) - .map(|(mount, host)| Mapping { - name: "--mount", - path: host, - target: Target::Guest(&mount.guest_target), - read_only: mount.access == MountAccess::ReadOnly, - }) - .collect(); - let list = |kind: PolicyKind, paths| PathList { - name: kind.option(), - paths, - }; - let plan = filesystem::plan(&ExportRequest { - mappings: &mappings, - denied: list(PolicyKind::Denied, &denied), - allowed: list(PolicyKind::Allowed, &allowed), - write: list(PolicyKind::Writable, &write), - owner: match first.owner { - MountOwner::Vmm => Owner::Vmm, - MountOwner::Caller => Owner::Caller, - }, - reserved: ReservedTargets::CONTAINER_ROOT, - })?; - // The planner reads each host path's kind again; a directory that replaced a shared file - // since the share was requested is refused rather than shared whole, and so is a host path - // that now leads into another share's directory, where the share would have no root of its - // own. - if let Some(plan) = &plan { - for mount in mounts { - let Some(bind) = plan - .binds - .iter() - .find(|bind| bind.target == mount.guest_target && bind.source.is_empty()) - else { - return Err(refused(format!( - "sandbox mount host path {} lies inside the host directory of another share; \ - a share could reach files of another under its access mode", - mount.host_path.display() - ))); - }; - let file = plan.children.get(bind.child).map(|child| child.file); - if file != Some(mount.kind == MountKind::File) { - return Err(mount.kind_refusal()); - } - } - } - Ok(plan) -} - -/// The form in which a launch delivers an export plan at boot: OpenVMM's arguments that attach -/// its directories and files, the kernel parameters that the plan adds for them, and the -/// bootstrap tokens that OpenVMM appends to the kernel command line. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct BootShares { - /// OpenVMM's arguments: one directory share or an aggregate with its children, in order, the - /// access-policy paths of each, absolute, and the owner unless it is `vmm`. - pub arguments: Vec, - /// The kernel parameters: as [`boot_shares`] renders them, the `nvx_share=` tokens of an - /// aggregate, one for each child, in order, and none for one directory share; for a mapping - /// table, the `nvx_maps=` token of its number of mappings. - pub tokens: Vec, - /// The bootstrap tokens that OpenVMM appends after the kernel command line. - pub fragment: String, -} - -/// Renders `plan` in the form that the guest's shell reads at boot. -/// -/// The shell binds each exported directory or file at one target, so every child of the plan -/// must be mapped exactly once, at its root, with the access with which OpenVMM exports it, at a -/// target that [`validate_mount_target`] accepts. -/// One directory then uses OpenVMM's virtio-fs device directly, and anything else shares it as -/// the children of an aggregate, named by [`aggregate_child_name`]. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a plan that the boot -/// tokens cannot express, such as one that maps a path below a share's root, rather than extend -/// what the shell reads. -pub fn boot_shares(plan: &ExportPlan) -> Result { - let mut shares = Vec::with_capacity(plan.children.len()); - for (index, child) in plan.children.iter().enumerate() { - let root = path_text(&child.root)?; - let binds: Vec<_> = plan - .binds - .iter() - .filter(|bind| bind.child == index) - .collect(); - let bind = match binds[..] { - [bind] if bind.source.is_empty() => bind, - [bind] => { - return Err(refused(format!( - "the boot tokens of a share cannot map {}, a path below the share's root {root}", - child.root.join(&bind.source).display() - ))); - } - _ => { - return Err(refused(format!( - "the boot tokens of a share map {root} at one target, not {}", - binds.len() - ))); - } - }; - validate_mount_target(&bind.target)?; - // The tokens mount a share with the access with which OpenVMM exports it, so a bind of - // another access, such as a read-only bind of a directory whose writes OpenVMM narrows to - // a path inside it, would let the guest write what the plan keeps read-only. - if bind.read_only == child.writable { - let (bound, exported) = if child.writable { - ("read-only", "read-write") - } else { - ("read-write", "read-only") - }; - return Err(refused(format!( - "the boot tokens of a share cannot map {root} {bound} while OpenVMM exports it \ - {exported}" - ))); - } - let access = if child.writable { - MountAccess::ReadWrite - } else { - MountAccess::ReadOnly - }; - shares.push((child, root, bind.target.as_str(), access)); - } - if plan.binds.len() != shares.len() { - return Err(refused( - "the boot tokens of a share cannot map a path of an unknown host directory", - )); - } - let mut boot = BootShares::default(); - match shares[..] { - [] => return Ok(boot), - [(child, root, target, access)] if !child.file => { - boot.arguments = vec!["--mount".to_owned(), mount_argument(target, root, access)]; - boot.fragment = command_line_fragment(target, access); - } - _ => { - boot.arguments = vec![ - "--mount-aggregate".to_owned(), - AGGREGATE_MOUNT_TARGET.to_owned(), - ]; - for (index, &(child, root, target, access)) in shares.iter().enumerate() { - let name = aggregate_child_name(index, target); - let (kind, file) = if child.file { - (MountKind::File, ",file") - } else { - (MountKind::Directory, "") - }; - boot.arguments.push("--mount-child".to_owned()); - boot.arguments - .push(aggregate_child_argument(&name, root, access, kind)); - boot.tokens - .push(format!("nvx_share={name},{target},{access}{file}")); - } - let accesses: Vec = - shares.iter().map(|&(_, _, _, access)| access).collect(); - boot.fragment = aggregate_command_line_fragment(&accesses); - } - } - for &(child, ..) in &shares { - for (kind, paths) in [ - (PolicyKind::Denied, &child.denied), - (PolicyKind::Allowed, &child.allowed), - (PolicyKind::Writable, &child.write), - ] { - for path in paths { - boot.arguments.push(kind.option().to_owned()); - boot.arguments.push(path_text(path)?.to_owned()); - } - } - } - if plan.owner == Owner::Caller { - boot.arguments.push("--mount-owner".to_owned()); - boot.arguments.push(MountOwner::Caller.as_str().to_owned()); - } - Ok(boot) -} - -/// Returns the text of a host path of an export plan, which OpenVMM's options carry. -/// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a path that is not -/// valid UTF-8. -fn path_text(path: &Path) -> Result<&str> { - path.to_str() - .ok_or_else(|| refused(format!("{} is not valid UTF-8", path.display()))) -} - -/// Returns the kernel command-line tokens that tell the guest where to bind each child of an -/// aggregate, given each share's guest target, access, and kind. A file child's token ends in -/// `,file`, so the guest binds it at a file. -pub fn aggregate_share_tokens(shares: &[(&str, MountAccess, MountKind)]) -> Vec { - shares - .iter() - .enumerate() - .map(|(index, &(target, access, kind))| { - let file = if kind == MountKind::File { ",file" } else { "" }; - format!( - "nvx_share={},{target},{access}{file}", - aggregate_child_name(index, target) - ) - }) - .collect() -} - -/// Returns the bootstrap tokens that OpenVMM appends for an aggregate of shares with -/// `accesses`, which it mounts read-write if any child is. -pub fn aggregate_command_line_fragment(accesses: &[MountAccess]) -> String { - aggregate_fragment(AGGREGATE_MOUNT_TARGET, accesses) -} - -/// Returns the bootstrap tokens that OpenVMM appends for the export of `plan` at -/// [`GUEST_EXPORT`], the aggregate from which a managed sandbox's guest agent maps the plan's -/// mapping table. -pub(crate) fn export_command_line_fragment(plan: &ExportPlan) -> String { - let accesses: Vec = plan - .children - .iter() - .map(|child| { - if child.writable { - MountAccess::ReadWrite - } else { - MountAccess::ReadOnly - } - }) - .collect(); - aggregate_fragment(GUEST_EXPORT, &accesses) -} - -/// Returns the bootstrap tokens that OpenVMM appends for an aggregate at `target` whose children -/// have `accesses`, which it mounts read-write if any child is. -fn aggregate_fragment(target: &str, accesses: &[MountAccess]) -> String { - let access = if accesses.contains(&MountAccess::ReadWrite) { - MountAccess::ReadWrite - } else { - MountAccess::ReadOnly - }; - command_line_fragment(target, access) + AGGREGATE_COMMAND_LINE_SUFFIX -} - -#[cfg(test)] -mod tests; diff --git a/nvx-rs/src/host/sandbox/mount/aggregate.rs b/nvx-rs/src/host/sandbox/mount/aggregate.rs new file mode 100644 index 000000000..ce52062d7 --- /dev/null +++ b/nvx-rs/src/host/sandbox/mount/aggregate.rs @@ -0,0 +1,147 @@ +//! The children of an aggregate of shares, the values of OpenVMM's options that attach shares, +//! and the planning of the export of a sandbox's shares. + +use std::io; +use std::path::PathBuf; + +use sha2::{Digest, Sha256}; + +use super::{ + MountAccess, MountKind, MountOwner, PolicyKind, SandboxMount, validate_mount_host_paths, + validate_mounts, +}; +use crate::error::Result; +use crate::host::filesystem::{ + self, ExportPlan, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, +}; +use crate::host::paths; +use crate::host::sandbox::refused; + +/// Returns the name of the aggregate child at `index` that the guest binds at `target`: the +/// index, a hyphen, and the first 32 hexadecimal digits of the SHA-256 digest of the target. +/// +/// A snapshot records every child's name, so it pins each target, and OpenVMM refuses a restore +/// that requests other targets before the guest runs, rather than keep the snapshot's binds. +pub fn aggregate_child_name(index: usize, target: &str) -> String { + let digest = Sha256::digest(target.as_bytes()); + let hex: String = digest[..16] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("{index}-{hex}") +} + +/// Returns the `--mount-child` value that attaches `host_path` as the child `name`. It always +/// gives the mode, which OpenVMM reads after the last comma or before the `file` flag, so the +/// host path may contain commas. A file child carries OpenVMM's `file` flag, so OpenVMM refuses +/// a directory that replaced the file instead of exporting all of it. +pub fn aggregate_child_argument( + name: &str, + host_path: &str, + access: MountAccess, + kind: MountKind, +) -> String { + let file = if kind == MountKind::File { ",file" } else { "" }; + format!("{name},{host_path},{access}{file}") +} + +/// Returns the `--mount` value that attaches `host_path` at `target`. It always gives the mode, +/// which OpenVMM reads after the last comma, so the host path may contain commas. +pub fn mount_argument(target: &str, host_path: &str, access: MountAccess) -> String { + format!("{target},{host_path},{access}") +} + +/// Plans the export of `mounts`, the live shares of one sandbox, through the planner of every +/// NVX caller, [`filesystem::plan`], after it checks them with [`validate_mounts`], each share +/// with [`SandboxMount::validated`], and their host paths with [`validate_mount_host_paths`]. +/// +/// Each share maps its host path at its guest target, read-only or read-write, and its denied, +/// allowed, and writable paths, joined to its absolute host path when relative, become the +/// export's denied, allowed, and write paths. The owner of the shares, which [`validate_mounts`] +/// requires to be one, becomes the export's, and the container root reserves the trees of its +/// runtime. Each share keeps the kind with which it was requested: a host path that the planner +/// finds to be of the other kind is refused, as [`SandboxMount::validated`] refuses it. Returns +/// `None` without shares. +/// +/// Fails as those checks, [`SandboxMount::absolute`], and [`filesystem::plan`] do. +pub fn plan_mounts(mounts: &[SandboxMount]) -> Result> { + validate_mounts(mounts)?; + for mount in mounts { + mount.validated()?; + } + validate_mount_host_paths(mounts)?; + plan_mounts_with(mounts, &paths::current_dir) +} + +/// Plans the export of `mounts` as [`plan_mounts`] does, without checking them against the host +/// first, with `cwd` as the current directory. +pub(crate) fn plan_mounts_with( + mounts: &[SandboxMount], + cwd: &dyn Fn() -> io::Result, +) -> Result> { + let Some(first) = mounts.first() else { + return Ok(None); + }; + let mut hosts = Vec::with_capacity(mounts.len()); + let (mut denied, mut allowed, mut write) = (Vec::new(), Vec::new(), Vec::new()); + for mount in mounts { + hosts.push(mount.absolute_with(cwd)?.host_path); + for (kind, paths) in mount.absolute_policy_paths_with(cwd)? { + match kind { + PolicyKind::Denied => &mut denied, + PolicyKind::Allowed => &mut allowed, + PolicyKind::Writable => &mut write, + } + .extend(paths.into_iter().map(PathBuf::from)); + } + } + let mappings: Vec> = mounts + .iter() + .zip(&hosts) + .map(|(mount, host)| Mapping { + name: "--mount", + path: host, + target: Target::Guest(&mount.guest_target), + read_only: mount.access == MountAccess::ReadOnly, + }) + .collect(); + let list = |kind: PolicyKind, paths| PathList { + name: kind.option(), + paths, + }; + let plan = filesystem::plan(&ExportRequest { + mappings: &mappings, + denied: list(PolicyKind::Denied, &denied), + allowed: list(PolicyKind::Allowed, &allowed), + write: list(PolicyKind::Writable, &write), + owner: match first.owner { + MountOwner::Vmm => Owner::Vmm, + MountOwner::Caller => Owner::Caller, + }, + reserved: ReservedTargets::CONTAINER_ROOT, + })?; + // The planner reads each host path's kind again; a directory that replaced a shared file + // since the share was requested is refused rather than shared whole, and so is a host path + // that now leads into another share's directory, where the share would have no root of its + // own. + if let Some(plan) = &plan { + for mount in mounts { + let Some(bind) = plan + .binds + .iter() + .find(|bind| bind.target == mount.guest_target && bind.source.is_empty()) + else { + return Err(refused(format!( + "sandbox mount host path {} lies inside the host directory of another share; \ + a share could reach files of another under its access mode", + mount.host_path.display() + ))); + }; + let file = plan.children.get(bind.child).map(|child| child.file); + if file != Some(mount.kind == MountKind::File) { + return Err(mount.kind_refusal()); + } + } + } + Ok(plan) +} diff --git a/nvx-rs/src/host/sandbox/mount/boot.rs b/nvx-rs/src/host/sandbox/mount/boot.rs new file mode 100644 index 000000000..f4ae4e5df --- /dev/null +++ b/nvx-rs/src/host/sandbox/mount/boot.rs @@ -0,0 +1,202 @@ +//! The shares of an export plan in the form that a launch delivers at boot, and the bootstrap +//! tokens that OpenVMM appends to the kernel command line for them. + +use std::path::Path; + +use super::request::command_line_fragment; +use super::{ + AGGREGATE_COMMAND_LINE_SUFFIX, AGGREGATE_MOUNT_TARGET, MountAccess, MountKind, MountOwner, + PolicyKind, aggregate_child_argument, aggregate_child_name, mount_argument, + validate_mount_target, +}; +use crate::error::Result; +use crate::host::filesystem::{ExportPlan, GUEST_EXPORT, Owner}; +use crate::host::sandbox::refused; + +/// The form in which a launch delivers an export plan at boot: OpenVMM's arguments that attach +/// its directories and files, the kernel parameters that the plan adds for them, and the +/// bootstrap tokens that OpenVMM appends to the kernel command line. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BootShares { + /// OpenVMM's arguments: one directory share or an aggregate with its children, in order, the + /// access-policy paths of each, absolute, and the owner unless it is `vmm`. + pub arguments: Vec, + /// The kernel parameters: as [`boot_shares`] renders them, the `nvx_share=` tokens of an + /// aggregate, one for each child, in order, and none for one directory share; for a mapping + /// table, the `nvx_maps=` token of its number of mappings. + pub tokens: Vec, + /// The bootstrap tokens that OpenVMM appends after the kernel command line. + pub fragment: String, +} + +/// Renders `plan` in the form that the guest's shell reads at boot. +/// +/// The shell binds each exported directory or file at one target, so every child of the plan +/// must be mapped exactly once, at its root, with the access with which OpenVMM exports it, at a +/// target that [`validate_mount_target`] accepts. +/// One directory then uses OpenVMM's virtio-fs device directly, and anything else shares it as +/// the children of an aggregate, named by [`aggregate_child_name`]. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a plan that the boot +/// tokens cannot express, such as one that maps a path below a share's root, rather than extend +/// what the shell reads. +pub fn boot_shares(plan: &ExportPlan) -> Result { + let mut shares = Vec::with_capacity(plan.children.len()); + for (index, child) in plan.children.iter().enumerate() { + let root = path_text(&child.root)?; + let binds: Vec<_> = plan + .binds + .iter() + .filter(|bind| bind.child == index) + .collect(); + let bind = match binds[..] { + [bind] if bind.source.is_empty() => bind, + [bind] => { + return Err(refused(format!( + "the boot tokens of a share cannot map {}, a path below the share's root {root}", + child.root.join(&bind.source).display() + ))); + } + _ => { + return Err(refused(format!( + "the boot tokens of a share map {root} at one target, not {}", + binds.len() + ))); + } + }; + validate_mount_target(&bind.target)?; + // The tokens mount a share with the access with which OpenVMM exports it, so a bind of + // another access, such as a read-only bind of a directory whose writes OpenVMM narrows to + // a path inside it, would let the guest write what the plan keeps read-only. + if bind.read_only == child.writable { + let (bound, exported) = if child.writable { + ("read-only", "read-write") + } else { + ("read-write", "read-only") + }; + return Err(refused(format!( + "the boot tokens of a share cannot map {root} {bound} while OpenVMM exports it \ + {exported}" + ))); + } + let access = if child.writable { + MountAccess::ReadWrite + } else { + MountAccess::ReadOnly + }; + shares.push((child, root, bind.target.as_str(), access)); + } + if plan.binds.len() != shares.len() { + return Err(refused( + "the boot tokens of a share cannot map a path of an unknown host directory", + )); + } + let mut boot = BootShares::default(); + match shares[..] { + [] => return Ok(boot), + [(child, root, target, access)] if !child.file => { + boot.arguments = vec!["--mount".to_owned(), mount_argument(target, root, access)]; + boot.fragment = command_line_fragment(target, access); + } + _ => { + boot.arguments = vec![ + "--mount-aggregate".to_owned(), + AGGREGATE_MOUNT_TARGET.to_owned(), + ]; + for (index, &(child, root, target, access)) in shares.iter().enumerate() { + let name = aggregate_child_name(index, target); + let (kind, file) = if child.file { + (MountKind::File, ",file") + } else { + (MountKind::Directory, "") + }; + boot.arguments.push("--mount-child".to_owned()); + boot.arguments + .push(aggregate_child_argument(&name, root, access, kind)); + boot.tokens + .push(format!("nvx_share={name},{target},{access}{file}")); + } + let accesses: Vec = + shares.iter().map(|&(_, _, _, access)| access).collect(); + boot.fragment = aggregate_command_line_fragment(&accesses); + } + } + for &(child, ..) in &shares { + for (kind, paths) in [ + (PolicyKind::Denied, &child.denied), + (PolicyKind::Allowed, &child.allowed), + (PolicyKind::Writable, &child.write), + ] { + for path in paths { + boot.arguments.push(kind.option().to_owned()); + boot.arguments.push(path_text(path)?.to_owned()); + } + } + } + if plan.owner == Owner::Caller { + boot.arguments.push("--mount-owner".to_owned()); + boot.arguments.push(MountOwner::Caller.as_str().to_owned()); + } + Ok(boot) +} + +/// Returns the text of a host path of an export plan, which OpenVMM's options carry. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a path that is not +/// valid UTF-8. +pub(super) fn path_text(path: &Path) -> Result<&str> { + path.to_str() + .ok_or_else(|| refused(format!("{} is not valid UTF-8", path.display()))) +} + +/// Returns the kernel command-line tokens that tell the guest where to bind each child of an +/// aggregate, given each share's guest target, access, and kind. A file child's token ends in +/// `,file`, so the guest binds it at a file. +pub fn aggregate_share_tokens(shares: &[(&str, MountAccess, MountKind)]) -> Vec { + shares + .iter() + .enumerate() + .map(|(index, &(target, access, kind))| { + let file = if kind == MountKind::File { ",file" } else { "" }; + format!( + "nvx_share={},{target},{access}{file}", + aggregate_child_name(index, target) + ) + }) + .collect() +} + +/// Returns the bootstrap tokens that OpenVMM appends for an aggregate of shares with +/// `accesses`, which it mounts read-write if any child is. +pub fn aggregate_command_line_fragment(accesses: &[MountAccess]) -> String { + aggregate_fragment(AGGREGATE_MOUNT_TARGET, accesses) +} + +/// Returns the bootstrap tokens that OpenVMM appends for the export of `plan` at +/// [`GUEST_EXPORT`], the aggregate from which a managed sandbox's guest agent maps the plan's +/// mapping table. +pub(crate) fn export_command_line_fragment(plan: &ExportPlan) -> String { + let accesses: Vec = plan + .children + .iter() + .map(|child| { + if child.writable { + MountAccess::ReadWrite + } else { + MountAccess::ReadOnly + } + }) + .collect(); + aggregate_fragment(GUEST_EXPORT, &accesses) +} + +/// Returns the bootstrap tokens that OpenVMM appends for an aggregate at `target` whose children +/// have `accesses`, which it mounts read-write if any child is. +fn aggregate_fragment(target: &str, accesses: &[MountAccess]) -> String { + let access = if accesses.contains(&MountAccess::ReadWrite) { + MountAccess::ReadWrite + } else { + MountAccess::ReadOnly + }; + command_line_fragment(target, access) + AGGREGATE_COMMAND_LINE_SUFFIX +} diff --git a/nvx-rs/src/host/sandbox/mount/host_paths.rs b/nvx-rs/src/host/sandbox/mount/host_paths.rs new file mode 100644 index 000000000..915f051e9 --- /dev/null +++ b/nvx-rs/src/host/sandbox/mount/host_paths.rs @@ -0,0 +1,152 @@ +//! The checks of the host paths of shares: shares whose host paths overlap, and policy paths +//! outside their share. + +use std::io; +use std::path::{Path, PathBuf}; + +use super::boot::path_text; +use super::{PolicyKind, SandboxMount, os_error}; +use crate::error::Result; +use crate::host::filesystem::ExportPlan; +use crate::host::sandbox::refused; +use crate::host::{paths, platform}; + +/// The queries of the host that [`validate_mount_host_paths`] makes, which tests replace. +pub(crate) struct HostQueries<'a> { + /// The current directory, which [`paths::current_dir`] gives. + pub(crate) cwd: &'a dyn Fn() -> io::Result, + /// A path with its links resolved, as [`paths::resolve`] resolves them. + pub(crate) resolve: &'a dyn Fn(&Path) -> io::Result, + /// The identity of the file or directory at a path, following links, which + /// [`platform::file_identity`] gives. + pub(crate) identity: &'a dyn Fn(&Path) -> io::Result<(u64, u64)>, +} + +impl HostQueries<'_> { + /// Returns the queries of this host. + pub(crate) fn native() -> HostQueries<'static> { + HostQueries { + cwd: &paths::current_dir, + resolve: &paths::resolve, + identity: &platform::file_identity, + } + } +} + +/// Refuses shares whose host paths overlap, and policy paths outside their share. +/// +/// Two host paths overlap if one contains the other once both are resolved, or, since resolving +/// follows symbolic links but not bind mounts, if one has the file identity of the other or of +/// one of its ancestors, as a hard link or a path inside a bind mount of it does. A shared file +/// therefore overlaps a shared directory that contains it, or another share of the same file, +/// but not a file beside it. Each policy path must name a path inside the host directory of its +/// share, and only a denied path may name the share itself. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or +/// [`ErrorKind::Io`](crate::ErrorKind::Io) if a path cannot be resolved or examined. +pub fn validate_mount_host_paths(mounts: &[SandboxMount]) -> Result<()> { + validate_mount_host_paths_with(mounts, &HostQueries::native()) +} + +/// Implements [`validate_mount_host_paths`] with the given host queries. +pub(crate) fn validate_mount_host_paths_with( + mounts: &[SandboxMount], + host: &HostQueries<'_>, +) -> Result<()> { + let roots = mounts + .iter() + .map(|mount| { + (host.resolve)(&mount.host_path) + .map_err(|error| os_error(&error, Some(&mount.host_path))) + }) + .collect::>>()?; + if mounts.len() > 1 { + check_distinct_host_paths(&roots, host)?; + } + for (mount, root) in mounts.iter().zip(&roots) { + for (kind, paths) in mount.absolute_policy_paths_with(host.cwd)? { + let option = kind.option(); + for path in paths { + let resolved = (host.resolve)(Path::new(&path)) + .map_err(|error| os_error(&error, Some(Path::new(&path))))?; + // A denied root hides the share behind its allowed paths, which OpenVMM requires; + // it accepts no other policy path at a root. + if resolved.starts_with(root) && (resolved != *root || kind == PolicyKind::Denied) { + continue; + } + let several = if mounts.len() > 1 { + format!( + "; with several shares, each {option} follows the --mount whose \ + directory it names" + ) + } else { + String::new() + }; + return Err(refused(format!( + "{option} {path} is not inside the host directory of --mount {}{several}", + mount.guest_target + ))); + } + } + } + Ok(()) +} + +/// Refuses directories and files of `export` that came to overlap since it was planned: one that +/// a hard link or bind mount made the same object as another, or put inside another, so that a +/// share could reach the files of another under its access mode. [`plan_mounts`] refused such +/// host paths when it planned them, and a launch checks them again, since the host may have +/// changed meanwhile. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or +/// [`ErrorKind::Io`](crate::ErrorKind::Io) if a path cannot be examined. +/// +/// [`plan_mounts`]: crate::host::sandbox::mount::plan_mounts +pub(crate) fn check_distinct_export_roots( + export: &ExportPlan, + host: &HostQueries<'_>, +) -> Result<()> { + if export.children.len() < 2 { + return Ok(()); + } + let roots = export + .children + .iter() + .map(|child| path_text(&child.root).map(PathBuf::from)) + .collect::>>()?; + check_distinct_host_paths(&roots, host) +} + +/// Refuses resolved share roots that overlap, by path or by the file identity of a root and its +/// ancestors. +fn check_distinct_host_paths(roots: &[PathBuf], host: &HostQueries<'_>) -> Result<()> { + // The identity of each root, then of each of its ancestors. + let lineages = roots + .iter() + .map(|root| { + root.ancestors() + .map(|path| (host.identity)(path).map_err(|error| os_error(&error, Some(path)))) + .collect::>>() + }) + .collect::>>()?; + for (index, root) in roots.iter().enumerate() { + for (other_index, other) in roots[..index].iter().enumerate() { + let (shown, other_shown) = (root.display(), other.display()); + if root.starts_with(other) || other.starts_with(root) { + return Err(refused(format!( + "sandbox mount host paths {other_shown} and {shown} overlap; a share could \ + reach files of another under its access mode" + ))); + } + if lineages[other_index].contains(&lineages[index][0]) + || lineages[index].contains(&lineages[other_index][0]) + { + return Err(refused(format!( + "sandbox mount host paths {other_shown} and {shown} name the same directory \ + or file, or one is inside the other through a bind mount" + ))); + } + } + } + Ok(()) +} diff --git a/nvx-rs/src/host/sandbox/mount/mod.rs b/nvx-rs/src/host/sandbox/mount/mod.rs new file mode 100644 index 000000000..8d04a91cc --- /dev/null +++ b/nvx-rs/src/host/sandbox/mount/mod.rs @@ -0,0 +1,272 @@ +//! Live virtio-fs shares of host directories and files inside the sandbox's container rootfs. +//! +//! A [`SandboxMount`] is one `--mount` request: a host directory or file at a guest target, with +//! its access mode, owner, and access-policy paths. [`plan_mounts`] plans the shares of a sandbox +//! through the planner of every NVX caller, [`filesystem::plan`], for the container root, whose +//! runtime keeps the [`RESERVED_MOUNT_TARGETS`] and the [`RESERVED_EXACT_MOUNT_TARGETS`], and the +//! sandbox records the resulting [`ExportPlan`]. +//! +//! [`boot_shares`] renders such a plan in the form that the guest's shell reads at boot, as the +//! one-shot lifecycle delivers it. OpenVMM attaches one virtio-fs device, with the tag +//! [`MOUNT_TAG`]. One directory uses it directly, and the guest mounts it at its target. Several +//! directories or files, or a file, share it as the children of an aggregate, each named by +//! [`aggregate_child_name`], which the guest mounts at [`AGGREGATE_MOUNT_TARGET`], a directory +//! that only the guest's root can enter, before it binds each child at its target, as the +//! `nvx_share=` kernel parameters tell it. The managed lifecycle exports the same plan at +//! [`GUEST_EXPORT`] instead, from which the guest agent maps it as the plan's mapping table +//! orders; see [`SandboxPlan`](super::SandboxPlan). +//! +//! A share's access-policy paths are host paths: `--mount-deny` hides a path, `--mount-allow` +//! exposes a path inside a denied path again, and `--mount-write` makes a path one of the only +//! writable parts of a read-write share. A relative path names a path in its share, and each +//! must lie in the host directory of its share. +//! +//! [`filesystem::plan`]: crate::host::filesystem::plan +//! [`ExportPlan`]: crate::host::filesystem::ExportPlan +//! [`GUEST_EXPORT`]: crate::host::filesystem::GUEST_EXPORT + +mod aggregate; +mod boot; +mod host_paths; +mod request; + +use std::fmt; + +use super::refused; +use crate::error::Result; + +pub use crate::protocol::share::{RESERVED_EXACT_MOUNT_TARGETS, RESERVED_MOUNT_TARGETS}; + +pub use self::aggregate::{ + aggregate_child_argument, aggregate_child_name, mount_argument, plan_mounts, +}; +pub(crate) use self::boot::export_command_line_fragment; +pub use self::boot::{ + BootShares, aggregate_command_line_fragment, aggregate_share_tokens, boot_shares, +}; +pub use self::host_paths::validate_mount_host_paths; +#[cfg(test)] +pub(crate) use self::host_paths::validate_mount_host_paths_with; +pub(crate) use self::host_paths::{HostQueries, check_distinct_export_roots}; +pub use self::request::{ + SandboxMount, mount_kind, parse_mount_value, require_mount_owner_supported, + validate_mount_target, validate_mounts, +}; +pub(crate) use self::request::{ + has_forbidden_character, has_irregular_component, os_error, targets_overlap, +}; + +/// The tag of OpenVMM's virtio-fs device. +pub const MOUNT_TAG: &str = "microvm"; +/// The guest directory at which the guest mounts the aggregate of several shares. +pub const AGGREGATE_MOUNT_TARGET: &str = "/run/nvx/shares"; +/// The most denied, allowed, and writable paths each that OpenVMM accepts for a share. +pub const MAX_MOUNT_POLICY_PATHS: usize = 128; +/// The longest guest target, in UTF-8 bytes. +pub const MAX_MOUNT_TARGET_BYTES: usize = 4096; + +/// The bootstrap tokens that OpenVMM appends to the kernel command line for its share, with the +/// guest directory, the tag, and the mode. +const MOUNT_COMMAND_LINE_FRAGMENT: [&str; 3] = [" virtfs_dir=", " virtfs_tag=", " virtfs_mode="]; +/// The token that OpenVMM appends after those of an aggregate. +const AGGREGATE_COMMAND_LINE_SUFFIX: &str = " virtfs_aggregate=1"; + +/// The access mode of a share. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MountAccess { + /// `ro`, the default. + ReadOnly, + /// `rw`. + ReadWrite, +} + +impl MountAccess { + /// Returns the mode's name: `ro` or `rw`. + pub const fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => "ro", + Self::ReadWrite => "rw", + } + } + + /// Returns the mode that `name` names, as a recorded share gives it. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another name. + pub fn from_name(name: &str) -> Result { + setting_from_name( + "mode", + [Self::ReadOnly, Self::ReadWrite], + Self::as_str, + name, + ) + } +} + +impl fmt::Display for MountAccess { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// The host identity of a share's file operations. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MountOwner { + /// `vmm`, the default: every operation runs as OpenVMM. + Vmm, + /// `caller`: each operation runs as the guest caller's identity, with guest root squashed to + /// the owner of the shared directory or file. Linux hosts only. + Caller, +} + +impl MountOwner { + /// Returns the owner's name: `vmm` or `caller`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Vmm => "vmm", + Self::Caller => "caller", + } + } + + /// Returns the owner that `name` names, as a recorded share gives it. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another name. + pub fn from_name(name: &str) -> Result { + setting_from_name("owner", [Self::Vmm, Self::Caller], Self::as_str, name) + } +} + +impl fmt::Display for MountOwner { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// What a share binds: a host directory, or one regular host file without its directory, which +/// only the child of an aggregate can share. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MountKind { + /// `directory`, the default. + Directory, + /// `file`. + File, +} + +impl MountKind { + /// Returns the kind's name: `directory` or `file`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Directory => "directory", + Self::File => "file", + } + } + + /// Returns the kind that `name` names, as a recorded share gives it. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another name. + pub fn from_name(name: &str) -> Result { + setting_from_name("kind", [Self::Directory, Self::File], Self::as_str, name) + } +} + +impl fmt::Display for MountKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Returns the one of `values`, the two values of a share's `setting`, whose name, as `as_str` +/// gives it, is `name`, as a recorded share gives it. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for another name, with a +/// message that names the setting and its two values. +fn setting_from_name( + setting: &str, + values: [T; 2], + as_str: fn(T) -> &'static str, + name: &str, +) -> Result { + values + .into_iter() + .find(|&value| as_str(value) == name) + .ok_or_else(|| { + let [first, second] = values.map(as_str); + refused(format!( + "unsupported sandbox mount {setting} {name:?}; choose {first} or {second}", + )) + }) +} + +/// A kind of access-policy path. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PolicyKind { + /// A path that the share hides: `--mount-deny`. + Denied, + /// A path inside a denied path that the share exposes again: `--mount-allow`. + Allowed, + /// One of the only writable paths of a read-write share: `--mount-write`. + Writable, +} + +/// The kinds of access-policy paths, in the order in which OpenVMM's options carry them. +pub const POLICY_KINDS: [PolicyKind; 3] = [ + PolicyKind::Denied, + PolicyKind::Allowed, + PolicyKind::Writable, +]; + +impl PolicyKind { + /// Returns the kind's name in messages: `denied`, `allowed`, or `writable`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Denied => "denied", + Self::Allowed => "allowed", + Self::Writable => "writable", + } + } + + /// Returns the option that requests a path of this kind: `--mount-deny`, `--mount-allow`, or + /// `--mount-write`. + pub const fn option(self) -> &'static str { + match self { + Self::Denied => "--mount-deny", + Self::Allowed => "--mount-allow", + Self::Writable => "--mount-write", + } + } +} + +impl fmt::Display for PolicyKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// The access-policy paths of a share, as given: host paths, relative to the share or absolute. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MountPolicy { + /// Paths that the share hides. + pub denied: Vec, + /// Paths inside denied paths that the share exposes again. + pub allowed: Vec, + /// The only paths of a read-write share that the workload can modify, if any. + pub writable: Vec, +} + +impl MountPolicy { + /// Returns the paths of `kind`. + pub fn paths(&self, kind: PolicyKind) -> &[String] { + match kind { + PolicyKind::Denied => &self.denied, + PolicyKind::Allowed => &self.allowed, + PolicyKind::Writable => &self.writable, + } + } + + /// Returns whether the policy has no paths. + pub fn is_empty(&self) -> bool { + POLICY_KINDS.iter().all(|&kind| self.paths(kind).is_empty()) + } +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/sandbox/mount/request.rs b/nvx-rs/src/host/sandbox/mount/request.rs new file mode 100644 index 000000000..e7050f6fa --- /dev/null +++ b/nvx-rs/src/host/sandbox/mount/request.rs @@ -0,0 +1,451 @@ +//! The requests of shares: the parsing and checks of `--mount` values and guest targets, and +//! [`SandboxMount`], one share with its access, owner, kind, and access-policy paths. + +use std::io; +use std::path::{Component, Path, PathBuf}; + +use super::{ + MAX_MOUNT_POLICY_PATHS, MAX_MOUNT_TARGET_BYTES, MOUNT_COMMAND_LINE_FRAGMENT, MOUNT_TAG, + MountAccess, MountKind, MountOwner, MountPolicy, POLICY_KINDS, PolicyKind, +}; +use crate::error::{Error, Result}; +use crate::host::filesystem::within; +use crate::host::paths; +use crate::host::sandbox::{has_kernel_separator, refused}; +use crate::protocol::share::{self, RESERVED_TARGET_CHARACTERS, Reservation}; + +/// Splits a `--mount` value, `GUEST_TARGET,HOST_PATH[,ro|rw]`, into its guest target, host path, +/// and access mode, which defaults to `ro`. +/// +/// The guest target ends at the first comma, and with another comma the mode follows the last +/// one, so a host path may contain commas when the mode is given. OpenVMM reads its `--mount` +/// and `--mount-child` values the same way. Fails with +/// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). +pub fn parse_mount_value(value: &str) -> Result<(String, String, MountAccess)> { + let Some((target, rest)) = value.split_once(',') else { + return Err(refused("--mount must be GUEST_TARGET,HOST_PATH[,ro|rw]")); + }; + let (host_path, access) = rest.rsplit_once(',').unwrap_or((rest, "ro")); + if host_path.is_empty() { + return Err(refused("--mount host path is empty")); + } + let access = match access { + "ro" => MountAccess::ReadOnly, + "rw" => MountAccess::ReadWrite, + _ => { + return Err(refused(format!( + "unsupported --mount mode {access:?}; choose ro or rw (if HOST_PATH contains commas, end \ + the value with ,ro or ,rw)", + ))); + } + }; + Ok((target.to_owned(), host_path.to_owned(), access)) +} + +/// Returns whether `target` cannot stay one kernel token or holds one of the +/// [`RESERVED_TARGET_CHARACTERS`], NUL, `\`, `=`, `,`, and `"`. +pub(crate) fn has_forbidden_character(target: &str) -> bool { + has_kernel_separator(target) + || target + .chars() + .any(|character| RESERVED_TARGET_CHARACTERS.contains(character)) +} + +/// Returns whether a guest target has an empty, `.`, or `..` component after its leading slash. +pub(crate) fn has_irregular_component(target: &str) -> bool { + target + .split('/') + .skip(1) + .any(|component| matches!(component, "" | "." | "..")) +} + +/// Checks a live share's guest target inside the sandbox container rootfs: an absolute non-root +/// path of at most [`MAX_MOUNT_TARGET_BYTES`] bytes, without empty, dot, or parent components, +/// whitespace, control characters, quotes, `\`, `=`, or `,`, and outside the +/// [`RESERVED_MOUNT_TARGETS`] trees and the +/// [`RESERVED_EXACT_MOUNT_TARGETS`]. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). +/// +/// [`RESERVED_MOUNT_TARGETS`]: crate::host::sandbox::mount::RESERVED_MOUNT_TARGETS +/// [`RESERVED_EXACT_MOUNT_TARGETS`]: crate::host::sandbox::mount::RESERVED_EXACT_MOUNT_TARGETS +pub fn validate_mount_target(target: &str) -> Result<()> { + if !target.starts_with('/') + || target == "/" + || target.len() > MAX_MOUNT_TARGET_BYTES + || has_forbidden_character(target) + || has_irregular_component(target) + { + return Err(refused(format!( + "invalid sandbox mount target {target:?}: expected an absolute non-root path without empty, \ + dot, or parent components, whitespace, control characters, quotes, '\\', '=', or ','", + ))); + } + match share::reservation(target.as_bytes()) { + Some(Reservation::Tree(reserved)) => Err(refused(format!( + "sandbox mount target {target} overlaps the reserved {reserved} tree" + ))), + Some(Reservation::Exact) => Err(refused(format!( + "sandbox mount target {target} is reserved" + ))), + None => Ok(()), + } +} + +/// Refuses an owner that this host cannot enforce: `caller` on Windows, which has no +/// per-request POSIX identity for OpenVMM to switch to. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). +pub fn require_mount_owner_supported(owner: MountOwner) -> Result<()> { + require_mount_owner_supported_on(owner, cfg!(windows)) +} + +/// Implements [`require_mount_owner_supported`] for a Windows host or another. +pub(crate) fn require_mount_owner_supported_on(owner: MountOwner, windows: bool) -> Result<()> { + if owner == MountOwner::Caller && windows { + return Err(refused( + "--mount-owner caller requires a Linux host; Windows has no per-request POSIX \ + identity for OpenVMM to switch to", + )); + } + Ok(()) +} + +/// Returns whether two guest targets overlap: whether they are equal, or one contains the other. +pub(crate) fn targets_overlap(left: &str, right: &str) -> bool { + within(left, right) || within(right, left) +} + +/// Returns the kind of share that `host_path` requests: a file for a regular file, and +/// otherwise a directory, which [`SandboxMount::validated`] then requires. +pub fn mount_kind(host_path: &Path) -> MountKind { + if host_path.is_file() { + MountKind::File + } else { + MountKind::Directory + } +} + +/// Returns a native I/O error with its path, when available. +pub(crate) fn os_error(error: &io::Error, path: Option<&Path>) -> Error { + Error::io(match path { + Some(path) => format!("{path:?}: {error}"), + None => error.to_string(), + }) +} + +/// A live virtio-fs share mounted inside the sandbox container rootfs. +/// +/// Its denied paths hide paths in the share, its allowed paths expose paths inside denied paths +/// again, and its writable paths, when present, are the only paths of a read-write share that +/// the workload can modify. A share of a file binds one regular host file, whose mode applies to +/// the whole file, so it has none of them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SandboxMount { + /// The guest directory or file at which the share appears. + pub(super) guest_target: String, + /// The host directory or file, with its components normalized. + pub(super) host_path: PathBuf, + /// The access mode. + pub(super) access: MountAccess, + /// The access-policy paths. + policy: MountPolicy, + /// The identity of the share's file operations. + pub(super) owner: MountOwner, + /// Whether the share binds a directory or a file. + pub(super) kind: MountKind, +} + +impl SandboxMount { + /// Returns the share of `host_path` at `guest_target`, with `host_path` spelled with its + /// components alone, as [`Path::components`] reads them. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a guest target + /// that [`validate_mount_target`] refuses, a host path with a NUL character or a `..` + /// component, policy paths for a file, more than [`MAX_MOUNT_POLICY_PATHS`] policy paths of a + /// kind, empty or repeated ones, allowed paths without denied paths, and writable paths of a + /// read-only share. + pub fn new( + guest_target: &str, + host_path: &str, + access: MountAccess, + policy: MountPolicy, + owner: MountOwner, + kind: MountKind, + ) -> Result { + Self::checked(Self { + guest_target: guest_target.to_owned(), + host_path: paths::normalized(Path::new(host_path)), + access, + policy, + owner, + kind, + }) + } + + /// Parses a `--mount` value with [`parse_mount_value`] into a share of the given policy, + /// owner, and kind. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), as + /// [`parse_mount_value`] and [`new`](Self::new) do. + pub fn parse( + value: &str, + policy: MountPolicy, + owner: MountOwner, + kind: MountKind, + ) -> Result { + let (guest_target, host_path, access) = parse_mount_value(value)?; + Self::new(&guest_target, &host_path, access, policy, owner, kind) + } + + /// Checks the target, host path, kind, and access policy, in that order. + fn checked(self) -> Result { + validate_mount_target(&self.guest_target)?; + let raw_path = self.host_path.as_os_str(); + if raw_path.as_encoded_bytes().contains(&0) { + return Err(refused(format!( + "sandbox mount host path contains a NUL character: {raw_path:?}", + ))); + } + if self + .host_path + .components() + .any(|component| component == Component::ParentDir) + { + return Err(refused(format!( + "sandbox mount host path contains a parent component: {}", + self.host_path.display() + ))); + } + if self.kind == MountKind::File && !self.policy.is_empty() { + return Err(refused(format!( + "--mount-deny, --mount-allow, and --mount-write name paths inside a shared \ + directory, but --mount {} shares a file, whose mode applies to the whole file", + self.guest_target + ))); + } + for kind in POLICY_KINDS { + let paths = self.policy.paths(kind); + if paths.len() > MAX_MOUNT_POLICY_PATHS { + return Err(refused(format!( + "a sandbox mount permits at most {MAX_MOUNT_POLICY_PATHS} {kind} paths" + ))); + } + if paths + .iter() + .any(|path| path.is_empty() || path.contains('\0')) + { + return Err(refused(format!( + "sandbox mount {kind} paths must be nonempty" + ))); + } + let unique: std::collections::HashSet<&String> = paths.iter().collect(); + if unique.len() != paths.len() { + return Err(refused(format!( + "sandbox mount {kind} paths must be unique" + ))); + } + } + if !self.policy.allowed.is_empty() && self.policy.denied.is_empty() { + return Err(refused( + "--mount-allow exposes a path inside a --mount-deny path of the same share, \ + which has none", + )); + } + if !self.policy.writable.is_empty() && self.access != MountAccess::ReadWrite { + return Err(refused(format!( + "--mount-write narrows the writes of a read-write share; --mount {} is read-only", + self.guest_target + ))); + } + Ok(self) + } + + /// Returns the share with another kind, checked again, as `dataclasses.replace` does. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a file share + /// with policy paths. + pub fn with_kind(&self, kind: MountKind) -> Result { + Self::checked(Self { + kind, + ..self.clone() + }) + } + + /// Returns the guest target. + pub fn guest_target(&self) -> &str { + &self.guest_target + } + + /// Returns the host directory or file. + pub fn host_path(&self) -> &Path { + &self.host_path + } + + /// Returns the access mode. + pub fn access(&self) -> MountAccess { + self.access + } + + /// Returns the access-policy paths. + pub fn policy(&self) -> &MountPolicy { + &self.policy + } + + /// Returns the identity of the share's file operations. + pub fn owner(&self) -> MountOwner { + self.owner + } + + /// Returns whether the share binds a directory or a file. + pub fn kind(&self) -> MountKind { + self.kind + } + + /// Checks the share against the host: its owner must be one that the host supports, its + /// host path must be a regular file or a plain directory, as its kind requires, without + /// being a symbolic link, which on Windows includes a junction, as [`Path::is_symlink`] tells + /// them, and with the `caller` owner on Linux, it must not belong to UID 0 or GID 0, to which + /// guest root would be squashed. + /// + /// The kind was fixed when the share was requested, so a directory that replaced a shared + /// file is not shared in its place. Fails with + /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), or + /// [`ErrorKind::Io`](crate::ErrorKind::Io) if the host path cannot be examined. + pub fn validated(&self) -> Result<()> { + require_mount_owner_supported(self.owner)?; + let host_path = self.host_path.as_path(); + let kind_matches = match self.kind { + MountKind::File => host_path.is_file(), + MountKind::Directory => host_path.is_dir(), + }; + if host_path.is_symlink() || !kind_matches { + return Err(self.kind_refusal()); + } + #[cfg(unix)] + if self.owner == MountOwner::Caller { + use std::os::unix::fs::MetadataExt; + + let metadata = + std::fs::metadata(host_path).map_err(|error| os_error(&error, Some(host_path)))?; + if metadata.uid() == 0 || metadata.gid() == 0 { + return Err(refused(format!( + "--mount-owner caller squashes guest root to the owner of the shared {}, so \ + {} must not be owned by UID 0 or GID 0", + self.kind, + host_path.display() + ))); + } + } + Ok(()) + } + + /// Returns the error for a host path that is not of the share's kind. + pub(super) fn kind_refusal(&self) -> Error { + let shown = self.host_path.display(); + refused(match self.kind { + MountKind::File => format!("sandbox mount host path is not a regular file: {shown}"), + MountKind::Directory => { + format!("sandbox mount host path is not a plain directory: {shown}") + } + }) + } + + /// Returns the share with its host path made absolute, as the one function of NVX that makes + /// host paths absolute makes it: joined to the current directory if it is relative, without + /// resolving it, so OpenVMM still sees, and refuses, every symbolic-link component of the + /// path that was requested. On Windows, a path with another drive and no root, such as + /// `D:relative`, is below the current directory of its drive. + /// + /// Fails with [`ErrorKind::Io`](crate::ErrorKind::Io) if a relative host path meets an + /// unreadable current directory, or Windows cannot qualify a drive-relative path. + pub fn absolute(&self) -> Result { + self.absolute_with(&paths::current_dir) + } + + /// Implements [`absolute`](Self::absolute) with `cwd` as the current directory. + pub(crate) fn absolute_with(&self, cwd: &dyn Fn() -> io::Result) -> Result { + if self.host_path.is_absolute() { + return Ok(self.clone()); + } + let directory = cwd().map_err(|error| os_error(&error, None))?; + Self::checked(Self { + host_path: absolute_host_path(&directory, &self.host_path)?, + ..self.clone() + }) + } + + /// Returns each kind of access-policy path with its requested paths, relative paths joined + /// to the absolute host path of the share, as OpenVMM attributes them to the children of an + /// aggregate. + /// + /// Fails as [`absolute`](Self::absolute) does. + pub fn absolute_policy_paths(&self) -> Result)>> { + self.absolute_policy_paths_with(&paths::current_dir) + } + + /// Implements [`absolute_policy_paths`](Self::absolute_policy_paths) with `cwd` as the + /// current directory. + pub(crate) fn absolute_policy_paths_with( + &self, + cwd: &dyn Fn() -> io::Result, + ) -> Result)>> { + let host_path = self.absolute_with(cwd)?.host_path; + POLICY_KINDS + .iter() + .map(|&kind| { + let paths = self + .policy + .paths(kind) + .iter() + .map(|path| { + if Path::new(path).is_absolute() { + return Ok(path.clone()); + } + let joined = + paths::normalized(&absolute_host_path(&host_path, Path::new(path))?); + paths::text(&joined) + .map(str::to_owned) + .map_err(|error| os_error(&error, None)) + }) + .collect::>>()?; + Ok((kind, paths)) + }) + .collect() + } +} + +/// Returns `path` joined to `directory` without resolving links, as [`paths::absolute_in`] joins +/// them. +fn absolute_host_path(directory: &Path, path: &Path) -> Result { + paths::absolute_in(directory, path).map_err(|error| os_error(&error, Some(path))) +} + +/// Returns the bootstrap tokens of a share at `target` with `access`. +pub(super) fn command_line_fragment(target: &str, access: MountAccess) -> String { + let [directory, tag, mode] = MOUNT_COMMAND_LINE_FRAGMENT; + format!("{directory}{target}{tag}{MOUNT_TAG}{mode}{access}") +} + +/// Checks the live shares that one sandbox attaches, in child order: no target may overlap +/// another, so that no share hides another, and every share has the same owner. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). +pub fn validate_mounts(mounts: &[SandboxMount]) -> Result<()> { + for (index, mount) in mounts.iter().enumerate() { + for other in &mounts[..index] { + if targets_overlap(&other.guest_target, &mount.guest_target) { + return Err(refused(format!( + "sandbox mount targets {} and {} overlap; a share cannot hide another", + other.guest_target, mount.guest_target + ))); + } + } + } + if mounts.iter().any(|mount| mount.owner != mounts[0].owner) { + return Err(refused( + "every sandbox --mount must use the same --mount-owner", + )); + } + Ok(()) +} diff --git a/nvx-rs/src/host/sandbox/mount/tests.rs b/nvx-rs/src/host/sandbox/mount/tests.rs index c376b64a6..73072be70 100644 --- a/nvx-rs/src/host/sandbox/mount/tests.rs +++ b/nvx-rs/src/host/sandbox/mount/tests.rs @@ -1,10 +1,14 @@ //! The share tests of `scripts/test_nvx_tools.py`, ported. use std::fs; +use std::path::{Path, PathBuf}; +use super::aggregate::plan_mounts_with; +use super::request::require_mount_owner_supported_on; use super::*; use crate::ErrorKind; -use crate::host::filesystem::Bind; +use crate::host::filesystem::{Bind, Owner, ReservedTargets}; +use crate::host::paths; use crate::host::sandbox::tests::{deny, exported, host, host_text, native, policy}; fn parse(value: &str, policy: MountPolicy) -> Result {