Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
14 changes: 9 additions & 5 deletions nvx-rs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
| --- | --- |
Expand Down
213 changes: 138 additions & 75 deletions nvx-rs/src/guest/managed_agent/mappings.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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,
Expand All @@ -77,89 +84,44 @@ 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::<Result<Vec<_>, _>>()
.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;
}
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<Mapping, InvalidMapping> {
let path = |bytes: Vec<u8>| PathBuf::from(OsString::from_vec(bytes));
Comment thread
esaurez marked this conversation as resolved.
let access = if entry.read_only {
Access::ReadOnly
} else {
Access::ReadWrite
};
Mapping::new(path(entry.source), path(entry.target), access)
}

#[cfg(test)]
Expand Down Expand Up @@ -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::<Result<Vec<_>, _>>()
.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::<Vec<_>>(),
[
(longest, target[1..].to_vec(), Access::ReadOnly),
(b"0/\xff".to_vec(), b"\xfe".to_vec(), Access::ReadWrite),
]
);
}
}
Loading
Loading