From 9c4f20dc15eaae2492355ea95ec9d847a86ff95e Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 24 Oct 2026 10:33:13 -0700 Subject: [PATCH 1/3] nvx-rs: define the activation record of a layered root and its limits A clone that the host restores from a template keeps the template's captured configuration and command line, so nothing tells its guest the image, upper, export, network settings, and workload view of its own launch, and nothing identifies the settings that a guest applied (#838). The host, the guest, and every adapter's framing need one record of them, which they all check by the same rules. nvx::activation defines it without features, beside nvx::protocol. An ActivationRecord holds the version of its schema, the restore section of a clone, which names the restore's transaction ID and the template that it restores, by its store generation and the identity of its key, and the Configuration that the activation applies: the image, 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 journal policy; the overlay mode; one export, a directory or an aggregate, with its plan's identity and its 1 to 4,096 mappings; the network, with its policy's identity, a DNS address, the listeners' publication, and a proxy endpoint; the workload view, its hostname, machine-ID mode, and optional workload identity; and the CPU count and memory size that the guest must have. The record names objects by 32-byte identities that the host computes, and never holds their content; the other inputs of the guest's root assembly are invariants of the template. ReadyActivation is what Ready reports: the configuration identity, and a clone's transaction ID. The limits are public constants: 1,024 bytes for a record, 4,096 mappings, 64 bytes for a hostname, and 255 for a proxy endpoint. ActivationRecord::new admits a configuration only once Configuration::check finds that it keeps them, pairs an export with its mappings, and holds a nonzero UID and GID, a hostname without a NUL, as the guest's entry requires, a proxy endpoint of visible ASCII without an @, and so without credentials, and nonzero sizes. The canonical encoding and the configuration identity follow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f7bd07b-b09f-48d5-bc49-ad06f47ef3f9 --- doc/project-structure.md | 1 + nvx-rs/README.md | 11 +- nvx-rs/src/activation/mod.rs | 672 +++++++++++++++++++++++++++++++++ nvx-rs/src/activation/tests.rs | 440 +++++++++++++++++++++ nvx-rs/src/lib.rs | 5 + 5 files changed, 1125 insertions(+), 4 deletions(-) create mode 100644 nvx-rs/src/activation/mod.rs create mode 100644 nvx-rs/src/activation/tests.rs 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..caad415a3 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, a `StoreGeneration` and the `Identity` of the template's 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 nonzero `WorkloadIdentity`; 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 zero UID or GID, 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..1e4cd2ede --- /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`] 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 nonzero UID and GID, 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, 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 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::from_bytes([0x33; 16]), +//! 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 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 16 bytes that the store assigns to a +/// generation when it publishes it, which a record holds without reading them. +/// +/// It displays as 32 lowercase hexadecimal digits. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct StoreGeneration([u8; 16]); + +impl StoreGeneration { + /// The length of a store generation, in bytes. + pub const LEN: usize = 16; + + /// Returns the store generation whose bytes are `bytes`. + pub const fn from_bytes(bytes: [u8; Self::LEN]) -> Self { + Self(bytes) + } + + /// Returns the store generation's bytes. + pub const fn as_bytes(&self) -> &[u8; Self::LEN] { + &self.0 + } +} + +impl fmt::Display for StoreGeneration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write_hex(formatter, &self.0) + } +} + +impl fmt::Debug for StoreGeneration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "StoreGeneration({self})") + } +} + +/// 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 nonzero UID and GID, whose account +/// the image must hold, without supplementary groups or capabilities. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct WorkloadIdentity { + /// The user ID: not 0. + pub uid: u32, + /// The group ID: not 0. + 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; 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); + } + if let Some(WorkloadIdentity { uid, gid }) = self.workload.identity + && (uid == 0 || gid == 0) + { + 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. + 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") + } + 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..f8432659e --- /dev/null +++ b/nvx-rs/src/activation/tests.rs @@ -0,0 +1,440 @@ +//! Tests of the activation record's types and limits. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +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, + gid: u32::MAX, + }), + }, + 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::from_bytes([0x33; 16]), + 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); + assert_eq!(StoreGeneration::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_zero_uid_or_gid_is_refused() { + for (uid, gid) in [(1, 1), (1000, 1001), (65534, 65534), (u32::MAX, u32::MAX)] { + 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, 0), (0, 1000), (1000, 0)] { + 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", + ), + (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})") + ); + let generation = StoreGeneration::from_bytes(bytes); + assert_eq!(generation.as_bytes(), &bytes); + assert_eq!(generation.to_string(), digits); + assert_eq!( + format!("{generation:?}"), + format!("StoreGeneration({digits})") + ); +} + +#[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; From 8cc026525b3a3c9db6333d1fdf361187d287e705 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 24 Oct 2026 11:14:54 -0700 Subject: [PATCH 2/3] nvx-rs: refuse workload IDs that the guest reads as unchanged The kernel reads an ID of u32::MAX as "unchanged", so the guest's entry refuses it, and the managed agent ignores such a workload ID on its command line. Configuration::check refused only 0, so a record could hold a workload identity that no guest applies. It now refuses u32::MAX too, for the UID and the GID alike, and the tests take u32::MAX - 1 as the largest ID that a record holds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f7bd07b-b09f-48d5-bc49-ad06f47ef3f9 --- nvx-rs/README.md | 2 +- nvx-rs/src/activation/mod.rs | 36 +++++++++++++++++++--------------- nvx-rs/src/activation/tests.rs | 25 +++++++++++++++++------ 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index caad415a3..ad5e62355 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -76,7 +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, a `StoreGeneration` and the `Identity` of the template's 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 nonzero `WorkloadIdentity`; 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 zero UID or GID, 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::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, a `StoreGeneration` and the `Identity` of the template's 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 | diff --git a/nvx-rs/src/activation/mod.rs b/nvx-rs/src/activation/mod.rs index 1e4cd2ede..e31aef4cd 100644 --- a/nvx-rs/src/activation/mod.rs +++ b/nvx-rs/src/activation/mod.rs @@ -41,10 +41,11 @@ //! 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 nonzero UID and GID, 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; +//! 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 @@ -67,9 +68,10 @@ //! [`MAX_RECORD_BYTES`]. //! //! [`Configuration::check`] also refuses an export without mappings, mappings without an export, -//! a workload identity whose UID or GID is 0, 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. +//! 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 nvx::activation::{ @@ -434,13 +436,13 @@ pub enum MachineIdMode { Generated, } -/// The identity in which every execution of a workload runs: a nonzero UID and GID, whose account -/// the image must hold, without supplementary groups or capabilities. +/// 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: not 0. + /// The user ID: neither 0 nor `u32::MAX`. pub uid: u32, - /// The group ID: not 0. + /// The group ID: neither 0 nor `u32::MAX`. pub gid: u32, } @@ -498,8 +500,8 @@ impl Configuration { /// 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; and a CPU count or memory size of - /// 0. + /// 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, @@ -528,8 +530,9 @@ impl Configuration { 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 == 0 || gid == 0) + && [uid, gid].into_iter().any(|id| id == 0 || id == u32::MAX) { return Err(ActivationError::WorkloadIdentity); } @@ -613,7 +616,8 @@ pub enum ActivationError { ProxyEndpoint, /// The hostname is empty, longer than [`MAX_HOSTNAME_BYTES`], or holds a NUL. Hostname, - /// The workload identity's UID or GID is 0. + /// 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, @@ -643,7 +647,7 @@ impl fmt::Display for ActivationError { "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") + 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"), diff --git a/nvx-rs/src/activation/tests.rs b/nvx-rs/src/activation/tests.rs index f8432659e..59fc49d47 100644 --- a/nvx-rs/src/activation/tests.rs +++ b/nvx-rs/src/activation/tests.rs @@ -63,8 +63,8 @@ fn maximal() -> Configuration { hostname: vec![0xff; MAX_HOSTNAME_BYTES], machine_id: MachineIdMode::Generated, identity: Some(WorkloadIdentity { - uid: u32::MAX, - gid: u32::MAX, + uid: u32::MAX - 1, + gid: u32::MAX - 1, }), }, resources: ExpectedResources { @@ -243,14 +243,27 @@ fn proxy_endpoints_are_admitted_up_to_the_limit_without_credentials() { } #[test] -fn a_zero_uid_or_gid_is_refused() { - for (uid, gid) in [(1, 1), (1000, 1001), (65534, 65534), (u32::MAX, u32::MAX)] { +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, 0), (0, 1000), (1000, 0)] { + 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 }); @@ -355,7 +368,7 @@ fn errors_name_the_limits_that_they_break() { ), ( ActivationError::WorkloadIdentity, - "the workload identity's UID or GID is 0", + "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"), From 0e11a6597e89aeb5356415771f9c6e03aa034a06 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 24 Oct 2026 11:42:39 -0700 Subject: [PATCH 3/3] nvx-rs: carry the store generation as a nonzero publication ID The template store publishes each template under a nonzero 64-bit ID, which it assigns in the order of publication and never reuses, and a clone's record names the template that it restores by that ID and the identity of the template's key (#838, #839). StoreGeneration held 16 opaque bytes, so the store would have had to fit its ID into bytes of another shape, by a convention of its own. StoreGeneration now wraps a NonZeroU64: StoreGeneration::new takes one, StoreGeneration::get returns it, two generations of one store compare in the order of their publication, and a generation displays as its decimal number. The identity of the template's key stays 32 bytes, and the transaction ID 16. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f7bd07b-b09f-48d5-bc49-ad06f47ef3f9 --- nvx-rs/README.md | 2 +- nvx-rs/src/activation/mod.rs | 44 ++++++++++++++++------------------ nvx-rs/src/activation/tests.rs | 26 +++++++++++++------- 3 files changed, 38 insertions(+), 34 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index ad5e62355..8c141704d 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -76,7 +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, a `StoreGeneration` and the `Identity` of the template's 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::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 | diff --git a/nvx-rs/src/activation/mod.rs b/nvx-rs/src/activation/mod.rs index e31aef4cd..59e49c64f 100644 --- a/nvx-rs/src/activation/mod.rs +++ b/nvx-rs/src/activation/mod.rs @@ -12,8 +12,8 @@ //! 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`] and the [`Identity`] of its key. A cold boot's record has no restore -//! section. +//! [`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`], @@ -74,6 +74,8 @@ //! 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, @@ -105,7 +107,7 @@ //! let restore = RestoreSection { //! transaction: TransactionId::from_bytes([0x22; 16]), //! template: TemplateReference { -//! generation: StoreGeneration::from_bytes([0x33; 16]), +//! generation: StoreGeneration::new(NonZeroU64::MIN), //! key: Identity::from_bytes([0x44; 32]), //! }, //! }; @@ -117,6 +119,7 @@ use std::fmt; use std::net::IpAddr; +use std::num::NonZeroU64; use crate::protocol::MAX_HOST_MAPPINGS; use crate::protocol::root::ImageFormat; @@ -212,37 +215,30 @@ impl fmt::Debug for TransactionId { } } -/// The generation of a template in its store: the 16 bytes that the store assigns to a -/// generation when it publishes it, which a record holds without reading them. +/// 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. /// -/// It displays as 32 lowercase hexadecimal digits. -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub struct StoreGeneration([u8; 16]); +/// 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 { - /// The length of a store generation, in bytes. - pub const LEN: usize = 16; - - /// Returns the store generation whose bytes are `bytes`. - pub const fn from_bytes(bytes: [u8; Self::LEN]) -> Self { - Self(bytes) + /// Returns the store generation `generation`. + pub const fn new(generation: NonZeroU64) -> Self { + Self(generation) } - /// Returns the store generation's bytes. - pub const fn as_bytes(&self) -> &[u8; Self::LEN] { - &self.0 + /// 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_hex(formatter, &self.0) - } -} - -impl fmt::Debug for StoreGeneration { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "StoreGeneration({self})") + write!(formatter, "{}", self.0) } } diff --git a/nvx-rs/src/activation/tests.rs b/nvx-rs/src/activation/tests.rs index 59fc49d47..36c031516 100644 --- a/nvx-rs/src/activation/tests.rs +++ b/nvx-rs/src/activation/tests.rs @@ -1,6 +1,7 @@ //! Tests of the activation record's types and limits. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::num::NonZeroU64; use super::*; @@ -79,7 +80,7 @@ fn restore() -> RestoreSection { RestoreSection { transaction: TransactionId::from_bytes([0x22; 16]), template: TemplateReference { - generation: StoreGeneration::from_bytes([0x33; 16]), + generation: StoreGeneration::new(NonZeroU64::new(0x3333_3333_3333_3333).unwrap()), key: Identity::from_bytes([0x44; 32]), }, } @@ -122,7 +123,6 @@ fn the_limits_keep_their_values() { assert_eq!(Identity::LEN, 32); assert_eq!(ConfigurationIdentity::LEN, 32); assert_eq!(TransactionId::LEN, 16); - assert_eq!(StoreGeneration::LEN, 16); } #[test] @@ -401,13 +401,21 @@ fn opaque_values_keep_their_bytes_and_display_them_in_hexadecimal() { format!("{transaction:?}"), format!("TransactionId({digits})") ); - let generation = StoreGeneration::from_bytes(bytes); - assert_eq!(generation.as_bytes(), &bytes); - assert_eq!(generation.to_string(), digits); - assert_eq!( - format!("{generation:?}"), - format!("StoreGeneration({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]