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
16 changes: 14 additions & 2 deletions doc/design/4.4-host-guest-control-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ Table 4.2 lists the operations of the control protocol.
| `CANCEL` | Kills the running execution that the request names |
| Shutdown | Shuts the VM down gracefully between executions |

A mapping request carries a contiguous range of the host mapping table's
entries, each with its access, a source relative to the guest's mount of the
host's export, and an absolute target in the guest's root. An empty source
names the export's root itself, as the table of a directory export maps the
whole directory. NVX's managed agent, whose export is an aggregate of host
directories and files, whose root lists them, refuses an empty source.

An execution request carries a 32-bit timeout and length-prefixed arguments,
and optionally an absolute working directory of at most 4,096 bytes and up to
256 `KEY=VALUE` environment entries of at most 4,096 bytes each, with
Expand Down Expand Up @@ -126,5 +133,10 @@ feature payloads, and records of the session that carries them.
[`openvmm/`](../../nvx-rs/tests/vectors/openvmm) holds the VMM's own vectors
and cases of the session's records, unchanged. A vector changes only with a
protocol version, which gets vector files of its own, and the bytes of an
earlier version that it refuses become its rejection vectors. The vectors'
[README](../../nvx-rs/tests/vectors/README.md) describes their line format.
earlier version that it refuses become its rejection vectors. Version 1 has
one exception: the bytes of a mapping with an empty source were a rejection
vector until an empty source came to name the export's root. Only the clone
of a template whose export is a directory sends one, to the template's own
guest, which deploys with the host, so no earlier guest receives one. The
vectors' [README](../../nvx-rs/tests/vectors/README.md) describes their line
format.
71 changes: 51 additions & 20 deletions nvx-rs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,23 @@ from the template's RAM to its capacity; the clone's image, which the check of
its `DistroBinding` opens; its upper, either the caller's scratch image, which
the check of its `ScratchBinding` claims, and which nothing formats, copies, or
reads, or for an upper in RAM a fresh sparse file, `clone-scratch.raw` in the
sandbox's directory, which the release removes; and a fresh capability.
sandbox's directory, which the release removes; its export, if it has one, an
`ExportPlan` that `filesystem::plan` made for the container root, which must
export exactly one directory, since the template's dormant filesystem slot binds
one directory and no file, and which must neither overlap the template store, by
the paths that the plan and the store's directory resolve to, nor let the guest
write in a directory through which the store's path leads, links included, since
the host reaches the store, and OpenVMM its snapshots, by that path, nor be or
hold the directory of the clone's sandbox, in which the host writes the clone's
records, checks of paths that leave two rules to whoever names the export, as
they leave the directories above the store: the export must not reach the store
through an alias of it that an administrator configures, such as a bind mount or
a mount point of the store, or of a directory above or inside it, a loopback
share, or a hard link of a payload, nor lie inside a directory that a writable
export of another sandbox or clone holds, since OpenVMM resolves an export's
path again at each launch and restore, so that the directories above the
export's directory must be ones that no guest may change, as those above a
clone's block devices must; and a fresh capability.

It builds the clone's activation record, `ClonePlan::activation`, which the
clone's guest applies in place of the configuration that the template captured,
Expand All @@ -668,11 +684,13 @@ the image, an EROFS layer or a GPT disk, as its binding names it, with its
binding's identity and the filesystem UUID or disk GUID that the adapter's plan
expects of it, and for a GPT disk the bound of its first partition, whose
partition table the guest checks with `nvx::gpt` as at a cold launch; a tmpfs,
or the scratch image's binding identity with the journal policy; no export; the
or the scratch image's binding identity with the journal policy; the export, a
directory, with its plan's identity, and its mappings, the plan's binds, from 1
to 4,096, or no export and no mappings, which leaves the slot dormant; the
template NIC's policy identity, as its key records it, unpublished listeners,
and the guest's proxy URL; and the template's processors and the memory target.
A binding's identity is the SHA-256 digest of a domain and of the binding's
serde form, never of a file's content.
The identity of a binding or of an export plan is the SHA-256 digest of a domain
and of its serde form, never of a file's content.

The image and the scratch device must present the `BlockGeometry` of the
template's, which a restore compares, and be, at the paths by which OpenVMM
Expand All @@ -685,14 +703,17 @@ store's names is refused.

`ClonePlan::launch` then launches the clone as the launch that its lease
reserves, once it has checked that the sandbox's clone binding records the
lease, and that the sandbox records no failure of the clone, which fails once
under its lease, and returns a `Restoring`: it writes the clone's activation
record in its canonical encoding, `clone-activation.bin`, from which a caller
resends it unchanged; it listens for the VMM's restore-ready event, at the
socket `restore.sock` on Linux, and at a named pipe of a random name on Windows;
and it runs `supervisor::launch` with OpenVMM's restore of the clone's devices,
`--restore-snapshot` with the generation's snapshot in place, which no clone
copies or changes, so that clones of one generation restore it at once,
lease, that the sandbox records no failure of the clone, which fails once under
its lease, and, with `ExportPlan::verify`, that the objects of its export, if it
has one, are still those that its plan found, and returns a `Restoring`: it
writes the clone's activation record in its canonical encoding,
`clone-activation.bin`, from which a caller resends it unchanged; it listens for
the VMM's restore-ready event, at the socket `restore.sock` on Linux, and at a
named pipe of a random name on Windows; and it runs `supervisor::launch` with
OpenVMM's restore of the clone's devices, its export bound to the dormant slot,
`--mount` with the directory, and its hidden, re-exposed, and writable paths and
its owner, `--restore-snapshot` with the generation's snapshot in place, which
no clone copies or changes, so that clones of one generation restore it at once,
`--restore-memory` with the memory target, and `--restore-ready-path`, under the
template's backend, CPU profile, and processors, with a boot console that
discards its output, as cold boots have, and the control console, but no kernel,
Expand Down Expand Up @@ -728,18 +749,24 @@ lost, as `CloneControl::lost` tells, with a new one that sends the same record,
in at most `MAX_ACTIVATION_SESSIONS` sessions by the same deadline, and retries
nothing else.

`Activated::start` then starts the sandbox by a Ready-to-Start deadline from the
check of Ready, `READY_TO_START_TIMEOUT`, 30 seconds by default, never sending
the start again, and `Started::commit` commits the launch, which writes its
runtime record. A failure ends the launch as `Starting::abandon` does; a crash
`Activated::start` then sends the clone's mapping table, if it has an export,
through `CloneControl::map`, in order, from paths in the exported directory,
which name no child's index, with an empty source for the directory itself, and
starts the sandbox once the guest applied every mapping, both by a
Ready-to-Start deadline from the check of Ready, `READY_TO_START_TIMEOUT`, 30
seconds by default, never sending either again, and `Started::commit` commits
the launch, which writes its runtime record. A failure, a partial or failed
mapping table included, ends the launch as `Starting::abandon` does; a crash
before the commit leaves the launch's journal, whose recovery ends the VMM,
while the lease lasts until the release, and a crash after it leaves a VM that
reattaches, and keeps its lease while it runs.

A failure of a clone is a `CloneFailure`, whose `Evidence` attributes it to an
`Outcome`, at a `Stage`, once the clone got as far as its `Progress`: the
request, for a denied authorization, a stale binding, invalid targets, format,
settings, Activate, or mappings, or an image, a scratch, or a root that the
settings, Activate, or mappings, such as an export that no clone takes, or whose
objects changed since its plan, or a mapping table that the guest refused, as
`CloneControl::refused` tells, or an image, a scratch, or a root that the
template cannot serve, and for a supplied file of the wrong identity, length, or
geometry, which the host's staging takes instead once the caller proves it,
`CloneFailure::with_staging_proven`; the host, for an endpoint that cannot be
Expand Down Expand Up @@ -945,8 +972,10 @@ descriptor of a `TargetRoot`, the root of a mount of a whole file system, which
overlay or a guest's `/`, from a `SourceRoot`, such as the guest's mount of the
host's export.

A `Mapping` names a relative source, an absolute target, its `Access`, and
optionally the `Kind` that its source must have.
A `Mapping` names a relative source, or an empty one for the source root itself,
such as a directory export's root, which the mapper opens as `.` beneath it with
the same flags, an absolute target, its `Access`, and optionally the `Kind` that
its source must have.

The mapper opens each component with `openat2`, through no symbolic link or
magic link, creates what a target lacks only on the root's own mounts, which
Expand All @@ -968,7 +997,9 @@ failure.

Linux 6.8 or later. NVX's managed agent applies its host mapping table through
it, beneath `/` in direct mode and beneath the container's assembled root in
sandbox mode, and a layered `Root` its live shares and mappings.
sandbox mode, and refuses an empty source, since its export is an aggregate,
whose root lists its children; a layered `Root` applies its live shares and
mappings through it.

### `nvx::guest::nvxs`

Expand Down
52 changes: 47 additions & 5 deletions nvx-rs/src/guest/managed_agent/mappings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,17 @@ pub(crate) fn teardown(system: &mut impl System, mappings: &mut HostMappings) ->
}

/// Returns the library's mapping of one decoded entry, which the decoder's path rules let
/// through unchanged.
/// through unchanged, and refuses an entry of the export's root, which an empty source names: the
/// decoder and the library take it for the root of a directory export, but the agent's export is
/// an aggregate, whose root lists its children, which the agent never maps.
///
/// 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> {
if entry.source.is_empty() {
return Err(InvalidMapping::Source);
}
let path = |bytes: Vec<u8>| PathBuf::from(OsString::from_vec(bytes));
let access = if entry.read_only {
Access::ReadOnly
Expand Down Expand Up @@ -565,7 +570,6 @@ mod tests {
&[good, (b"0/src", target.as_os_str().as_bytes(), 2)],
None,
),
payload(0, &[good, (b"", target.as_os_str().as_bytes(), 0)], None),
[payload(0, &[good], None), vec![0]].concat(),
payload(0, &[good], Some(2)),
payload(0, &[], Some(0)),
Expand All @@ -582,6 +586,41 @@ mod tests {
assert!(!target.exists());
}

#[test]
fn the_root_of_the_export_is_refused_though_it_decodes() {
// An empty source names the export's root, which a directory export maps, but the
// agent's export is an aggregate, whose root lists its children: the agent refuses the
// table that holds one, as it refuses a malformed one, and mounts none of it.
let (_temporary, mut system, mut mappings, guest) = setup(2);
let (first, root) = (guest.join("first"), guest.join("root"));
let request = payload(
0,
&[
(b"0/src", first.as_os_str().as_bytes(), 0),
(b"", root.as_os_str().as_bytes(), protocol::MAP_READ_ONLY),
],
None,
);
let decoded = protocol::decode_guest_maps_payload(&request).unwrap();
assert!(decoded.maps[1].source.is_empty());
assert!(matches!(
apply_request(&mut system, &mut mappings, &request),
Err(MappingError::Invalid)
));
assert!(system.kernel.calls().is_empty());
assert_eq!((mappings.applied, mappings.failed), (0, false));
assert!(!first.exists() && !root.exists());
// So does a sandbox agent, whose export is an aggregate too.
let (_temporary, mut system, mut mappings, rootfs) = sandbox(1);
let request = payload(0, &[(b"", b"/workspace", 0)], None);
assert!(matches!(
apply_request(&mut system, &mut mappings, &request),
Err(MappingError::Invalid)
));
assert!(system.kernel.calls().is_empty());
assert!(!rootfs.join("workspace").exists());
}

#[test]
fn without_an_announcement_tables_are_refused() {
let (_temporary, mut system, mut mappings, guest) = setup(0);
Expand Down Expand Up @@ -664,7 +703,7 @@ mod tests {
}

#[test]
fn every_entry_that_the_decoder_accepts_becomes_a_library_mapping() {
fn every_entry_that_the_decoder_accepts_but_the_export_s_root_becomes_a_library_mapping() {
assert_eq!(
crate::guest::mappings::MAX_PATH_BYTES,
protocol::MAX_MAP_PATH_BYTES
Expand All @@ -680,12 +719,15 @@ mod tests {
protocol::MAP_READ_ONLY,
),
(b"0/\xff", b"/\xfe", 0),
(b"", b"/root", 0),
],
None,
))
.unwrap();
let converted = request
.maps
let mut entries = request.maps;
let root = entries.pop().unwrap();
assert_eq!(mapping_of(root), Err(InvalidMapping::Source));
let converted = entries
.into_iter()
.map(mapping_of)
.collect::<Result<Vec<_>, _>>()
Expand Down
49 changes: 31 additions & 18 deletions nvx-rs/src/guest/mappings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
//! `RESOLVE_NO_MAGICLINKS`: a symbolic link or a magic link at any component, the last
//! included, is refused, and no path leads above the root. It opens a source beneath its root
//! the same way, in one call, and with `RESOLVE_NO_XDEV` too, so that a source is on its root's
//! mount. A target root is the root of a mount, and that mount, like each that the caller adds
//! with [`TargetRoot::add_mount`], shows its whole file system, which `statmount` tells, rather
//! than being a bind of a directory below that file system's root: no rename, through it or
//! another mount of its file system, can move a file out of it, so nothing that the walk opened
//! on one of the root's own mounts leaves the root, whatever is renamed meanwhile.
//! mount. An empty source names the source root itself, such as the root of a directory export,
//! which it opens as `.` beneath the root, with the same flags. A target root is the root of a
//! mount, and that mount, like each that the caller adds with [`TargetRoot::add_mount`], shows its
//! whole file system, which `statmount` tells, rather than being a bind of a directory below that
//! file system's root: no rename, through it or another mount of its file system, can move a file
//! out of it, so nothing that the walk opened on one of the root's own mounts leaves the root,
//! whatever is renamed meanwhile.
//!
//! # Creation
//!
Expand Down Expand Up @@ -97,9 +99,10 @@
//! mapper that is dropped leaves its mappings mounted.
//!
//! NVX's managed agent applies its host mapping table through a mapper beneath its own root, `/`,
//! from its mount of the host's export. A layered [`Root`](super::root::Root) mounts its live
//! shares, and the mappings that its caller applies, through one, which its teardown unmounts
//! first.
//! from its mount of the host's export, an aggregate, whose root, which lists the aggregate's
//! children, it never maps: it refuses an empty source. A layered [`Root`](super::root::Root)
//! mounts its live shares, and the mappings that its caller applies, through one, which its
//! teardown unmounts first.
//!
//! ```no_run
//! use std::path::Path;
Expand Down Expand Up @@ -227,8 +230,9 @@ impl fmt::Display for Kind {
#[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 `..`.
/// The source is neither empty, for the source root itself, nor 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 `..`.
Expand All @@ -238,7 +242,7 @@ pub enum InvalidMapping {
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::Source => ("source", "empty, for its root, or a relative"),
Self::Target => ("target", "an absolute"),
};
write!(
Expand Down Expand Up @@ -269,19 +273,20 @@ pub struct Mapping {
impl Mapping {
/// Describes the mapping of `source`, a path relative to the source root, at `target`, an
/// absolute path as seen from the target root, with `access`: for example `0/workspace` at
/// `/workspace`. The target takes the kind of the source, whatever it is, unless
/// [`with_kind`](Self::with_kind) asks for one.
/// `/workspace`. An empty `source` names the source root itself, such as the root of a
/// directory export, whose whole directory the mapping then maps. The target takes the kind of
/// the source, whatever it is, unless [`with_kind`](Self::with_kind) asks for one.
///
/// 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.
/// component, so that it names a path below its root, or, for an empty source, the root
/// itself; [`InvalidMapping`] tells which one does not. Other bytes need not be UTF-8.
pub fn new(
source: impl Into<PathBuf>,
target: impl Into<PathBuf>,
access: Access,
) -> Result<Self, InvalidMapping> {
let source = source.into();
if below_its_root(&source, false).is_none() {
if !source.as_os_str().is_empty() && below_its_root(&source, false).is_none() {
return Err(InvalidMapping::Source);
}
let target = below_its_root(&target.into(), true)
Expand All @@ -301,7 +306,7 @@ impl Mapping {
self
}

/// Returns the source, relative to the source root.
/// Returns the source, relative to the source root: empty for the source root itself.
pub fn source(&self) -> &Path {
&self.source
}
Expand Down Expand Up @@ -1118,12 +1123,20 @@ fn attach(
}
}

/// Opens `path` beneath `source`, and returns it and its kind, which must be a mapping's.
/// Opens `path` beneath `source`, or the source root itself for an empty `path`, and returns it
/// and its kind, which must be a mapping's.
fn open_source(
kernel: &mut dyn Kernel,
source: &SourceRoot,
path: &Path,
) -> Result<(FileDescriptor, Kind), MappingError> {
// `openat2` resolves no empty path, so the root itself is `.` beneath it, which the same
// resolution confines to the root's own mount, as it does every other source.
let path = if path.as_os_str().is_empty() {
Path::new(".")
} else {
path
};
let opened = sys::path_c_string(path).and_then(|path| {
let descriptor = kernel.open_beneath(
&source.directory,
Expand Down
Loading
Loading