diff --git a/doc/project-structure.md b/doc/project-structure.md index f22c3914f..74c787d73 100644 --- a/doc/project-structure.md +++ b/doc/project-structure.md @@ -104,7 +104,7 @@ nvx/ | | |-- net/ CIDR parsing and subtraction, and hostname and guest-network forms | | |-- host/ Artifacts, launch, network and egress-policy lowering, sessions, VM instances, sandbox stores, the sandbox launch profile, and the `nvx-rs` CLI (feature `cli`) | | |-- testing/ Fake OpenVMM (feature `testing`) -| | |-- guest/ Programs that run in Linux guests, the guest's NVXS connection, and the libraries through which any guest init or agent starts a program, collects its children's statuses, and settles its executions' cgroups (feature `guest`) +| | |-- guest/ Programs that run in Linux guests, the guest's NVXS connection, and the libraries through which any guest init or agent starts a program, collects its children's statuses, settles its executions' cgroups, and bind-mounts host mappings below the roots that it names (feature `guest`) | | `-- bin/ nvx-rs CLI, nvx-fake-openvmm test double, and the guest binaries | |-- tests/ Fake-OpenVMM instance lifecycle, sandbox store, CLI, NVXS session, guest-binary, and hypervisor managed-lifecycle tests, the store's record fixtures, and the golden fixtures that Python recorded | |-- build.rs Stages the bundled artifacts (feature `bundled`) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index b5c5945a6..c4e258910 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -37,8 +37,8 @@ nvx-rs/ │ ├── testing/ feature `testing`: the fake OpenVMM │ ├── guest/ feature `guest`: the programs that run in Linux guests, the guest's NVXS │ │ connection, and the libraries through which a guest init or agent starts -│ │ a program, collects its children's statuses, and settles its executions' -│ │ cgroups +│ │ a program, collects its children's statuses, settles its executions' +│ │ cgroups, and bind-mounts host mappings below the roots that it names │ └── bin/ nvx-rs, nvx-fake-openvmm, and the guest binaries ├── tests/ fake-OpenVMM, CLI, NVXS session, process-supervision, store, and guest-binary │ tests, the store's record fixtures, and the golden fixtures that Python @@ -68,6 +68,7 @@ nvx-rs/ | `nvx::testing::fake_openvmm` | A fake OpenVMM that serves the control console and runs scripted workloads without a VM, for direct launches and for the managed and one-shot sandboxes of `nvx-rs sandbox` and `nvx.py sandbox` | | `nvx::host::cli` | Feature `cli`: the `nvx-rs` executable; see [The nvx-rs CLI](#the-nvx-rs-cli) | | `nvx::guest` | The programs that run in Linux guests; see [Guest binaries](#guest-binaries) | +| `nvx::guest::mappings` | `Mapping`: one host mapping for any guest init or agent, a relative source below an export root, where the host's shares appear, and a target below a target root, the tree that the mappings go into, with its `Access`. `Mapping::apply` takes both roots explicitly: it creates the target as the kind of its source, a directory or a regular file, bind-mounts the source on it, and remounts the bind `nosuid` and `nodev`, and read-only when asked. A `MappingFailure` tells which `MappingStage` failed and its `errno`. The caller keeps the order of its mappings and what a failure means for the rest | | `nvx::guest::nvxs` | `GuestConnection`: the guest's role of the NVXS session over a byte stream, such as the control console, on which NVX's managed agent and other guest agents run their sessions. It never reads past the record in progress, so a caller that polls the stream between records sees every record that it has not returned | | `nvx::guest::spawn` | `Command`: starts a program in a new process for any guest init or agent, with its arguments, environment, root and working directories, standard streams, inherited descriptors, cgroup, and process group, without NVX's control protocol, managed agent, or workload identity. Its `LaunchReport` tells whether the program started, or which `LaunchStage` failed and why. NVX's managed agent is one caller | | `nvx::guest::process` | `Child`: the status of one child that the caller names, by its pidfd or its process ID. It waits for that child alone, so another child's status stays for the caller; a caller that collects every status itself, such as a PID 1 that adopts orphans, hands each one to its `Child` with `observe`. Statuses are `std::process::ExitStatus`, real-time signals included | @@ -494,10 +495,13 @@ The `guest` feature adds the programs that run in NVX's Linux guests: the `nvx::guest` module and one binary per program. The Alpine, Ubuntu, and Azure Linux images ship all nine at their established `/sbin/nvx-*` paths. The module's libraries are not programs: `spawn` starts a program in a new -process, `process` collects the status of a child that the caller names, and -`cgroup` places, kills, and settles the processes of an execution. +process, `process` collects the status of a child that the caller names, +`cgroup` places, kills, and settles the processes of an execution, and +`mappings` bind-mounts host mappings below the roots that its caller names. `nvx-managed-agent` launches, collects, and settles its workloads through them, -and any other guest init or agent can too. +and applies its host mapping table through `mappings`, with its mount of the +host's export and `/` as the roots, and any other guest init or agent can use +them too. | Binary | Program | | --- | --- | diff --git a/nvx-rs/src/guest/managed_agent/mappings.rs b/nvx-rs/src/guest/managed_agent/mappings.rs index 461269c75..9aa275278 100644 --- a/nvx-rs/src/guest/managed_agent/mappings.rs +++ b/nvx-rs/src/guest/managed_agent/mappings.rs @@ -1,9 +1,11 @@ +use std::ffi::{CStr, OsString}; use std::io::Read; -use std::os::unix::ffi::OsStrExt; +use std::os::unix::ffi::OsStringExt; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; -use crate::guest::sys::{self, Errno, Mode, MountFlags, OpenFlags, c_string}; +use crate::guest::mappings::{Access, InvalidMapping, Mapping}; +use crate::guest::sys::{self, Errno, Mode, MountFlags}; use crate::protocol::{self, GuestHostMap}; use super::seam::{System, path_c_string}; @@ -59,6 +61,11 @@ pub(crate) fn prepare(system: &mut impl System, mappings: &mut HostMappings) -> } /// Validates and applies one ordered batch of host mappings. +/// +/// The agent keeps the table's bookkeeping: the count that the kernel command line announced, +/// the order of the batches, and the latch after a failure. [`Mapping`] applies each entry, with +/// the host export's mount, `hostfs_dir/root`, as the export root and the agent's own root, `/`, +/// as the target root. pub(crate) fn apply_request( system: &mut impl System, mappings: &mut HostMappings, @@ -77,12 +84,23 @@ pub(crate) fn apply_request( { return Err(MappingError::Invalid); } - for (offset, mapping) in request.maps.iter().enumerate() { - if let Err(error) = apply(system, mapping) { + let first = request.first; + let batch = request + .maps + .into_iter() + .map(mapping_of) + .collect::, _>>() + .map_err(|_| MappingError::Invalid)?; + let export_root = system.paths().hostfs_dir.join("root"); + for (offset, mapping) in batch.iter().enumerate() { + let mount = |source: Option<&CStr>, target: &CStr, flags: MountFlags| { + system.mount(source, target, None, flags) + }; + if let Err(failure) = mapping.apply_with(&export_root, Path::new("/"), mount) { mappings.failed = true; return Err(MappingError::Mount { - status: error as i32, - entry: request.first + offset as u32, + status: failure.raw_os_error(), + entry: first + offset as u32, }); } mappings.applied += 1; @@ -90,76 +108,20 @@ pub(crate) fn apply_request( Ok(()) } -/// Creates one target and bind-mounts a validated mapping onto it. -fn apply(system: &mut impl System, mapping: &GuestHostMap) -> Result<(), Errno> { - let root = system.paths().hostfs_dir.join("root"); - let source = append_bytes(&root, &mapping.source); - let target = PathBuf::from(std::ffi::OsStr::from_bytes(&mapping.target)); - let metadata = std::fs::symlink_metadata(&source).map_err(|error| sys::errno_of(&error))?; - if metadata.file_type().is_dir() { - make_directories(&target, true)?; - } else if metadata.file_type().is_file() { - make_directories(&target, false)?; - match std::fs::symlink_metadata(&target) { - Ok(existing) if existing.file_type().is_file() => {} - Ok(_) => return Err(Errno::EINVAL), - Err(error) => match sys::errno_of(&error) { - Errno::ENOENT => create_file(&target)?, - error => return Err(error), - }, - } - } else { - return Err(Errno::EINVAL); - } - let source = path_c_string(&source)?; - let target = path_c_string(&target)?; - system.mount(Some(&source), &target, None, MountFlags::MS_BIND)?; - let mut flags = - MountFlags::MS_BIND | MountFlags::MS_REMOUNT | MountFlags::MS_NOSUID | MountFlags::MS_NODEV; - if mapping.read_only { - flags |= MountFlags::MS_RDONLY; - } - system.mount(None, &target, None, flags) -} - -/// Appends arbitrary Unix bytes to a trusted root path. -fn append_bytes(root: &Path, suffix: &[u8]) -> PathBuf { - let mut bytes = root.as_os_str().as_bytes().to_vec(); - bytes.push(b'/'); - bytes.extend_from_slice(suffix); - PathBuf::from(std::ffi::OsStr::from_bytes(&bytes)) -} - -/// Creates every missing directory component in `path`. -fn make_directories(path: &Path, include_last: bool) -> Result<(), Errno> { - let bytes = path.as_os_str().as_bytes(); - for index in 1..=bytes.len() { - if bytes.get(index) == Some(&b'/') || (index == bytes.len() && include_last) { - let directory = c_string(&bytes[..index])?; - match sys::create_directory(&directory, Mode::from_bits_retain(0o755)) { - Ok(()) | Err(Errno::EEXIST) => {} - Err(error) => return Err(error), - } - } - } - Ok(()) -} - -/// Atomically creates one regular mapping target without following a symlink. +/// Returns the library's mapping of one decoded entry, which the decoder's path rules let +/// through unchanged. /// -/// As in the C agent, the new file closes at once, and a close failure is ignored. -fn create_file(path: &Path) -> Result<(), Errno> { - let path = path_c_string(path)?; - sys::open( - &path, - OpenFlags::O_RDONLY - | OpenFlags::O_CREAT - | OpenFlags::O_EXCL - | OpenFlags::O_CLOEXEC - | OpenFlags::O_NOFOLLOW, - Mode::from_bits_retain(0o644), - ) - .map(|_created| ()) +/// The paths are the decoder's buffers, which it bounds: it refuses a payload of more than +/// `MAX_HOST_MAPPINGS` entries before it allocates them, and a source or target of more than +/// `MAX_MAP_PATH_BYTES` bytes. +fn mapping_of(entry: GuestHostMap) -> Result { + let path = |bytes: Vec| PathBuf::from(OsString::from_vec(bytes)); + let access = if entry.read_only { + Access::ReadOnly + } else { + Access::ReadWrite + }; + Mapping::new(path(entry.source), path(entry.target), access) } #[cfg(test)] @@ -330,4 +292,105 @@ mod tests { )); assert_eq!(mappings.applied, 0); } + + #[test] + fn a_failure_in_the_middle_of_a_batch_keeps_earlier_mappings_and_latches() { + // The second entry fails at its bind, the third mount call, or at its missing source. + for (fail_mount_call, source, mounted) in [ + (Some(3), b"0/notes.txt".as_slice(), 3), + (None, b"0/missing".as_slice(), 2), + ] { + let (temporary, mut system, mut mappings) = setup(3); + system.fail_mount_call = fail_mount_call; + let guest = temporary.path().join("guest"); + let [first, second, third] = ["first", "second", "third"].map(|name| guest.join(name)); + let request = payload( + 0, + &[ + ( + b"0/src", + first.as_os_str().as_bytes(), + protocol::MAP_READ_ONLY, + ), + (source, second.as_os_str().as_bytes(), 0), + (b"1", third.as_os_str().as_bytes(), 0), + ], + None, + ); + assert!(matches!( + apply_request(&mut system, &mut mappings, &request), + Err(MappingError::Mount { status, entry: 1 }) if status == Errno::ENOENT as i32 + )); + assert_eq!((mappings.applied, mappings.failed), (1, true)); + // The first mapping stays mounted read-only, and the third is never attempted. + assert_eq!(system.mounts.len(), mounted); + assert_eq!( + system.mounts[1], + ( + None, + first.as_os_str().as_bytes().to_vec(), + MountFlags::MS_BIND + | MountFlags::MS_REMOUNT + | MountFlags::MS_NOSUID + | MountFlags::MS_NODEV + | MountFlags::MS_RDONLY + ) + ); + assert!(first.is_dir()); + // The latch refuses the rest of the table, wherever it continues. + for continued in [1, 2] { + let rest = payload(continued, &[(b"1", third.as_os_str().as_bytes(), 0)], None); + assert!(matches!( + apply_request(&mut system, &mut mappings, &rest), + Err(MappingError::Invalid) + )); + } + assert_eq!(system.mounts.len(), mounted); + assert_eq!(mappings.applied, 1); + assert!(!third.exists()); + } + } + + #[test] + fn every_entry_that_the_decoder_accepts_becomes_a_library_mapping() { + assert_eq!( + crate::guest::mappings::MAX_PATH_BYTES, + protocol::MAX_MAP_PATH_BYTES + ); + let longest = vec![b'a'; protocol::MAX_MAP_PATH_BYTES]; + let target = [b"/".as_slice(), &longest[1..]].concat(); + let request = protocol::decode_guest_maps_payload(&payload( + 0, + &[ + ( + longest.as_slice(), + target.as_slice(), + protocol::MAP_READ_ONLY, + ), + (b"0/\xff", b"/\xfe", 0), + ], + None, + )) + .unwrap(); + let converted = request + .maps + .into_iter() + .map(mapping_of) + .collect::, _>>() + .unwrap(); + let parts = |mapping: &Mapping| { + ( + mapping.source().as_os_str().as_bytes().to_vec(), + mapping.target().as_os_str().as_bytes().to_vec(), + mapping.access(), + ) + }; + assert_eq!( + converted.iter().map(parts).collect::>(), + [ + (longest, target[1..].to_vec(), Access::ReadOnly), + (b"0/\xff".to_vec(), b"\xfe".to_vec(), Access::ReadWrite), + ] + ); + } } diff --git a/nvx-rs/src/guest/mappings.rs b/nvx-rs/src/guest/mappings.rs new file mode 100644 index 000000000..a0378fe7b --- /dev/null +++ b/nvx-rs/src/guest/mappings.rs @@ -0,0 +1,671 @@ +//! Bind mounts of host mappings, below roots that a guest init or agent names. +//! +//! A [`Mapping`] names a source below an export root, the directory where the host's shares +//! appear in the guest; a target below a target root, the tree that the mappings go into; and the +//! target's [`Access`]. [`Mapping::apply`] takes both roots explicitly. It gives the target the +//! kind of its source, a directory or a regular file, bind-mounts the source on it, and remounts +//! the bind with `nosuid` and `nodev`, and with `ro` for [`Access::ReadOnly`]. +//! +//! The roots are absolute paths that the caller chooses. Each path of a mapping stays below its +//! root: [`Mapping::new`] takes a relative source and a target that is absolute as seen from the +//! target root, and refuses empty, `.`, and `..` components. A mapping is applied by path: the +//! kernel follows symbolic links on the way to the source and the target, as the bind mount does, +//! except that the call reads the kind of the source, and creates or checks a file target, without +//! following a final symbolic link. A caller therefore applies mappings only to trees that no +//! untrusted program can change, such as a root that it is still assembling. +//! +//! The caller orders its mappings and decides what a failure means for the rest. A failed mapping +//! leaves the mappings before it mounted, and what its own earlier stages did: the directories and +//! the file that it created, and, when the remount failed, the bind without its flags. +//! [`MappingFailure::stage`] tells which stage failed. NVX's managed agent is one caller: it +//! applies the host mapping table that its control session receives, in order, with its mount of +//! the host's export as the export root and `/` as the target root. +//! +//! ```no_run +//! use std::path::Path; +//! +//! use nvx::guest::mappings::{Access, Mapping}; +//! +//! # fn main() -> Result<(), Box> { +//! let mapping = Mapping::new("0/workspace", "/workspace", Access::ReadWrite)?; +//! // Mounts /run/export/0/workspace on /newroot/workspace. +//! mapping.apply(Path::new("/run/export"), Path::new("/newroot"))?; +//! # Ok(()) +//! # } +//! ``` + +use std::ffi::{CStr, CString, OsStr}; +use std::fmt; +use std::io; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; + +use super::sys::{self, Errno, Mode, MountFlags, OpenFlags}; + +/// The longest source or target of a [`Mapping`], in bytes: Linux's `PATH_MAX` without its +/// terminating NUL. +pub const MAX_PATH_BYTES: usize = sys::PATH_MAX - 1; + +/// The mode of each directory that [`Mapping::apply`] creates, before the umask: `rwxr-xr-x`. +const DIRECTORY_MODE: Mode = Mode::S_IRWXU + .union(Mode::S_IRGRP) + .union(Mode::S_IXGRP) + .union(Mode::S_IROTH) + .union(Mode::S_IXOTH); +/// The mode of each file target that [`Mapping::apply`] creates, before the umask: `rw-r--r--`. +const FILE_MODE: Mode = Mode::S_IRUSR + .union(Mode::S_IWUSR) + .union(Mode::S_IRGRP) + .union(Mode::S_IROTH); + +/// Whether programs can write a mapping's target. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Access { + /// The target is read-only. + ReadOnly, + /// The target is writable, as far as the source allows. + ReadWrite, +} + +/// The path that [`Mapping::new`] refuses, because it does not name a path below its root. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum InvalidMapping { + /// The source is not a relative path of at most [`MAX_PATH_BYTES`] bytes without NUL bytes, + /// whose components are neither empty, `.`, nor `..`. + Source, + /// The target is not an absolute path of at most [`MAX_PATH_BYTES`] bytes without NUL bytes, + /// whose components after the leading `/` are neither empty, `.`, nor `..`. + Target, +} + +impl fmt::Display for InvalidMapping { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let (path, form) = match self { + Self::Source => ("source", "a relative"), + Self::Target => ("target", "an absolute"), + }; + write!( + formatter, + "a mapping {path} must be {form} path of at most {MAX_PATH_BYTES} bytes without NUL \ + bytes or empty, `.`, or `..` components" + ) + } +} + +impl std::error::Error for InvalidMapping {} + +/// A stage of applying a [`Mapping`]. The variants are in the order of the stages. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum MappingStage { + /// The kind of the source is read, without following a final symbolic link. + Source, + /// The target is created, or an existing one is checked, as the kind of the source. + Target, + /// The source is bind-mounted on the target. + Bind, + /// The bind is remounted with the mapping's flags. + Remount, +} + +impl MappingStage { + /// Returns the failure of this stage with `error`. + fn failure(self, error: Errno) -> MappingFailure { + MappingFailure { + stage: self, + error: error as i32, + } + } +} + +impl fmt::Display for MappingStage { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Source => "source", + Self::Target => "target", + Self::Bind => "bind", + Self::Remount => "remount", + }) + } +} + +/// The stage at which applying a [`Mapping`] failed, and the `errno` of the failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MappingFailure { + /// The stage that failed. + stage: MappingStage, + /// The `errno` with which it failed. + error: i32, +} + +impl MappingFailure { + /// Returns the stage that failed. + pub fn stage(&self) -> MappingStage { + self.stage + } + + /// Returns the `errno` with which the stage failed. + pub fn raw_os_error(&self) -> i32 { + self.error + } +} + +impl fmt::Display for MappingFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "the {} mapping stage failed: {}", + self.stage, + io::Error::from_raw_os_error(self.error) + ) + } +} + +impl std::error::Error for MappingFailure {} + +/// A host mapping: a source below an export root, which [`apply`](Self::apply) bind-mounts at a +/// target below a target root, with an [`Access`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Mapping { + /// The source, relative to the export root. + source: PathBuf, + /// The target, relative to the target root: the absolute target that [`Mapping::new`] took, + /// without its leading `/`. + target: PathBuf, + /// Whether programs can write the target. + access: Access, +} + +impl Mapping { + /// Describes the mapping of `source`, a path relative to the export root, at `target`, an + /// absolute path as seen from the target root, with `access`: for example `0/workspace` at + /// `/workspace`. + /// + /// Each path holds at most [`MAX_PATH_BYTES`] bytes, no NUL byte, and no empty, `.`, or `..` + /// component, so that it names a path below its root; [`InvalidMapping`] tells which one does + /// not. Other bytes need not be UTF-8. + pub fn new( + source: impl Into, + target: impl Into, + access: Access, + ) -> Result { + let source = source.into(); + if below_its_root(&source, false).is_none() { + return Err(InvalidMapping::Source); + } + let target = below_its_root(&target.into(), true) + .ok_or(InvalidMapping::Target)? + .to_path_buf(); + Ok(Self { + source, + target, + access, + }) + } + + /// Returns the source, relative to the export root. + pub fn source(&self) -> &Path { + &self.source + } + + /// Returns the target, relative to the target root: the target that [`new`](Self::new) took, + /// without its leading `/`, such as `workspace` for `/workspace`. + pub fn target(&self) -> &Path { + &self.target + } + + /// Returns whether programs can write the target. + pub fn access(&self) -> Access { + self.access + } + + /// Applies the mapping below `export_root` and `target_root`, the absolute paths of existing + /// directories. It needs the privilege to mount. + /// + /// A directory source gets a directory target: the call creates each missing directory of + /// the target below the target root, with mode 0755 before the umask, and uses the entries + /// that exist as they are, so the bind mount follows a symbolic link there. A regular-file + /// source gets a regular-file target: the call creates the missing directories above it, then + /// the file, empty and with mode 0644 before the umask, unless a regular file is there + /// already, without following a symbolic link. The call then bind-mounts the source on the + /// target, and remounts the bind with `nosuid` and `nodev`, and with `ro` for + /// [`Access::ReadOnly`]. + /// + /// A source of any other kind, such as a symbolic link, and a file target that exists as + /// anything but a regular file fail with `EINVAL`, as does a relative root, at the stage of + /// their path. A failure leaves what the earlier stages did, as the [module](self) + /// documentation describes. + pub fn apply(&self, export_root: &Path, target_root: &Path) -> Result<(), MappingFailure> { + self.apply_with(export_root, target_root, |source, target, flags| { + sys::mount(source, target, None, flags) + }) + } + + /// Applies the mapping as [`apply`](Self::apply) does, but makes each mount through `mount`, + /// which takes the source, if any, the target, and the flags of one `mount` system call + /// without a filesystem type. + pub(crate) fn apply_with( + &self, + export_root: &Path, + target_root: &Path, + mut mount: impl FnMut(Option<&CStr>, &CStr, MountFlags) -> Result<(), Errno>, + ) -> Result<(), MappingFailure> { + let (source, c_source) = below(export_root, &self.source) + .map_err(|error| MappingStage::Source.failure(error))?; + let (target, c_target) = below(target_root, &self.target) + .map_err(|error| MappingStage::Target.failure(error))?; + let kind = std::fs::symlink_metadata(&source) + .map_err(|error| MappingStage::Source.failure(sys::errno_of(&error)))? + .file_type(); + let created = if kind.is_dir() { + create_directories(target_root, &self.target) + } else if kind.is_file() { + let parent = self.target.parent().unwrap_or(Path::new("")); + create_directories(target_root, parent).and_then(|()| file_target(&target, &c_target)) + } else { + return Err(MappingStage::Source.failure(Errno::EINVAL)); + }; + created.map_err(|error| MappingStage::Target.failure(error))?; + mount(Some(&c_source), &c_target, MountFlags::MS_BIND) + .map_err(|error| MappingStage::Bind.failure(error))?; + let mut flags = MountFlags::MS_BIND + | MountFlags::MS_REMOUNT + | MountFlags::MS_NOSUID + | MountFlags::MS_NODEV; + if self.access == Access::ReadOnly { + flags |= MountFlags::MS_RDONLY; + } + mount(None, &c_target, flags).map_err(|error| MappingStage::Remount.failure(error)) + } +} + +/// Returns the part of `path` below its root: the whole of a relative path, or what follows the +/// leading `/` of an `absolute` one. It returns `None` unless `path` holds at most +/// [`MAX_PATH_BYTES`] bytes and no NUL byte, starts with `/` exactly when it is `absolute`, and +/// has a part below its root of at least one component, each neither empty, `.`, nor `..`. +fn below_its_root(path: &Path, absolute: bool) -> Option<&Path> { + let bytes = path.as_os_str().as_bytes(); + let components = if absolute { + bytes.strip_prefix(b"/")? + } else { + bytes + }; + let valid = bytes.len() <= MAX_PATH_BYTES + && !bytes.contains(&0) + && !components.is_empty() + && components + .split(|&byte| byte == b'/') + .all(|component| !matches!(component, b"" | b"." | b"..")); + valid.then(|| Path::new(OsStr::from_bytes(components))) +} + +/// Returns the path that the relative `path` names below `root`, and the same path as a C string, +/// or `EINVAL` if `root` is relative or holds a NUL byte. +fn below(root: &Path, path: &Path) -> Result<(PathBuf, CString), Errno> { + if !root.is_absolute() { + return Err(Errno::EINVAL); + } + let joined = root.join(path); + let c_string = sys::path_c_string(&joined)?; + Ok((joined, c_string)) +} + +/// Creates each missing directory of the relative `directories` below `root`, outermost first, +/// with [`DIRECTORY_MODE`] before the umask. It uses the entries that exist as they are. +fn create_directories(root: &Path, directories: &Path) -> Result<(), Errno> { + let mut directory = root.to_path_buf(); + for component in directories { + directory.push(component); + match sys::create_directory(&sys::path_c_string(&directory)?, DIRECTORY_MODE) { + Ok(()) | Err(Errno::EEXIST) => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + +/// Checks that the file target `target`, whose C string is `c_target`, is a regular file, or +/// creates it if it is missing, without following a symbolic link. +fn file_target(target: &Path, c_target: &CStr) -> Result<(), Errno> { + match std::fs::symlink_metadata(target) { + Ok(existing) if existing.file_type().is_file() => Ok(()), + Ok(_) => Err(Errno::EINVAL), + Err(error) => match sys::errno_of(&error) { + Errno::ENOENT => create_file(c_target), + error => Err(error), + }, + } +} + +/// Creates the regular file `path`, empty and with [`FILE_MODE`] before the umask, failing if any +/// entry is there, even a symbolic link. +/// +/// The new file closes at once, and a failure to close it is ignored. +fn create_file(path: &CStr) -> Result<(), Errno> { + sys::open( + path, + OpenFlags::O_RDONLY + | OpenFlags::O_CREAT + | OpenFlags::O_EXCL + | OpenFlags::O_CLOEXEC + | OpenFlags::O_NOFOLLOW, + FILE_MODE, + ) + .map(|_created| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One recorded mount: the source, if any, the target, and the flags. + type Mount = (Option>, Vec, MountFlags); + + /// Returns the flags of every remount, without `MS_RDONLY`. + fn remount() -> MountFlags { + MountFlags::MS_BIND | MountFlags::MS_REMOUNT | MountFlags::MS_NOSUID | MountFlags::MS_NODEV + } + + /// Returns the bytes of `path`. + fn bytes(path: &Path) -> Vec { + path.as_os_str().as_bytes().to_vec() + } + + /// Returns a temporary directory, which the caller keeps, with an export root, `export`, that + /// holds the directory `0/src` and the file `0/notes.txt`, and an empty target root, `root`. + fn roots() -> (tempfile::TempDir, PathBuf, PathBuf) { + let temporary = tempfile::tempdir().unwrap(); + let export = temporary.path().join("export"); + let root = temporary.path().join("root"); + std::fs::create_dir_all(export.join("0/src")).unwrap(); + std::fs::write(export.join("0/notes.txt"), b"notes").unwrap(); + std::fs::create_dir(&root).unwrap(); + (temporary, export, root) + } + + /// Applies `mapping` below `export` and `root`, records its mounts in `mounts`, and fails + /// the mount call numbered `fail`, from 1 for the first call of `mounts`, with `ENOENT`. + fn apply( + mapping: &Mapping, + export: &Path, + root: &Path, + mounts: &mut Vec, + fail: Option, + ) -> Result<(), MappingFailure> { + mapping.apply_with(export, root, |source, target, flags| { + mounts.push(( + source.map(|source| source.to_bytes().to_vec()), + target.to_bytes().to_vec(), + flags, + )); + if fail == Some(mounts.len()) { + Err(Errno::ENOENT) + } else { + Ok(()) + } + }) + } + + /// Returns the sorted names in `directory`. + fn entries(directory: &Path) -> Vec { + let mut names = std::fs::read_dir(directory) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + names.sort(); + names + } + + #[test] + fn directory_and_file_mappings_go_below_a_target_root_other_than_slash() { + let (temporary, export, root) = roots(); + let mut mounts = Vec::new(); + let directory = Mapping::new("0/src", "/srv/data", Access::ReadWrite).unwrap(); + let file = Mapping::new("0/notes.txt", "/etc/app/notes.txt", Access::ReadWrite).unwrap(); + apply(&directory, &export, &root, &mut mounts, None).unwrap(); + apply(&file, &export, &root, &mut mounts, None).unwrap(); + let (data, notes) = (root.join("srv/data"), root.join("etc/app/notes.txt")); + assert_eq!( + mounts, + vec![ + ( + Some(bytes(&export.join("0/src"))), + bytes(&data), + MountFlags::MS_BIND + ), + (None, bytes(&data), remount()), + ( + Some(bytes(&export.join("0/notes.txt"))), + bytes(¬es), + MountFlags::MS_BIND + ), + (None, bytes(¬es), remount()), + ] + ); + assert!(data.is_dir()); + assert!(notes.is_file()); + assert_eq!(std::fs::read(¬es).unwrap(), b""); + // The call creates nothing outside the target root and changes nothing in the export. + assert_eq!(entries(temporary.path()), ["export", "root"]); + assert_eq!(entries(&root), ["etc", "srv"]); + assert_eq!(entries(&export.join("0")), ["notes.txt", "src"]); + assert_eq!(std::fs::read(export.join("0/notes.txt")).unwrap(), b"notes"); + assert_eq!( + (directory.source(), directory.target()), + (Path::new("0/src"), Path::new("srv/data")) + ); + } + + #[test] + fn read_only_and_read_write_mappings_remount_with_their_flags() { + let (_temporary, export, root) = roots(); + for (source, target) in [("0/src", "/directory"), ("0/notes.txt", "/file")] { + for (access, flags) in [ + (Access::ReadOnly, remount() | MountFlags::MS_RDONLY), + (Access::ReadWrite, remount()), + ] { + let mapping = Mapping::new(source, target, access).unwrap(); + assert_eq!(mapping.access(), access); + let mut mounts = Vec::new(); + apply(&mapping, &export, &root, &mut mounts, None).unwrap(); + let mounted = bytes(&root.join(&target[1..])); + assert_eq!( + mounts, + vec![ + ( + Some(bytes(&export.join(source))), + mounted.clone(), + MountFlags::MS_BIND + ), + (None, mounted, flags), + ], + "{source} {access:?}" + ); + } + } + } + + #[test] + fn sources_and_targets_that_would_leave_their_roots_are_refused() { + let long = "a".repeat(MAX_PATH_BYTES + 1); + for source in [ + "", + "/0/src", + "..", + "../0", + "0/../..", + "0/..", + ".", + "./0", + "0/.", + "0//src", + "0/src/", + "0/s\0rc", + long.as_str(), + ] { + assert_eq!( + Mapping::new(source, "/target", Access::ReadOnly), + Err(InvalidMapping::Source), + "{source:?}" + ); + } + let long_target = format!("/{long}"); + for target in [ + "", + "target", + "0/src", + "/", + "//target", + "/target/", + "/..", + "/../etc", + "/target/../../etc", + "/target/./etc", + "/./target", + "/tar\0get", + long_target.as_str(), + ] { + assert_eq!( + Mapping::new("0/src", target, Access::ReadOnly), + Err(InvalidMapping::Target), + "{target:?}" + ); + } + // The longest paths and bytes that are not UTF-8 name paths below their roots. + let longest = "a".repeat(MAX_PATH_BYTES); + Mapping::new( + longest.as_str(), + format!("/{}", &longest[1..]), + Access::ReadOnly, + ) + .unwrap(); + Mapping::new( + OsStr::from_bytes(b"0/\xffsrc"), + OsStr::from_bytes(b"/\xfe"), + Access::ReadWrite, + ) + .unwrap(); + + // A root must be absolute, and a target root is never created. + let (temporary, export, root) = roots(); + let mapping = Mapping::new("0/src", "/srv/data", Access::ReadOnly).unwrap(); + let missing = temporary.path().join("missing"); + let mut mounts = Vec::new(); + for (export_root, target_root, stage, error) in [ + ( + Path::new("export"), + root.as_path(), + MappingStage::Source, + Errno::EINVAL, + ), + ( + export.as_path(), + Path::new("root"), + MappingStage::Target, + Errno::EINVAL, + ), + ( + export.as_path(), + missing.as_path(), + MappingStage::Target, + Errno::ENOENT, + ), + ] { + let failure = apply(&mapping, export_root, target_root, &mut mounts, None).unwrap_err(); + assert_eq!( + (failure.stage(), failure.raw_os_error()), + (stage, error as i32), + "{export_root:?} {target_root:?}" + ); + } + assert!(mounts.is_empty()); + assert_eq!(entries(&root), Vec::::new()); + assert!(!missing.exists()); + } + + #[test] + fn sources_and_file_targets_of_other_kinds_are_refused() { + let (temporary, export, root) = roots(); + std::os::unix::fs::symlink("src", export.join("0/link")).unwrap(); + let _socket = std::os::unix::net::UnixListener::bind(export.join("0/socket")).unwrap(); + std::fs::create_dir(root.join("directory")).unwrap(); + std::fs::write(root.join("plain"), b"plain").unwrap(); + let outside = temporary.path().join("outside"); + std::os::unix::fs::symlink(&outside, root.join("link")).unwrap(); + let mut mounts = Vec::new(); + for (source, target, stage, error) in [ + ("0/link", "/new", MappingStage::Source, Errno::EINVAL), + ("0/socket", "/new", MappingStage::Source, Errno::EINVAL), + ("0/missing", "/new", MappingStage::Source, Errno::ENOENT), + ( + "0/notes.txt", + "/directory", + MappingStage::Target, + Errno::EINVAL, + ), + ("0/notes.txt", "/link", MappingStage::Target, Errno::EINVAL), + ("0/src", "/plain/new", MappingStage::Target, Errno::ENOTDIR), + ] { + let mapping = Mapping::new(source, target, Access::ReadWrite).unwrap(); + let failure = apply(&mapping, &export, &root, &mut mounts, None).unwrap_err(); + assert_eq!( + (failure.stage(), failure.raw_os_error()), + (stage, error as i32), + "{source} {target}" + ); + } + assert!(mounts.is_empty()); + // Nothing followed the symbolic link out of the target root, or replaced an entry. + assert!(!outside.exists()); + assert_eq!(entries(&root), ["directory", "link", "plain"]); + assert_eq!(entries(&root.join("directory")), Vec::::new()); + assert_eq!(std::fs::read(root.join("plain")).unwrap(), b"plain"); + } + + #[test] + fn existing_targets_are_used_as_they_are() { + let (_temporary, export, root) = roots(); + std::fs::create_dir_all(root.join("srv/data/kept")).unwrap(); + std::fs::write(root.join("notes.txt"), b"existing").unwrap(); + let mut mounts = Vec::new(); + for (source, target) in [("0/src", "/srv/data"), ("0/notes.txt", "/notes.txt")] { + let mapping = Mapping::new(source, target, Access::ReadOnly).unwrap(); + apply(&mapping, &export, &root, &mut mounts, None).unwrap(); + } + assert_eq!(mounts.len(), 4); + assert!(root.join("srv/data/kept").is_dir()); + assert_eq!(std::fs::read(root.join("notes.txt")).unwrap(), b"existing"); + } + + #[test] + fn a_failed_bind_or_remount_reports_its_stage_and_leaves_earlier_stages() { + let (_temporary, export, root) = roots(); + let mapping = Mapping::new("0/src", "/srv/data", Access::ReadOnly).unwrap(); + for (fail, stage) in [(1, MappingStage::Bind), (2, MappingStage::Remount)] { + let mut mounts = Vec::new(); + let failure = apply(&mapping, &export, &root, &mut mounts, Some(fail)).unwrap_err(); + assert_eq!( + (failure.stage(), failure.raw_os_error()), + (stage, Errno::ENOENT as i32) + ); + assert_eq!( + failure.to_string(), + format!( + "the {stage} mapping stage failed: {}", + io::Error::from_raw_os_error(Errno::ENOENT as i32) + ) + ); + // The created target stays, and the remount comes only after its bind. + assert_eq!(mounts.len(), fail); + assert!(root.join("srv/data").is_dir()); + } + assert_eq!( + InvalidMapping::Target.to_string(), + format!( + "a mapping target must be an absolute path of at most {MAX_PATH_BYTES} bytes \ + without NUL bytes or empty, `.`, or `..` components" + ) + ); + } +} diff --git a/nvx-rs/src/guest/mod.rs b/nvx-rs/src/guest/mod.rs index 2d587ee9d..c2c02591e 100644 --- a/nvx-rs/src/guest/mod.rs +++ b/nvx-rs/src/guest/mod.rs @@ -1,12 +1,14 @@ //! The programs that run in NVX's Linux guests, which ship as `/sbin/nvx-*`, and the libraries //! on which any guest init or agent builds: [`nvxs`], the guest's I/O driver of the NVXS session; //! [`spawn`], through which it starts a program; [`process`], through which it collects the -//! status of each child that it names, and of no other; and [`cgroup`], through which it places, -//! accounts, kills, and settles the processes of its executions. +//! status of each child that it names, and of no other; [`cgroup`], through which it places, +//! accounts, kills, and settles the processes of its executions; and [`mappings`], through which +//! it bind-mounts host mappings below the roots that it names. //! //! Each other module holds one program, and the crate's binary of the same name runs its `main`. //! The libraries need none of NVX's control protocol, managed agent, or workload identity, and -//! NVX's managed agent starts, collects, and settles its workloads through them. +//! NVX's managed agent applies its host mapping table and starts, collects, and settles its +//! workloads through them. //! //! The programs keep the command lines, exit statuses, and output of the C programs that they //! replace, except that numeric arguments are plain decimal, with a `-` sign where `nvx-time`'s @@ -72,6 +74,7 @@ pub mod device_io; pub mod exit; pub mod ingress; pub mod managed_agent; +pub mod mappings; #[cfg(target_arch = "x86_64")] pub mod mmio_write; pub mod nvxs; diff --git a/nvx-rs/src/guest/sys/filesystem.rs b/nvx-rs/src/guest/sys/filesystem.rs index bc62df8b0..9ccfff8e0 100644 --- a/nvx-rs/src/guest/sys/filesystem.rs +++ b/nvx-rs/src/guest/sys/filesystem.rs @@ -15,6 +15,10 @@ use nix::unistd::{AccessFlags, Gid, Uid}; use super::{Errno, errno_of}; +/// Linux's `PATH_MAX`: the size of the longest path that the kernel takes, with its terminating +/// NUL. +pub(crate) const PATH_MAX: usize = nix::libc::PATH_MAX as usize; + /// Converts a Unix path to a NUL-terminated string, or `EINVAL` if it contains a NUL. pub(crate) fn path_c_string(path: &Path) -> Result { super::c_string(path.as_os_str().as_bytes()) diff --git a/nvx-rs/src/guest/sys/mod.rs b/nvx-rs/src/guest/sys/mod.rs index 9a35e1ab4..8362a6786 100644 --- a/nvx-rs/src/guest/sys/mod.rs +++ b/nvx-rs/src/guest/sys/mod.rs @@ -77,9 +77,9 @@ pub(crate) use fd::{ create_memfd, create_pipe, lock_file, open, open_at, page_size, unlink, }; pub(crate) use filesystem::{ - MountFlags, ScratchDirectory, TmpfsMount, change_directory, check_exists, chmod, chown, - current_directory, enter_private_mount_namespace, mount, path_c_string, path_exists, read_file, - read_line, read_number, write_file, write_number, + MountFlags, PATH_MAX, ScratchDirectory, TmpfsMount, change_directory, check_exists, chmod, + chown, current_directory, enter_private_mount_namespace, mount, path_c_string, path_exists, + read_file, read_line, read_number, write_file, write_number, }; pub(crate) use ioctl::{console_pending, reseed_random}; pub(crate) use mapping::MemoryMapping; diff --git a/nvx-rs/src/lib.rs b/nvx-rs/src/lib.rs index 91b843476..d62cf9575 100644 --- a/nvx-rs/src/lib.rs +++ b/nvx-rs/src/lib.rs @@ -22,9 +22,9 @@ //! - `guest` (feature `guest`, Linux): the programs that run in NVX guests, such as //! `nvx-console-pending`, the guest's NVXS connection, and the libraries through which any //! guest init or agent starts a program in a new process (`guest::spawn`), collects the status -//! of each child that it names (`guest::process`), and places, kills, and settles the processes -//! of its executions in cgroups (`guest::cgroup`). The crate's `nvx-*` guest binaries run the -//! programs. +//! of each child that it names (`guest::process`), places, kills, and settles the processes of +//! its executions in cgroups (`guest::cgroup`), and bind-mounts host mappings below the roots +//! that it names (`guest::mappings`). The crate's `nvx-*` guest binaries run the programs. //! //! # Example //!