diff --git a/doc/project-structure.md b/doc/project-structure.md index bfc91f3a5..1758b2310 100644 --- a/doc/project-structure.md +++ b/doc/project-structure.md @@ -92,6 +92,7 @@ nvx/ |-- nvx-rs/ Rust crate `nvx` for the NVX mechanics | |-- src/ | | |-- protocol/ Control-console codec, NVXS session, and the guest targets that live shares may not take, shared by the host and the guest +| | |-- activation/ The activation record of a layered root and what Ready reports about it, shared by the host, the guest, and every adapter's framing | | |-- contract.rs Control-contract versions and release compatibility | | |-- net/ CIDR parsing and subtraction, and hostname and guest-network forms | | |-- gpt/ Strict reading of GPT disks whose first partition configures an adapter and whose other partitions hold a root's read-only layers, shared by the host and the guest diff --git a/nvx-rs/README.md b/nvx-rs/README.md index a466a45e9..8c141704d 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -30,6 +30,8 @@ nvx-rs/ │ ├── protocol/ NVXS session, NVXC frames, EXEC and MAPS payloads, guest features, kernel │ │ command-line tokens, and the guest targets that live shares may not take; │ │ no dependencies, shared by the host and the guest +│ ├── activation/ the activation record of a layered root and what Ready reports about it; +│ │ no dependencies, shared by the host, the guest, and every adapter's framing │ ├── contract.rs control-contract versions and release-manifest compatibility │ ├── net/ CIDR parsing and subtraction, and hostname and guest-network forms │ ├── gpt/ strict reading of GPT disks whose first partition configures an adapter and @@ -74,6 +76,7 @@ nvx-rs/ | `nvx::protocol::nvxs` | The NVXS session, independent of `NVXC` and of any other application protocol: `RecordType`, `Record`, `encode_record`, and `RecordParser`, which parses records incrementally across partial reads with buffers bounded by one record; `HostSession` and `GuestSession`, the state machines of the host's and the guest's roles, which validate every record's type, identity, and sequence number, and the guest's receive window and credits, without I/O. An application message may span DATA records, and the application's own framing reassembles it | | `nvx::protocol::root` | The boot tokens of a layered root, which the host renders and guests parse by the same rules: a `RootLayout` is a `RootImage` on the `distro` block device, an EROFS filesystem or a GPT disk with the `nvx::uuid::Uuid` that the guest expects of it, if any, over a `RootUpper`, the ext4 scratch disk or RAM. `RootLayout::tokens` renders `nvx_layer=distro,0xd0003000,FORMAT[:IDENTITY]` and `nvx_scratch=0xd0006000,ext4` or `nvx_overlay_upper=ramfs`, and `RootLayout::parse` reads them from a kernel command line, and refuses malformed, repeated, and conflicting tokens and the earlier form without a format, with no fallback that probes the devices. It reads the virtio-mmio devices of the command line with `nvx::protocol::cmdline`, to refuse a RAM upper beside one at the scratch base. `tests/fixtures/root-tokens.json` holds its cases, which the tests of the guest's shell init replay too | | `nvx::protocol::share` | The guest targets that a sandbox's live shares may not take, which the host's `sandbox::mount` and the guest's `root` both refuse: `RESERVED_MOUNT_TARGETS`, the trees where the container runtime mounts or binds, `RESERVED_EXACT_MOUNT_TARGETS`, the directories that it creates, `RESERVED_TARGET_CHARACTERS`, and `reservation`, which tells why a target is reserved | +| `nvx::activation` | The activation record of a layered root: one bounded, versioned, non-secret record of the settings that its activation applies, which a clone restored from a template reads in place of its captured configuration. An `ActivationRecord` holds the schema's `VERSION`, a clone's `RestoreSection`, the restore's `TransactionId` and the `TemplateReference` of its template, the template's `StoreGeneration`, a nonzero publication ID that its store assigns in order, and the `Identity` of its key, and the `Configuration`: the `Image`, EROFS or GPT, with the `Identity` of its binding and the UUID or disk GUID that the guest expects; the `Upper`, a tmpfs or the scratch with its binding's `Identity` and its `Journal` policy; the `OverlayMode`; an `Export`, a directory or an aggregate with its plan's `Identity`, and its 1 to 4,096 mappings; the `Network`, its policy's `Identity`, a DNS address, the listeners' `Publication`, and a proxy endpoint without credentials; the `WorkloadView`, its hostname, `MachineIdMode`, and `WorkloadIdentity`, whose IDs are neither 0 nor `u32::MAX`; and the `ExpectedResources`. The other inputs of the root's assembly are invariants of the template. `ActivationRecord::new` refuses a configuration that `Configuration::check` finds past a public limit, `MAX_MAPPINGS`, `MAX_HOSTNAME_BYTES`, or `MAX_PROXY_ENDPOINT_BYTES`, which keep every record within `MAX_RECORD_BYTES`, or with an export without mappings, mappings without an export, a UID or GID of 0 or `u32::MAX`, a proxy endpoint with credentials, or a zero size. `ReadyActivation` is what Ready reports: the `ConfigurationIdentity`, and a clone's transaction ID. It needs no features | | `nvx::contract` | The OpenVMM microVM ABI, boot contract, broker protocol, and control-contract revision that the crate speaks, and when each changes, and `manifest_is_compatible` for a release's `SOURCE-MANIFEST.json` | | `nvx::net` | `Cidr`: IPv4 and IPv6 networks and their subtraction, which `Cidr::parse` reads without host bits and `Cidr::parse_masked` as OpenVMM reads the destination of an egress rule; `is_valid_hostname` and `is_valid_guest_network`, the forms of a VM's hostname and guest address, and `guest_gateway`, the gateway of a guest address | | `nvx::gpt` | `Disk::read`: the protective MBR, primary header, and primary partition entry array of a GPT disk, read through a `ReadAt` source that the caller gives and checked strictly, as UEFI specifies them and as Linux requires them, without ever reading the backup: 128-byte entries, at most 1 MiB of them, used entries first and without holes, a first partition and 1 to 254 layers, extents in the usable blocks without overlap, and unique GUIDs, with checked arithmetic in the disk's logical blocks. `Expected` adds the disk GUID and the partitions' unique GUIDs and sizes that a caller knows, and `Disk::read_first_partition` returns the first partition's opaque bytes, up to a bound and without trailing NUL bytes and whitespace. GUIDs are `nvx::uuid::Uuid`s. It has no dependencies, so the guest and the host's checks of the images that it stages share it | @@ -876,10 +879,10 @@ caught. | `serde` | The serde form of `Hypervisor` | | `guest` | The `guest` module and the guest binaries; see [Guest binaries](#guest-binaries) | -Without default features, the crate has only `protocol`, `exec`, `contract`, -`net`, `gpt`, `uuid`, `Hypervisor`, and `Error`, and depends on nothing but -`serde` when `serde` is enabled and `nix` and `getrandom` when `guest` is; the -protocol-only configuration of the +Without default features, the crate has only `protocol`, `activation`, `exec`, +`contract`, `net`, `gpt`, `uuid`, `Hypervisor`, and `Error`, and depends on +nothing but `serde` when `serde` is enabled and `nix` and `getrandom` when +`guest` is; the protocol-only configuration of the [runtime adapter](../tests/runtime-adapter/README.md) uses it that way. ## Example diff --git a/nvx-rs/src/activation/mod.rs b/nvx-rs/src/activation/mod.rs new file mode 100644 index 000000000..59e49c64f --- /dev/null +++ b/nvx-rs/src/activation/mod.rs @@ -0,0 +1,672 @@ +//! The activation record of a layered root: one bounded, versioned, non-secret record of the +//! settings that a root's activation applies, and what Ready reports about that activation, which +//! the host, the guest, and every adapter's framing share. +//! +//! A layered root's guest assembles its root and enters its workload with settings that the host +//! chooses for each launch. A clone that the host restores from a template keeps the template's +//! captured configuration, so its record gives it the settings of its own launch: everything that +//! it needs to activate. A cold boot's record holds the settings that its guest applied, so that +//! every layered root reports the identity of its configuration in the same way: +//! +//! - [`ActivationRecord`]: the [`VERSION`] of its schema, the [`RestoreSection`] of a clone, and +//! the [`Configuration`]. A clone's restore section holds the restore's [`TransactionId`], 16 +//! random bytes that the host picks for each restore and keeps across the restore's retries, +//! and the [`TemplateReference`] of the template that the clone restores: the template's +//! [`StoreGeneration`], the nonzero publication ID that its store assigned to it, and the +//! [`Identity`] of its key. A cold boot's record has no restore section. +//! - [`Configuration`]: the non-secret settings that the activation applies, which the +//! [configuration](#configuration) lists. +//! - [`ReadyActivation`]: what Ready reports about the activation: the [`ConfigurationIdentity`], +//! which every layered root reports, and the transaction ID, which only a clone reports. +//! +//! [`ActivationRecord::new`] admits a configuration only once [`Configuration::check`] finds that +//! it keeps every [limit](#limits), so that every record holds settings that a guest can apply. +//! The module depends on nothing outside the standard library and the crate's `protocol` and +//! `uuid` modules, so it builds without features, and the host, the guest, and every adapter +//! share it. +//! +//! # Configuration +//! +//! A [`Configuration`] holds: +//! +//! - the [`Image`]: its format, an EROFS filesystem or a GPT disk, the [`Identity`] of the +//! image's binding, and the EROFS filesystem's UUID or the GPT disk's GUID that the guest +//! expects, if any; +//! - the [`Upper`]: a tmpfs, or the scratch, with the [`Identity`] of the scratch's binding; +//! - the [`Export`], if any: one export, a directory or an aggregate, with the [`Identity`] of +//! its plan, which covers the export's whole shape, and the number of host mappings that the +//! host sends once the guest is ready, from 1 to [`MAX_MAPPINGS`]. A configuration without an +//! export has no mappings; +//! - the [`Network`] of a VM that has one: the [`Identity`] of its policy, the address of its DNS +//! server, if any, the [`Publication`] of the workload's listeners, and the endpoint of its +//! proxy, if any, which holds no credentials; +//! - the [`WorkloadView`]: the hostname, the [`MachineIdMode`], the image's own machine ID or one +//! that the guest generates, and the [`WorkloadIdentity`], if any: a UID and a GID, neither 0 +//! nor `u32::MAX`, whose account the image must hold, without supplementary groups or +//! capabilities, in which every execution runs, as each execution of a layered sandbox runs +//! today. An adapter whose executions name their own identity, as CRI containers do, leaves it +//! out; +//! - the root's settings: the [`OverlayMode`], the [`Journal`] policy of a scratch upper, and the +//! bound of a GPT disk's first partition; +//! - the [`ExpectedResources`]: the CPU count and the memory size that the guest must have, as a +//! clone's repair produces them. +//! +//! The guest takes the other inputs of its root's assembly from its own configuration, which a +//! template captures, so that they are invariants of the template: the runtime directory, the +//! MMIO windows and the sizes of the block devices, the partitions that a GPT disk must have, of +//! which it expects none, and how long it waits for the devices. +//! +//! Secrets, per-execution environments, and host mapping tables never enter a record: they +//! travel on the control channel once the guest is ready, as after a cold boot. +//! +//! # Limits +//! +//! The limits are public constants: a record holds at most [`MAX_RECORD_BYTES`] bytes, so that a +//! restore packet could carry the same bytes, a configuration announces at most [`MAX_MAPPINGS`] +//! mappings, a hostname holds at most [`MAX_HOSTNAME_BYTES`] bytes, and a proxy endpoint at most +//! [`MAX_PROXY_ENDPOINT_BYTES`]. The limits of a record's parts keep every record within +//! [`MAX_RECORD_BYTES`]. +//! +//! [`Configuration::check`] also refuses an export without mappings, mappings without an export, +//! a workload identity whose UID or GID is 0, or `u32::MAX`, which the kernel reads as an +//! unchanged ID, a hostname with a NUL, a proxy endpoint with anything other than visible ASCII +//! characters or with an `@`, which would start credentials, and a zero CPU count, memory size, +//! or bound of a GPT disk's first partition. +//! +//! ``` +//! use std::num::NonZeroU64; +//! +//! use nvx::activation::{ +//! ActivationRecord, Configuration, ExpectedResources, Identity, Image, MachineIdMode, +//! OverlayMode, RestoreSection, StoreGeneration, TemplateReference, TransactionId, Upper, +//! WorkloadIdentity, WorkloadView, +//! }; +//! +//! # fn main() -> Result<(), Box> { +//! let configuration = Configuration { +//! image: Image::Erofs { +//! binding: Identity::from_bytes([0x11; 32]), +//! uuid: Some("0123abcd-4567-89ef-0123-456789abcdef".parse()?), +//! }, +//! upper: Upper::Tmpfs, +//! overlay: OverlayMode::UntrustedLowers, +//! export: None, +//! mappings: 0, +//! network: None, +//! workload: WorkloadView { +//! hostname: b"sandbox".to_vec(), +//! machine_id: MachineIdMode::Generated, +//! identity: Some(WorkloadIdentity { uid: 1000, gid: 1000 }), +//! }, +//! resources: ExpectedResources { cpus: 2, memory: 1 << 30 }, +//! }; +//! // A cold boot's record has no restore section. +//! let cold = ActivationRecord::new(None, configuration.clone())?; +//! assert!(cold.restore().is_none()); +//! // A clone's names its restore's transaction and the template that it restores. +//! let restore = RestoreSection { +//! transaction: TransactionId::from_bytes([0x22; 16]), +//! template: TemplateReference { +//! generation: StoreGeneration::new(NonZeroU64::MIN), +//! key: Identity::from_bytes([0x44; 32]), +//! }, +//! }; +//! let clone = ActivationRecord::new(Some(restore), configuration)?; +//! assert_eq!(clone.restore(), Some(&restore)); +//! # Ok(()) +//! # } +//! ``` + +use std::fmt; +use std::net::IpAddr; +use std::num::NonZeroU64; + +use crate::protocol::MAX_HOST_MAPPINGS; +use crate::protocol::root::ImageFormat; +use crate::uuid::Uuid; + +#[cfg(test)] +mod tests; + +/// The version of the activation contract that this build speaks: the schema of an +/// [`ActivationRecord`], which every record names. A record of another version is refused. +pub const VERSION: u16 = 1; + +/// The most bytes that a record holds, so that a restore packet could carry the same bytes. +pub const MAX_RECORD_BYTES: usize = 1024; + +/// The most host mappings that a configuration announces: as many as a host mapping table holds, +/// [`MAX_HOST_MAPPINGS`]. +pub const MAX_MAPPINGS: usize = MAX_HOST_MAPPINGS; + +/// The longest hostname, in bytes: Linux's `HOST_NAME_MAX`, which the guest's entry applies. +pub const MAX_HOSTNAME_BYTES: usize = 64; + +/// The longest proxy endpoint, in bytes. +pub const MAX_PROXY_ENDPOINT_BYTES: usize = 255; + +/// The identity of an object that a record names: 32 bytes that the host computes, such as a +/// SHA-256 digest of the object's canonical form, by which a record names the binding of its +/// image and of its scratch, the plan of its export, the policy of its network, and the key of a +/// clone's template. A record never holds or reads the objects themselves. +/// +/// It displays as 64 lowercase hexadecimal digits. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct Identity([u8; 32]); + +impl Identity { + /// The length of an identity, in bytes. + pub const LEN: usize = 32; + + /// Returns the identity whose bytes are `bytes`. + pub const fn from_bytes(bytes: [u8; Self::LEN]) -> Self { + Self(bytes) + } + + /// Returns the identity's bytes. + pub const fn as_bytes(&self) -> &[u8; Self::LEN] { + &self.0 + } +} + +impl fmt::Display for Identity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write_hex(formatter, &self.0) + } +} + +impl fmt::Debug for Identity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "Identity({self})") + } +} + +/// The transaction ID of a clone's restore: 16 random bytes that the host picks for each restore +/// and keeps across the restore's retries, which the clone's Ready reports. +/// +/// It displays as 32 lowercase hexadecimal digits. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct TransactionId([u8; 16]); + +impl TransactionId { + /// The length of a transaction ID, in bytes. + pub const LEN: usize = 16; + + /// Returns the transaction ID whose bytes are `bytes`. + pub const fn from_bytes(bytes: [u8; Self::LEN]) -> Self { + Self(bytes) + } + + /// Returns the transaction ID's bytes. + pub const fn as_bytes(&self) -> &[u8; Self::LEN] { + &self.0 + } +} + +impl fmt::Display for TransactionId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write_hex(formatter, &self.0) + } +} + +impl fmt::Debug for TransactionId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "TransactionId({self})") + } +} + +/// The generation of a template in its store: the template's publication ID, a nonzero number +/// that the store assigns to each template that it publishes, in the order of publication, and +/// never reuses. +/// +/// Two generations of one store compare in the order of their publication. A generation displays +/// as its decimal number. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct StoreGeneration(NonZeroU64); + +impl StoreGeneration { + /// Returns the store generation `generation`. + pub const fn new(generation: NonZeroU64) -> Self { + Self(generation) + } + + /// Returns the store generation's number. + pub const fn get(self) -> NonZeroU64 { + self.0 + } +} + +impl fmt::Display for StoreGeneration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.0) + } +} + +/// The identity of the configuration that a layered root's activation applied, which its Ready +/// reports: 32 bytes. +/// +/// It displays as 64 lowercase hexadecimal digits. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct ConfigurationIdentity([u8; 32]); + +impl ConfigurationIdentity { + /// The length of a configuration identity, in bytes. + pub const LEN: usize = 32; + + /// Returns the configuration identity whose bytes are `bytes`. + pub const fn from_bytes(bytes: [u8; Self::LEN]) -> Self { + Self(bytes) + } + + /// Returns the configuration identity's bytes. + pub const fn as_bytes(&self) -> &[u8; Self::LEN] { + &self.0 + } +} + +impl fmt::Display for ConfigurationIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write_hex(formatter, &self.0) + } +} + +impl fmt::Debug for ConfigurationIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "ConfigurationIdentity({self})") + } +} + +/// The template that a clone restores: the generation of the template in its store, and the +/// identity of the template's key. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TemplateReference { + /// The template's generation in its store. + pub generation: StoreGeneration, + /// The identity of the template's key. + pub key: Identity, +} + +/// The restore section of a clone's record: the restore's transaction ID, and the template that +/// the clone restores. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RestoreSection { + /// The restore's transaction ID, which the clone's Ready reports. + pub transaction: TransactionId, + /// The template that the clone restores. + pub template: TemplateReference, +} + +/// The image of a layered root, on its `distro` block device, in the format that the record +/// names: the guest never probes the image for another. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Image { + /// An EROFS filesystem, the overlay's one lower layer. + Erofs { + /// The identity of the image's binding. + binding: Identity, + /// The UUID that the filesystem's superblock must hold, if the guest expects one. + uuid: Option, + }, + /// A GPT disk, whose first partition holds opaque bytes, and whose later partitions hold the + /// overlay's lower layers. + Gpt { + /// The identity of the image's binding. + binding: Identity, + /// The GUID that the disk must have, if the guest expects one. + disk_guid: Option, + /// The most bytes that the disk's first partition may hold: at least 1, since a + /// partition holds at least one block. + first_partition_bound: u64, + }, +} + +impl Image { + /// Returns the image's format. + pub fn format(&self) -> ImageFormat { + match self { + Self::Erofs { .. } => ImageFormat::Erofs, + Self::Gpt { .. } => ImageFormat::Gpt, + } + } + + /// Returns the identity of the image's binding. + pub fn binding(&self) -> &Identity { + match self { + Self::Erofs { binding, .. } | Self::Gpt { binding, .. } => binding, + } + } + + /// Returns what the guest expects of the image, if anything: the EROFS filesystem's UUID, or + /// the GPT disk's GUID. + pub fn expected(&self) -> Option { + match self { + Self::Erofs { uuid, .. } => *uuid, + Self::Gpt { disk_guid, .. } => *disk_guid, + } + } +} + +/// The upper of a layered root, which holds the overlay's upper and work directories. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Upper { + /// A tmpfs, for a root that keeps no upper on a scratch. + Tmpfs, + /// A whole-device ext4 filesystem on the `scratch` block device. + Scratch { + /// The identity of the scratch's binding. + binding: Identity, + /// Whether the scratch's filesystem may have a journal. + journal: Journal, + }, +} + +/// Whether an ext4 scratch may have a journal: the guest's assembly refuses a scratch with one +/// when the policy [forbids](Self::Forbidden) it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Journal { + /// The scratch may have a journal or none. + Allowed, + /// The scratch may not have a journal. + Forbidden, +} + +/// The overlay's mode, which selects its one option set, as the guest's assembly names it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum OverlayMode { + /// `metacopy=on,xino=on`, for lowers whose overlay xattrs the guest trusts. + TrustedLowers, + /// `metacopy=off,redirect_dir=nofollow`, for lowers that may carry untrusted overlay xattrs. + UntrustedLowers, +} + +/// The shape of an export. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ExportKind { + /// One host directory, which is the export's root. + Directory, + /// An aggregate of host directories and files, each a child of the export's root. + Aggregate, +} + +/// The export that the guest mounts as the source of the host's mappings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Export { + /// The export's shape. + pub kind: ExportKind, + /// The identity of the export's plan, which covers the export's whole shape, so that an + /// export of any shape has a record of the same form. + pub plan: Identity, +} + +/// Whether the workload's listeners are published, through private-network ingress or the +/// host's loopback, in the vocabulary of the guest's entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Publication { + /// The workload's listeners are published. + Published, + /// No listener is published. + Unpublished, +} + +/// The network of a VM that has one. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Network { + /// The identity of the network's policy. + pub policy: Identity, + /// The address of the DNS server that the guest's resolver configuration names, if any. + pub dns: Option, + /// Whether the workload's listeners are published. + pub publication: Publication, + /// The endpoint of the proxy through which the workload reaches the network, as the guest + /// sees it, such as `http://10.0.0.1:3128`, if any: 1 to [`MAX_PROXY_ENDPOINT_BYTES`] visible + /// ASCII characters, without an `@`, so that it holds no credentials. + pub proxy: Option, +} + +/// The machine ID that the workload sees at `/etc/machine-id`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MachineIdMode { + /// The image's own machine ID, which the guest leaves untouched. + Image, + /// A machine ID that the guest generates for the activation. + Generated, +} + +/// The identity in which every execution of a workload runs: a UID and a GID, neither 0 nor +/// `u32::MAX`, whose account the image must hold, without supplementary groups or capabilities. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct WorkloadIdentity { + /// The user ID: neither 0 nor `u32::MAX`. + pub uid: u32, + /// The group ID: neither 0 nor `u32::MAX`. + pub gid: u32, +} + +/// The view that the workload has of its VM. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct WorkloadView { + /// The hostname: 1 to [`MAX_HOSTNAME_BYTES`] bytes, without a NUL. + pub hostname: Vec, + /// The machine ID that the workload sees. + pub machine_id: MachineIdMode, + /// The identity in which every execution runs, unless the executions name their own. + pub identity: Option, +} + +/// The resources that the guest must have, as a clone's repair produces them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ExpectedResources { + /// The number of online CPUs: at least 1. + pub cpus: u32, + /// The memory size, in bytes: at least 1. + pub memory: u64, +} + +/// The non-secret settings that a layered root's activation applies, as the +/// [module](self#configuration) describes them. +/// +/// [`check`](Self::check) checks that they keep every limit, and [`ActivationRecord::new`] admits +/// only a configuration that does. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Configuration { + /// The image. + pub image: Image, + /// The upper. + pub upper: Upper, + /// The overlay's mode. + pub overlay: OverlayMode, + /// The export, if any. + pub export: Option, + /// The number of host mappings that the host sends once the guest is ready: 1 to + /// [`MAX_MAPPINGS`] with an export, and 0 without one. + pub mappings: usize, + /// The network, for a VM that has one. + pub network: Option, + /// The workload's view. + pub workload: WorkloadView, + /// The resources that the guest must have. + pub resources: ExpectedResources, +} + +impl Configuration { + /// Checks that the configuration keeps every limit of the [module](self#limits), and returns + /// the first that it breaks, in the order of the configuration's fields. + /// + /// It refuses a GPT disk's first-partition bound of 0; an export without mappings, mappings + /// without an export, and more than [`MAX_MAPPINGS`] mappings; a proxy endpoint that is empty + /// or longer than [`MAX_PROXY_ENDPOINT_BYTES`], or holds a byte other than a visible ASCII + /// character, or an `@`; a hostname that is empty or longer than [`MAX_HOSTNAME_BYTES`], or + /// holds a NUL; a workload identity whose UID or GID is 0 or `u32::MAX`; and a CPU count or + /// memory size of 0. + pub fn check(&self) -> Result<(), ActivationError> { + if let Image::Gpt { + first_partition_bound: 0, + .. + } = self.image + { + return Err(ActivationError::FirstPartitionBound); + } + match (self.export.is_some(), self.mappings) { + (true, 0) => return Err(ActivationError::ExportWithoutMappings), + (true, mappings) if mappings > MAX_MAPPINGS => { + return Err(ActivationError::TooManyMappings(mappings)); + } + (false, 1..) => return Err(ActivationError::MappingsWithoutExport), + _ => {} + } + if let Some(proxy) = self + .network + .as_ref() + .and_then(|network| network.proxy.as_ref()) + && !is_proxy_endpoint(proxy) + { + return Err(ActivationError::ProxyEndpoint); + } + let hostname = &self.workload.hostname; + if hostname.is_empty() || hostname.len() > MAX_HOSTNAME_BYTES || hostname.contains(&0) { + return Err(ActivationError::Hostname); + } + // The kernel reads `u32::MAX` as an unchanged ID, which the guest's entry refuses. + if let Some(WorkloadIdentity { uid, gid }) = self.workload.identity + && [uid, gid].into_iter().any(|id| id == 0 || id == u32::MAX) + { + return Err(ActivationError::WorkloadIdentity); + } + if self.resources.cpus == 0 { + return Err(ActivationError::Cpus); + } + if self.resources.memory == 0 { + return Err(ActivationError::Memory); + } + Ok(()) + } +} + +/// An activation record: the [`VERSION`] of its schema, the [`RestoreSection`] of a clone, and +/// the [`Configuration`], which keeps every limit of the [module](self#limits). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ActivationRecord { + /// The restore section, for a clone. + restore: Option, + /// The configuration, which keeps every limit. + configuration: Configuration, +} + +impl ActivationRecord { + /// Returns the record of `configuration`, with `restore`, the restore section of a clone, or + /// without one for a cold boot. + /// + /// Fails with the first limit that the configuration breaks, as [`Configuration::check`] + /// finds it. + pub fn new( + restore: Option, + configuration: Configuration, + ) -> Result { + configuration.check()?; + Ok(Self { + restore, + configuration, + }) + } + + /// Returns the version of the record's schema: [`VERSION`]. + pub fn version(&self) -> u16 { + VERSION + } + + /// Returns the restore section of a clone's record, or `None` for a cold boot's. + pub fn restore(&self) -> Option<&RestoreSection> { + self.restore.as_ref() + } + + /// Returns the configuration. + pub fn configuration(&self) -> &Configuration { + &self.configuration + } +} + +/// What Ready reports about a layered root's activation: the identity of the configuration that +/// the activation applied, and, for a clone, the transaction ID of the restore that it activated. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ReadyActivation { + /// The identity of the configuration that the activation applied. + pub identity: ConfigurationIdentity, + /// The transaction ID of a clone's restore, or `None` for a cold boot. + pub transaction: Option, +} + +/// Why an activation record was refused: the limit or the rule that it breaks. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ActivationError { + /// A GPT disk's first-partition bound is 0. + FirstPartitionBound, + /// The configuration has an export, but no mappings. + ExportWithoutMappings, + /// The configuration has mappings, but no export. + MappingsWithoutExport, + /// The configuration announces more than [`MAX_MAPPINGS`] mappings, this many. + TooManyMappings(usize), + /// The proxy endpoint is empty, longer than [`MAX_PROXY_ENDPOINT_BYTES`], or holds a byte + /// other than a visible ASCII character, or an `@`. + ProxyEndpoint, + /// The hostname is empty, longer than [`MAX_HOSTNAME_BYTES`], or holds a NUL. + Hostname, + /// The workload identity's UID or GID is 0, or `u32::MAX`, which the kernel reads as an + /// unchanged ID. + WorkloadIdentity, + /// The expected CPU count is 0. + Cpus, + /// The expected memory size is 0. + Memory, +} + +impl fmt::Display for ActivationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::FirstPartitionBound => { + formatter.write_str("the bound of a GPT disk's first partition is 0") + } + Self::ExportWithoutMappings => formatter.write_str("the export has no mappings"), + Self::MappingsWithoutExport => formatter.write_str("the mappings have no export"), + Self::TooManyMappings(mappings) => write!( + formatter, + "{mappings} mappings are more than the {MAX_MAPPINGS} that a record announces" + ), + Self::ProxyEndpoint => write!( + formatter, + "the proxy endpoint is not 1 to {MAX_PROXY_ENDPOINT_BYTES} visible ASCII \ + characters without an @" + ), + Self::Hostname => write!( + formatter, + "the hostname is not 1 to {MAX_HOSTNAME_BYTES} bytes without a NUL" + ), + Self::WorkloadIdentity => { + formatter.write_str("the workload identity's UID or GID is 0 or 4294967295") + } + Self::Cpus => formatter.write_str("the expected CPU count is 0"), + Self::Memory => formatter.write_str("the expected memory size is 0"), + } + } +} + +impl std::error::Error for ActivationError {} + +/// Returns whether `endpoint` is a proxy endpoint that a record holds: 1 to +/// [`MAX_PROXY_ENDPOINT_BYTES`] visible ASCII characters, without an `@`, which would start a +/// URL's user information, where credentials go. +fn is_proxy_endpoint(endpoint: &str) -> bool { + !endpoint.is_empty() + && endpoint.len() <= MAX_PROXY_ENDPOINT_BYTES + && endpoint + .bytes() + .all(|byte| byte.is_ascii_graphic() && byte != b'@') +} + +/// Writes `bytes` as lowercase hexadecimal digits, two for each byte. +fn write_hex(formatter: &mut fmt::Formatter<'_>, bytes: &[u8]) -> fmt::Result { + bytes + .iter() + .try_for_each(|byte| write!(formatter, "{byte:02x}")) +} diff --git a/nvx-rs/src/activation/tests.rs b/nvx-rs/src/activation/tests.rs new file mode 100644 index 000000000..36c031516 --- /dev/null +++ b/nvx-rs/src/activation/tests.rs @@ -0,0 +1,461 @@ +//! Tests of the activation record's types and limits. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::num::NonZeroU64; + +use super::*; + +/// The disk GUID of the tests' GPT images. +const DISK_GUID: &str = "fedcba98-7654-3210-fedc-ba9876543210"; + +/// Returns the configuration of the tests: an EROFS image over a tmpfs, without an export, a +/// network, or a workload identity. +fn configuration() -> Configuration { + Configuration { + image: Image::Erofs { + binding: Identity::from_bytes([0x11; 32]), + uuid: None, + }, + upper: Upper::Tmpfs, + overlay: OverlayMode::TrustedLowers, + export: None, + mappings: 0, + network: None, + workload: WorkloadView { + hostname: b"nvx-sandbox".to_vec(), + machine_id: MachineIdMode::Image, + identity: None, + }, + resources: ExpectedResources { + cpus: 1, + memory: 256 << 20, + }, + } +} + +/// Returns a configuration whose every part is at its limit: a GPT image over a scratch, an +/// aggregate export of [`MAX_MAPPINGS`] mappings, a network with an IPv6 DNS server and a proxy +/// endpoint of [`MAX_PROXY_ENDPOINT_BYTES`], a hostname of [`MAX_HOSTNAME_BYTES`], and the largest +/// IDs and resources. +fn maximal() -> Configuration { + Configuration { + image: Image::Gpt { + binding: Identity::from_bytes([0xff; 32]), + disk_guid: Some(DISK_GUID.parse().unwrap()), + first_partition_bound: u64::MAX, + }, + upper: Upper::Scratch { + binding: Identity::from_bytes([0xee; 32]), + journal: Journal::Forbidden, + }, + overlay: OverlayMode::UntrustedLowers, + export: Some(Export { + kind: ExportKind::Aggregate, + plan: Identity::from_bytes([0xdd; 32]), + }), + mappings: MAX_MAPPINGS, + network: Some(Network { + policy: Identity::from_bytes([0xcc; 32]), + dns: Some(IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0xa00, 1))), + publication: Publication::Published, + proxy: Some("~".repeat(MAX_PROXY_ENDPOINT_BYTES)), + }), + workload: WorkloadView { + hostname: vec![0xff; MAX_HOSTNAME_BYTES], + machine_id: MachineIdMode::Generated, + identity: Some(WorkloadIdentity { + uid: u32::MAX - 1, + gid: u32::MAX - 1, + }), + }, + resources: ExpectedResources { + cpus: u32::MAX, + memory: u64::MAX, + }, + } +} + +/// Returns the restore section of the tests' clones. +fn restore() -> RestoreSection { + RestoreSection { + transaction: TransactionId::from_bytes([0x22; 16]), + template: TemplateReference { + generation: StoreGeneration::new(NonZeroU64::new(0x3333_3333_3333_3333).unwrap()), + key: Identity::from_bytes([0x44; 32]), + }, + } +} + +/// Returns the cold boot's record of the tests' configuration once `change` has changed it. +fn admit(change: impl FnOnce(&mut Configuration)) -> Result { + let mut configuration = configuration(); + change(&mut configuration); + ActivationRecord::new(None, configuration) +} + +/// Gives a configuration an export of `mappings` mappings. +fn export(configuration: &mut Configuration, mappings: usize) { + configuration.export = Some(Export { + kind: ExportKind::Directory, + plan: Identity::from_bytes([0x55; 32]), + }); + configuration.mappings = mappings; +} + +/// Gives a configuration a network whose proxy endpoint is `proxy`. +fn proxy(configuration: &mut Configuration, proxy: &str) { + configuration.network = Some(Network { + policy: Identity::from_bytes([0x66; 32]), + dns: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), + publication: Publication::Unpublished, + proxy: Some(proxy.to_owned()), + }); +} + +#[test] +fn the_limits_keep_their_values() { + assert_eq!(VERSION, 1); + assert_eq!(MAX_RECORD_BYTES, 1024); + assert_eq!(MAX_MAPPINGS, 4096); + assert_eq!(MAX_MAPPINGS, crate::protocol::MAX_HOST_MAPPINGS); + assert_eq!(MAX_HOSTNAME_BYTES, 64); + assert_eq!(MAX_PROXY_ENDPOINT_BYTES, 255); + assert_eq!(Identity::LEN, 32); + assert_eq!(ConfigurationIdentity::LEN, 32); + assert_eq!(TransactionId::LEN, 16); +} + +#[test] +fn a_cold_boot_record_has_no_restore_section_and_a_clone_record_has_one() { + let cold = ActivationRecord::new(None, configuration()).unwrap(); + assert_eq!(cold.version(), VERSION); + assert_eq!(cold.restore(), None); + assert_eq!(cold.configuration(), &configuration()); + let clone = ActivationRecord::new(Some(restore()), configuration()).unwrap(); + assert_eq!(clone.version(), VERSION); + assert_eq!(clone.restore(), Some(&restore())); + assert_eq!(clone.configuration(), &configuration()); + assert_ne!(cold, clone); +} + +#[test] +fn a_configuration_with_every_part_at_its_limit_is_admitted() { + let configuration = maximal(); + assert_eq!(configuration.check(), Ok(())); + let record = ActivationRecord::new(Some(restore()), configuration.clone()).unwrap(); + assert_eq!(record.configuration(), &configuration); +} + +#[test] +fn mappings_are_admitted_up_to_the_limit_with_an_export() { + for mappings in [1, 2, MAX_MAPPINGS - 1, MAX_MAPPINGS] { + let record = admit(|configuration| export(configuration, mappings)).unwrap(); + assert_eq!(record.configuration().mappings, mappings); + } + for mappings in [MAX_MAPPINGS + 1, usize::MAX] { + assert_eq!( + admit(|configuration| export(configuration, mappings)), + Err(ActivationError::TooManyMappings(mappings)) + ); + } +} + +#[test] +fn an_export_without_mappings_is_refused() { + assert_eq!( + admit(|configuration| export(configuration, 0)), + Err(ActivationError::ExportWithoutMappings) + ); +} + +#[test] +fn mappings_without_an_export_are_refused() { + for mappings in [1, MAX_MAPPINGS, MAX_MAPPINGS + 1] { + assert_eq!( + admit(|configuration| configuration.mappings = mappings), + Err(ActivationError::MappingsWithoutExport), + "{mappings}" + ); + } +} + +#[test] +fn hostnames_are_admitted_up_to_the_limit_without_a_nul() { + for length in [1, 2, MAX_HOSTNAME_BYTES - 1, MAX_HOSTNAME_BYTES] { + let record = + admit(|configuration| configuration.workload.hostname = vec![b'a'; length]).unwrap(); + assert_eq!(record.configuration().workload.hostname.len(), length); + } + // The guest's entry sets any hostname without a NUL, so a record holds any other byte. + admit(|configuration| configuration.workload.hostname = b"\x01 \xff.Host".to_vec()).unwrap(); + for hostname in [ + Vec::new(), + vec![b'a'; MAX_HOSTNAME_BYTES + 1], + b"\0".to_vec(), + b"nvx\0sandbox".to_vec(), + b"nvx-sandbox\0".to_vec(), + ] { + assert_eq!( + admit(|configuration| configuration.workload.hostname = hostname.clone()), + Err(ActivationError::Hostname), + "{hostname:?}" + ); + } +} + +#[test] +fn proxy_endpoints_are_admitted_up_to_the_limit_without_credentials() { + for endpoint in [ + "http://10.0.0.1:3128".to_owned(), + "10.0.0.1:3128".to_owned(), + "https://[fd00::a00:1]:8443/".to_owned(), + "!".to_owned(), + "~".repeat(MAX_PROXY_ENDPOINT_BYTES - 1), + "~".repeat(MAX_PROXY_ENDPOINT_BYTES), + ] { + let record = admit(|configuration| proxy(configuration, &endpoint)).unwrap(); + let network = record.configuration().network.as_ref().unwrap(); + assert_eq!(network.proxy.as_deref(), Some(endpoint.as_str())); + } + for endpoint in [ + String::new(), + "~".repeat(MAX_PROXY_ENDPOINT_BYTES + 1), + "http://user:secret@10.0.0.1:3128".to_owned(), + "http://@10.0.0.1:3128".to_owned(), + "http://10.0.0.1:3128 ".to_owned(), + "http://10.0.0.1:3128\n".to_owned(), + "http://10.0.0.1:3128\0".to_owned(), + "http://10.0.0.1:3128\u{7f}".to_owned(), + "http://proxy.ex\u{e1}mple:3128".to_owned(), + ] { + assert_eq!( + admit(|configuration| proxy(configuration, &endpoint)), + Err(ActivationError::ProxyEndpoint), + "{endpoint:?}" + ); + } + // A network without a proxy needs no endpoint. + admit(|configuration| { + proxy(configuration, "unused"); + configuration.network.as_mut().unwrap().proxy = None; + }) + .unwrap(); +} + +#[test] +fn a_uid_or_gid_of_0_or_u32_max_is_refused() { + for (uid, gid) in [ + (1, 1), + (1000, 1001), + (65534, 65534), + (u32::MAX - 1, u32::MAX - 1), + ] { + let identity = WorkloadIdentity { uid, gid }; + let record = + admit(|configuration| configuration.workload.identity = Some(identity)).unwrap(); + assert_eq!(record.configuration().workload.identity, Some(identity)); + } + for (uid, gid) in [ + (0, 1000), + (1000, 0), + (u32::MAX, 1000), + (1000, u32::MAX), + (0, 0), + (u32::MAX, u32::MAX), + (0, u32::MAX), + ] { + assert_eq!( + admit(|configuration| { + configuration.workload.identity = Some(WorkloadIdentity { uid, gid }); + }), + Err(ActivationError::WorkloadIdentity), + "{uid}:{gid}" + ); + } +} + +#[test] +fn zero_resources_are_refused() { + assert_eq!( + admit(|configuration| configuration.resources.cpus = 0), + Err(ActivationError::Cpus) + ); + assert_eq!( + admit(|configuration| configuration.resources.memory = 0), + Err(ActivationError::Memory) + ); +} + +#[test] +fn a_zero_first_partition_bound_is_refused() { + let gpt = |first_partition_bound| Image::Gpt { + binding: Identity::from_bytes([0x77; 32]), + disk_guid: None, + first_partition_bound, + }; + for bound in [1, 64 << 10, u64::MAX] { + admit(|configuration| configuration.image = gpt(bound)).unwrap(); + } + assert_eq!( + admit(|configuration| configuration.image = gpt(0)), + Err(ActivationError::FirstPartitionBound) + ); +} + +#[test] +fn the_first_broken_limit_is_reported_in_the_order_of_the_fields() { + let mut configuration = maximal(); + configuration.resources.memory = 0; + assert_eq!(configuration.check(), Err(ActivationError::Memory)); + configuration.resources.cpus = 0; + assert_eq!(configuration.check(), Err(ActivationError::Cpus)); + configuration.workload.identity = Some(WorkloadIdentity { uid: 0, gid: 0 }); + assert_eq!( + configuration.check(), + Err(ActivationError::WorkloadIdentity) + ); + configuration.workload.hostname.clear(); + assert_eq!(configuration.check(), Err(ActivationError::Hostname)); + configuration.network.as_mut().unwrap().proxy = Some(String::new()); + assert_eq!(configuration.check(), Err(ActivationError::ProxyEndpoint)); + configuration.mappings = MAX_MAPPINGS + 1; + assert_eq!( + configuration.check(), + Err(ActivationError::TooManyMappings(MAX_MAPPINGS + 1)) + ); + configuration.mappings = 0; + assert_eq!( + configuration.check(), + Err(ActivationError::ExportWithoutMappings) + ); + configuration.image = Image::Gpt { + binding: Identity::from_bytes([0; 32]), + disk_guid: None, + first_partition_bound: 0, + }; + assert_eq!( + configuration.check(), + Err(ActivationError::FirstPartitionBound) + ); +} + +#[test] +fn errors_name_the_limits_that_they_break() { + for (error, message) in [ + ( + ActivationError::FirstPartitionBound, + "the bound of a GPT disk's first partition is 0", + ), + ( + ActivationError::ExportWithoutMappings, + "the export has no mappings", + ), + ( + ActivationError::MappingsWithoutExport, + "the mappings have no export", + ), + ( + ActivationError::TooManyMappings(4097), + "4097 mappings are more than the 4096 that a record announces", + ), + ( + ActivationError::ProxyEndpoint, + "the proxy endpoint is not 1 to 255 visible ASCII characters without an @", + ), + ( + ActivationError::Hostname, + "the hostname is not 1 to 64 bytes without a NUL", + ), + ( + ActivationError::WorkloadIdentity, + "the workload identity's UID or GID is 0 or 4294967295", + ), + (ActivationError::Cpus, "the expected CPU count is 0"), + (ActivationError::Memory, "the expected memory size is 0"), + ] { + assert_eq!(error.to_string(), message); + } +} + +#[test] +fn opaque_values_keep_their_bytes_and_display_them_in_hexadecimal() { + let bytes: [u8; 32] = std::array::from_fn(|index| index as u8 * 8 + 1); + let digits = "0109111921293139414951596169717981899199a1a9b1b9c1c9d1d9e1e9f1f9"; + let identity = Identity::from_bytes(bytes); + assert_eq!(identity.as_bytes(), &bytes); + assert_eq!(identity.to_string(), digits); + assert_eq!(format!("{identity:?}"), format!("Identity({digits})")); + let configuration = ConfigurationIdentity::from_bytes(bytes); + assert_eq!(configuration.as_bytes(), &bytes); + assert_eq!(configuration.to_string(), digits); + assert_eq!( + format!("{configuration:?}"), + format!("ConfigurationIdentity({digits})") + ); + let bytes: [u8; 16] = std::array::from_fn(|index| 0xf0 - index as u8); + let digits = "f0efeeedecebeae9e8e7e6e5e4e3e2e1"; + let transaction = TransactionId::from_bytes(bytes); + assert_eq!(transaction.as_bytes(), &bytes); + assert_eq!(transaction.to_string(), digits); + assert_eq!( + format!("{transaction:?}"), + format!("TransactionId({digits})") + ); +} + +#[test] +fn a_store_generation_is_a_nonzero_number_in_the_order_of_publication() { + let first = StoreGeneration::new(NonZeroU64::MIN); + let number = NonZeroU64::new(1_000_000_007).unwrap(); + let later = StoreGeneration::new(number); + assert_eq!(first.get(), NonZeroU64::MIN); + assert_eq!(later.get(), number); + assert!(first < later); + assert_eq!(later.to_string(), "1000000007"); + assert_eq!(format!("{later:?}"), "StoreGeneration(1000000007)"); + let last = StoreGeneration::new(NonZeroU64::MAX); + assert!(later < last); + assert_eq!(last.to_string(), "18446744073709551615"); +} + +#[test] +fn an_image_reports_its_format_binding_and_expected_identity() { + let binding = Identity::from_bytes([0x88; 32]); + let guid: Uuid = DISK_GUID.parse().unwrap(); + let erofs = Image::Erofs { + binding, + uuid: Some(guid), + }; + assert_eq!( + (erofs.format(), erofs.binding(), erofs.expected()), + (ImageFormat::Erofs, &binding, Some(guid)) + ); + let gpt = Image::Gpt { + binding, + disk_guid: None, + first_partition_bound: 1, + }; + assert_eq!( + (gpt.format(), gpt.binding(), gpt.expected()), + (ImageFormat::Gpt, &binding, None) + ); +} + +#[test] +fn ready_reports_a_transaction_only_for_a_clone() { + let identity = ConfigurationIdentity::from_bytes([0x99; 32]); + let cold = ReadyActivation { + identity, + transaction: None, + }; + let clone = ReadyActivation { + identity, + transaction: Some(restore().transaction), + }; + assert_eq!(cold.identity, clone.identity); + assert_eq!( + clone.transaction, + Some(TransactionId::from_bytes([0x22; 16])) + ); + assert_ne!(cold, clone); +} diff --git a/nvx-rs/src/lib.rs b/nvx-rs/src/lib.rs index da6b2c883..5f1bebeda 100644 --- a/nvx-rs/src/lib.rs +++ b/nvx-rs/src/lib.rs @@ -8,6 +8,10 @@ //! - [`protocol`]: the codec of the control console, the `NVXS` session that OpenVMM brokers and //! the `NVXC` frames of the guest agent, and the guest targets that a sandbox's live shares may //! not take. It has no dependencies, so the host, the guest, and test doubles share it. +//! - [`activation`]: the activation record of a layered root, the bounded, versioned, non-secret +//! record of the settings that its activation applies, which a clone restored from a template +//! reads in place of its captured configuration, and what Ready reports about the activation. +//! It needs no features, so the host, the guest, and every adapter's framing share it. //! - [`contract`]: the control-contract versions that this crate speaks, and the compatibility of //! NVX releases with them. //! - [`exec`]: the credit and stdio engine of an execution, which the side that runs a workload @@ -116,6 +120,7 @@ //! - `guest`: the `guest` module and the guest binaries, which the `guest` profile builds as //! static `x86_64-unknown-linux-musl` executables. +pub mod activation; pub mod contract; mod error; pub mod exec;