From c177b95758f8c130ddc11ff977ae52a7fe943f61 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 24 Oct 2026 10:54:01 -0700 Subject: [PATCH] nvx-rs: encode activation records canonically The host writes an activation record, and the guest reads it, so they need one encoding of it, which also gives the configuration identity the bytes that it covers (#838). ActivationRecord::encode writes the canonical encoding, and ActivationRecord::decode reads it back: the record's version, a little-endian u16, then a tagged, length-prefixed field for each of the record's parts, in the increasing order of their tags, each at most once, without padding. Choices are one-byte codes from 1, integers, the store generation among them, are little-endian, and identities, IDs, and addresses are their bytes. The largest record takes 602 of the 1,024 bytes that MAX_RECORD_BYTES allows. The module's documentation lists every field. The decoder refuses bytes past MAX_RECORD_BYTES, bytes that end inside the version or a field, other versions, unknown, repeated, and misordered fields, values of another length or with codes that the version does not define, a mapping count or a store generation of 0, missing fields, a DNS server or a proxy without a network, and every configuration that Configuration::check refuses, in a documented order, naming the Field at fault, so that every record has exactly one encoding. The tests build their bytes from the definition, not with the encoder, and property tests check that generated records round-trip and that the decoder accepts only the canonical encoding of what it returns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f7bd07b-b09f-48d5-bc49-ad06f47ef3f9 --- doc/project-structure.md | 2 +- nvx-rs/README.md | 15 +- nvx-rs/src/activation/codec.rs | 578 ++++++++++++++++ nvx-rs/src/activation/codec/tests.rs | 983 +++++++++++++++++++++++++++ nvx-rs/src/activation/mod.rs | 122 +++- nvx-rs/src/activation/tests.rs | 6 +- 6 files changed, 1687 insertions(+), 19 deletions(-) create mode 100644 nvx-rs/src/activation/codec.rs create mode 100644 nvx-rs/src/activation/codec/tests.rs diff --git a/doc/project-structure.md b/doc/project-structure.md index 1758b2310..b78b013e8 100644 --- a/doc/project-structure.md +++ b/doc/project-structure.md @@ -92,7 +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 +| | |-- activation/ The activation record of a layered root, its canonical encoding, 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 8c141704d..1bbaa3391 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -30,8 +30,9 @@ 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 +│ ├── activation/ the activation record of a layered root, its canonical encoding, 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 @@ -76,7 +77,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::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. `ActivationRecord::encode` writes a record's canonical encoding, its version, then a tagged, length-prefixed field for each part in the order of their tags, 602 bytes at most, and `ActivationRecord::decode` reads exactly those bytes back and refuses any others, naming the `Field` at fault. `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 | @@ -1114,9 +1115,11 @@ sides of every record, frame, and payload: the codec decodes each vector of `tests/vectors/nvxc-v1.txt` to its values and encodes them to its bytes, it accepts or refuses OpenVMM's vectors and cases of the `NVXS` session as they say, and, on Linux, the guest's listener announcements match -`tests/vectors/nvxl-v1.txt`. Property tests check that records round-trip, and -that the `NVXS` record parser and the host's and the guest's `EXEC` and `MAPS` -decoders return, never panic, on arbitrary bytes; those of the egress policy +`tests/vectors/nvxl-v1.txt`. Property tests check that records and activation +records round-trip, that the activation record decoder accepts only canonical +encodings, and that the `NVXS` record parser, the activation record decoder, +and the host's and the guest's `EXEC` and `MAPS` decoders return, never panic, +on arbitrary bytes; those of the egress policy compiler compare it with a reference model of the format ([Egress policy files](#egress-policy-files)). The NVXS test drives the host's and the guest's roles of the session and their connections through a diff --git a/nvx-rs/src/activation/codec.rs b/nvx-rs/src/activation/codec.rs new file mode 100644 index 000000000..91e6dc4e7 --- /dev/null +++ b/nvx-rs/src/activation/codec.rs @@ -0,0 +1,578 @@ +//! The canonical encoding of an activation record, which the [module](super#encoding) describes: +//! [`ActivationRecord::encode`] writes it, and [`ActivationRecord::decode`] reads it back. + +use std::fmt; +use std::net::IpAddr; +use std::num::NonZeroU64; + +use super::{ + ActivationError, ActivationRecord, Configuration, ExpectedResources, Export, ExportKind, + Identity, Image, Journal, MAX_RECORD_BYTES, MachineIdMode, Network, OverlayMode, Publication, + RestoreSection, StoreGeneration, TemplateReference, TransactionId, Upper, VERSION, + WorkloadIdentity, WorkloadView, +}; +use crate::protocol::root::ImageFormat; +use crate::uuid::Uuid; + +#[cfg(test)] +mod tests; + +/// The bytes of a record's version. +const VERSION_LEN: usize = 2; +/// The bytes of a field's header: its tag, then the length of its value. +const FIELD_HEADER_LEN: usize = 2; +/// The code of an upper that is a tmpfs. +const TMPFS: u8 = 1; +/// The code of an upper that is the scratch. +const SCRATCH: u8 = 2; + +/// A field of a record's encoding, which a refusal names: the fields in the order of their tags, +/// as the [module](super#encoding) lists them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Field { + /// `0x01`: the restore section of a clone. + Restore, + /// `0x10`: the image's format, the identity of its binding, and a GPT disk's first-partition + /// bound. + Image, + /// `0x11`: the UUID or the disk GUID that the guest expects of the image. + ImageIdentity, + /// `0x20`: the upper, and a scratch's binding and journal policy. + Upper, + /// `0x30`: the overlay's mode. + Overlay, + /// `0x40`: the export's shape, and the identity of its plan. + Export, + /// `0x41`: the number of host mappings. + Mappings, + /// `0x50`: the identity of the network's policy, and the publication of the listeners. + Network, + /// `0x51`: the address of the network's DNS server. + Dns, + /// `0x52`: the network's proxy endpoint. + Proxy, + /// `0x60`: the hostname. + Hostname, + /// `0x61`: the machine ID's mode. + MachineId, + /// `0x62`: the workload identity. + WorkloadIdentity, + /// `0x70`: the expected resources. + Resources, +} + +impl Field { + /// Every field, in the order of their tags. + pub const ALL: [Self; 14] = [ + Self::Restore, + Self::Image, + Self::ImageIdentity, + Self::Upper, + Self::Overlay, + Self::Export, + Self::Mappings, + Self::Network, + Self::Dns, + Self::Proxy, + Self::Hostname, + Self::MachineId, + Self::WorkloadIdentity, + Self::Resources, + ]; + + /// Returns the field's tag. + pub const fn tag(self) -> u8 { + match self { + Self::Restore => 0x01, + Self::Image => 0x10, + Self::ImageIdentity => 0x11, + Self::Upper => 0x20, + Self::Overlay => 0x30, + Self::Export => 0x40, + Self::Mappings => 0x41, + Self::Network => 0x50, + Self::Dns => 0x51, + Self::Proxy => 0x52, + Self::Hostname => 0x60, + Self::MachineId => 0x61, + Self::WorkloadIdentity => 0x62, + Self::Resources => 0x70, + } + } + + /// Returns the field whose tag is `tag`, if any. + pub fn from_tag(tag: u8) -> Option { + Self::ALL.into_iter().find(|field| field.tag() == tag) + } +} + +impl fmt::Display for Field { + /// Writes the field's name, as a refusal names it. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Restore => "restore", + Self::Image => "image", + Self::ImageIdentity => "image identity", + Self::Upper => "upper", + Self::Overlay => "overlay", + Self::Export => "export", + Self::Mappings => "mappings", + Self::Network => "network", + Self::Dns => "DNS", + Self::Proxy => "proxy", + Self::Hostname => "hostname", + Self::MachineId => "machine ID", + Self::WorkloadIdentity => "workload identity", + Self::Resources => "resources", + }) + } +} + +/// A choice that a field names with a one-byte code: a field's choices have the codes 1, 2, and +/// so on, as the [module](super#encoding) lists them, so that a zero byte names none. +trait Code: Sized { + /// Returns the choice's code. + fn code(self) -> u8; + /// Returns the choice whose code is `code`, if any. + fn from_code(code: u8) -> Option; +} + +/// Gives each choice of an enum its code, both ways, with matches that name every choice. +macro_rules! codes { + ($type:ty { $($choice:path => $code:literal),+ $(,)? }) => { + impl Code for $type { + fn code(self) -> u8 { + match self { + $($choice => $code,)+ + } + } + + fn from_code(code: u8) -> Option { + match code { + $($code => Some($choice),)+ + _ => None, + } + } + } + }; +} + +codes!(ImageFormat { + ImageFormat::Erofs => 1, + ImageFormat::Gpt => 2, +}); +codes!(Journal { + Journal::Allowed => 1, + Journal::Forbidden => 2, +}); +codes!(OverlayMode { + OverlayMode::TrustedLowers => 1, + OverlayMode::UntrustedLowers => 2, +}); +codes!(ExportKind { + ExportKind::Directory => 1, + ExportKind::Aggregate => 2, +}); +codes!(Publication { + Publication::Published => 1, + Publication::Unpublished => 2, +}); +codes!(MachineIdMode { + MachineIdMode::Image => 1, + MachineIdMode::Generated => 2, +}); + +impl ActivationRecord { + /// Returns the record's canonical encoding, as the [module](super#encoding) describes it: at + /// most [`MAX_RECORD_BYTES`] bytes, which [`decode`](Self::decode) reads back to this record. + /// + /// It never fails: a record keeps every limit, as [`ActivationRecord::new`] and + /// [`decode`](Self::decode) check them, so the encoder never writes what the decoder refuses. + pub fn encode(&self) -> Vec { + let configuration = &self.configuration; + let mut encoder = Encoder::new(); + if let Some(RestoreSection { + transaction, + template, + }) = &self.restore + { + encoder.field( + Field::Restore, + &[ + transaction.as_bytes(), + &template.generation.get().get().to_le_bytes(), + template.key.as_bytes(), + ], + ); + } + let image = &configuration.image; + let format = [image.format().code()]; + match image { + Image::Erofs { binding, .. } => { + encoder.field(Field::Image, &[&format, binding.as_bytes()]); + } + Image::Gpt { + binding, + first_partition_bound, + .. + } => encoder.field( + Field::Image, + &[ + &format, + binding.as_bytes(), + &first_partition_bound.to_le_bytes(), + ], + ), + } + if let Some(expected) = image.expected() { + encoder.field(Field::ImageIdentity, &[expected.as_bytes()]); + } + match configuration.upper { + Upper::Tmpfs => encoder.field(Field::Upper, &[&[TMPFS]]), + Upper::Scratch { binding, journal } => encoder.field( + Field::Upper, + &[&[SCRATCH], binding.as_bytes(), &[journal.code()]], + ), + } + encoder.field(Field::Overlay, &[&[configuration.overlay.code()]]); + if let Some(Export { kind, plan }) = configuration.export { + encoder.field(Field::Export, &[&[kind.code()], plan.as_bytes()]); + // A record with an export announces 1 to `MAX_MAPPINGS` mappings, which a `u16` holds. + let mappings = configuration.mappings as u16; + encoder.field(Field::Mappings, &[&mappings.to_le_bytes()]); + } + if let Some(network) = &configuration.network { + encoder.field( + Field::Network, + &[network.policy.as_bytes(), &[network.publication.code()]], + ); + match network.dns { + Some(IpAddr::V4(address)) => encoder.field(Field::Dns, &[&address.octets()]), + Some(IpAddr::V6(address)) => encoder.field(Field::Dns, &[&address.octets()]), + None => {} + } + if let Some(proxy) = &network.proxy { + encoder.field(Field::Proxy, &[proxy.as_bytes()]); + } + } + let workload = &configuration.workload; + encoder.field(Field::Hostname, &[&workload.hostname]); + encoder.field(Field::MachineId, &[&[workload.machine_id.code()]]); + if let Some(WorkloadIdentity { uid, gid }) = workload.identity { + encoder.field( + Field::WorkloadIdentity, + &[&uid.to_le_bytes(), &gid.to_le_bytes()], + ); + } + let ExpectedResources { cpus, memory } = configuration.resources; + encoder.field( + Field::Resources, + &[&cpus.to_le_bytes(), &memory.to_le_bytes()], + ); + encoder.bytes + } + + /// Reads a record from its canonical encoding, as the [module](super#encoding) describes it. + /// + /// Fails with the first rule that the bytes break, in this order: bytes longer than + /// [`MAX_RECORD_BYTES`]; bytes that end inside the version; another version than [`VERSION`]; + /// then, field by field, a field whose tag and length end past the bytes, a field with an + /// unknown tag, a repeated field, a field out of order, a value that ends past the bytes, and a + /// value with another length than its contents take, with a code that the version does not + /// define, with a mapping count or a store generation of 0, or with a proxy endpoint that is + /// not UTF-8; then a missing field that every record holds, in the order of the tags; then a + /// DNS server or a proxy without a network; then the first limit that [`Configuration::check`] + /// finds broken, such as an export without mappings, or mappings without an export. So it reads + /// exactly the bytes that [`encode`](Self::encode) writes. + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_RECORD_BYTES { + return Err(ActivationError::TooLong(bytes.len())); + } + let (version, mut rest) = bytes + .split_first_chunk::() + .ok_or(ActivationError::Truncated)?; + let version = u16::from_le_bytes(*version); + if version != VERSION { + return Err(ActivationError::Version(version)); + } + let mut draft = Draft::default(); + let mut seen = Vec::with_capacity(Field::ALL.len()); + while !rest.is_empty() { + let (&[tag, length], tail) = rest + .split_first_chunk::() + .ok_or(ActivationError::Truncated)?; + let field = Field::from_tag(tag).ok_or(ActivationError::UnknownField(tag))?; + if seen.contains(&field) { + return Err(ActivationError::DuplicateField(field)); + } + if seen.last().is_some_and(|last: &Field| last.tag() > tag) { + return Err(ActivationError::FieldOrder(field)); + } + let (value, tail) = tail + .split_at_checked(usize::from(length)) + .ok_or(ActivationError::Truncated)?; + draft.read(field, value)?; + seen.push(field); + rest = tail; + } + draft.into_record() + } +} + +/// Writes a record's encoding: its version, then the fields that the caller writes in the order +/// of their tags. +struct Encoder { + /// The encoding so far. + bytes: Vec, +} + +impl Encoder { + /// Returns an encoder that has written the version. + fn new() -> Self { + let mut bytes = Vec::with_capacity(MAX_RECORD_BYTES); + bytes.extend_from_slice(&VERSION.to_le_bytes()); + Self { bytes } + } + + /// Writes `field`, whose value is `parts`, one after the other. + fn field(&mut self, field: Field, parts: &[&[u8]]) { + let length: usize = parts.iter().map(|part| part.len()).sum(); + debug_assert!( + length <= usize::from(u8::MAX), + "the {field} field is too long" + ); + self.bytes.push(field.tag()); + // A field's value holds at most `MAX_PROXY_ENDPOINT_BYTES`, the most that a byte counts. + self.bytes.push(length as u8); + for part in parts { + self.bytes.extend_from_slice(part); + } + } +} + +/// What the fields of an encoding have given so far, each once. +#[derive(Default)] +struct Draft { + /// The restore section. + restore: Option, + /// The image, without what the guest expects of it. + image: Option, + /// What the guest expects of the image. + image_identity: Option, + /// The upper. + upper: Option, + /// The overlay's mode. + overlay: Option, + /// The export. + export: Option, + /// The number of mappings: 1 or more. + mappings: Option, + /// The identity of the network's policy, and the publication of the listeners. + network: Option<(Identity, Publication)>, + /// The address of the DNS server. + dns: Option, + /// The proxy endpoint. + proxy: Option, + /// The hostname. + hostname: Option>, + /// The machine ID's mode. + machine_id: Option, + /// The workload identity. + identity: Option, + /// The expected resources. + resources: Option, +} + +impl Draft { + /// Reads `field`'s value, `bytes`. + fn read(&mut self, field: Field, bytes: &[u8]) -> Result<(), ActivationError> { + let mut value = Value::new(field, bytes); + match field { + Field::Restore => { + let transaction = TransactionId::from_bytes(value.take()?); + // A store assigns no template the generation 0. + let generation = NonZeroU64::new(u64::from_le_bytes(value.take()?)) + .map(StoreGeneration::new) + .ok_or(ActivationError::FieldValue(field))?; + let key = Identity::from_bytes(value.take()?); + self.restore = Some(RestoreSection { + transaction, + template: TemplateReference { generation, key }, + }); + } + Field::Image => { + let format = value.code::()?; + let binding = Identity::from_bytes(value.take()?); + self.image = Some(match format { + ImageFormat::Erofs => Image::Erofs { + binding, + uuid: None, + }, + ImageFormat::Gpt => Image::Gpt { + binding, + disk_guid: None, + first_partition_bound: u64::from_le_bytes(value.take()?), + }, + }); + } + Field::ImageIdentity => self.image_identity = Some(Uuid::from_bytes(value.take()?)), + Field::Upper => { + self.upper = Some(match value.take::<1>()? { + [TMPFS] => Upper::Tmpfs, + [SCRATCH] => Upper::Scratch { + binding: Identity::from_bytes(value.take()?), + journal: value.code()?, + }, + _ => return Err(ActivationError::FieldValue(field)), + }); + } + Field::Overlay => self.overlay = Some(value.code()?), + Field::Export => { + let kind = value.code()?; + let plan = Identity::from_bytes(value.take()?); + self.export = Some(Export { kind, plan }); + } + Field::Mappings => match u16::from_le_bytes(value.take()?) { + // A record without mappings leaves the field out. + 0 => return Err(ActivationError::FieldValue(field)), + mappings => self.mappings = Some(usize::from(mappings)), + }, + Field::Network => { + let policy = Identity::from_bytes(value.take()?); + self.network = Some((policy, value.code()?)); + } + Field::Dns => { + // A DNS server's address is IPv4 or IPv6, which its length tells apart. + self.dns = Some(if bytes.len() == 4 { + IpAddr::from(value.take::<4>()?) + } else { + IpAddr::from(value.take::<16>()?) + }); + } + Field::Proxy => { + let proxy = String::from_utf8(value.rest().to_vec()) + .map_err(|_| ActivationError::ProxyEndpoint)?; + self.proxy = Some(proxy); + } + Field::Hostname => self.hostname = Some(value.rest().to_vec()), + Field::MachineId => self.machine_id = Some(value.code()?), + Field::WorkloadIdentity => { + let uid = u32::from_le_bytes(value.take()?); + let gid = u32::from_le_bytes(value.take()?); + self.identity = Some(WorkloadIdentity { uid, gid }); + } + Field::Resources => { + let cpus = u32::from_le_bytes(value.take()?); + let memory = u64::from_le_bytes(value.take()?); + self.resources = Some(ExpectedResources { cpus, memory }); + } + } + value.end() + } + + /// Returns the record of the fields: the fields that every record holds must be there, a DNS + /// server and a proxy only with a network, and the configuration must keep every limit. + fn into_record(self) -> Result { + let missing = ActivationError::MissingField; + let mut image = self.image.ok_or(missing(Field::Image))?; + let upper = self.upper.ok_or(missing(Field::Upper))?; + let overlay = self.overlay.ok_or(missing(Field::Overlay))?; + let hostname = self.hostname.ok_or(missing(Field::Hostname))?; + let machine_id = self.machine_id.ok_or(missing(Field::MachineId))?; + let resources = self.resources.ok_or(missing(Field::Resources))?; + if let Some(expected) = self.image_identity { + match &mut image { + Image::Erofs { uuid, .. } => *uuid = Some(expected), + Image::Gpt { disk_guid, .. } => *disk_guid = Some(expected), + } + } + let network = match self.network { + Some((policy, publication)) => Some(Network { + policy, + dns: self.dns, + publication, + proxy: self.proxy, + }), + None if self.dns.is_some() => { + return Err(ActivationError::WithoutNetwork(Field::Dns)); + } + None if self.proxy.is_some() => { + return Err(ActivationError::WithoutNetwork(Field::Proxy)); + } + None => None, + }; + let configuration = Configuration { + image, + upper, + overlay, + export: self.export, + mappings: self.mappings.unwrap_or(0), + network, + workload: WorkloadView { + hostname, + machine_id, + identity: self.identity, + }, + resources, + }; + ActivationRecord::new(self.restore, configuration) + } +} + +/// Reads the parts of a field's value in order, and refuses a value of another length than its +/// parts take. +struct Value<'a> { + /// The field. + field: Field, + /// The value's length. + length: usize, + /// What remains of the value. + rest: &'a [u8], +} + +impl<'a> Value<'a> { + /// Returns a reader of `field`'s value, `bytes`. + fn new(field: Field, bytes: &'a [u8]) -> Self { + Self { + field, + length: bytes.len(), + rest: bytes, + } + } + + /// Returns the refusal of a value of another length than its parts take. + fn length_error(&self) -> ActivationError { + ActivationError::FieldLength(self.field, self.length) + } + + /// Reads the next `N` bytes. + fn take(&mut self) -> Result<[u8; N], ActivationError> { + let (part, rest) = self + .rest + .split_first_chunk::() + .ok_or_else(|| self.length_error())?; + self.rest = rest; + Ok(*part) + } + + /// Reads the next byte, the code of a choice. + fn code(&mut self) -> Result { + let [code] = self.take()?; + T::from_code(code).ok_or(ActivationError::FieldValue(self.field)) + } + + /// Returns what remains of the value, which the caller reads whole. + fn rest(&mut self) -> &'a [u8] { + std::mem::take(&mut self.rest) + } + + /// Checks that nothing remains of the value. + fn end(self) -> Result<(), ActivationError> { + if self.rest.is_empty() { + Ok(()) + } else { + Err(self.length_error()) + } + } +} diff --git a/nvx-rs/src/activation/codec/tests.rs b/nvx-rs/src/activation/codec/tests.rs new file mode 100644 index 000000000..7c5700208 --- /dev/null +++ b/nvx-rs/src/activation/codec/tests.rs @@ -0,0 +1,983 @@ +//! Tests of the canonical encoding of an activation record. The tests build their encodings field +//! by field from the encoding's definition, never with the encoder that they check. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::num::NonZeroU64; + +use proptest::prelude::*; + +use super::*; +use crate::activation::tests::{configuration, maximal, restore}; +use crate::activation::{MAX_HOSTNAME_BYTES, MAX_MAPPINGS, MAX_PROXY_ENDPOINT_BYTES}; + +/// A field of an encoding, as the tests build it: its tag and its value. +type RawField = (u8, Vec); + +/// Returns the encoding of version `version` with `fields`, in the order given. +fn encoding_of(version: u16, fields: &[RawField]) -> Vec { + let mut bytes = version.to_le_bytes().to_vec(); + for (tag, value) in fields { + bytes.push(*tag); + bytes.push(u8::try_from(value.len()).unwrap()); + bytes.extend_from_slice(value); + } + bytes +} + +/// Returns the encoding of version 1 with `fields`, in the order given. +fn encoding(fields: &[RawField]) -> Vec { + encoding_of(1, fields) +} + +/// Returns the fields of the cold record of the tests' [`configuration`], from the encoding's +/// definition. +fn cold_fields() -> Vec { + vec![ + // An EROFS image, whose binding's identity is 0x11 bytes. + (0x10, [&[1][..], &[0x11; 32]].concat()), + // A tmpfs upper. + (0x20, vec![1]), + // Trusted lowers. + (0x30, vec![1]), + (0x60, b"nvx-sandbox".to_vec()), + // The image's machine ID. + (0x61, vec![1]), + // One CPU and 256 MiB. + (0x70, vec![1, 0, 0, 0, 0, 0, 0, 0x10, 0, 0, 0, 0]), + ] +} + +/// Returns `fields` with the field of `tag` set to `value`, in the order of the tags. +fn with(mut fields: Vec, tag: u8, value: &[u8]) -> Vec { + fields.retain(|(other, _)| *other != tag); + let index = fields + .iter() + .position(|(other, _)| *other > tag) + .unwrap_or(fields.len()); + fields.insert(index, (tag, value.to_vec())); + fields +} + +/// Returns `fields` without the field of `tag`. +fn without(mut fields: Vec, tag: u8) -> Vec { + fields.retain(|(other, _)| *other != tag); + fields +} + +/// Returns the fields of the cold record with an aggregate export, whose plan's identity is 0x55 +/// bytes, and with the mapping count `count`. +fn with_export(count: [u8; 2]) -> Vec { + let fields = with(cold_fields(), 0x40, &[&[2][..], &[0x55; 32]].concat()); + with(fields, 0x41, &count) +} + +/// Returns the fields of the cold record with a network, whose policy's identity is 0x66 bytes, +/// and whose listeners are not published. +fn with_network() -> Vec { + with(cold_fields(), 0x50, &[&[0x66; 32][..], &[2]].concat()) +} + +/// Returns what the decoder makes of `fields`. +fn decode(fields: &[RawField]) -> Result { + ActivationRecord::decode(&encoding(fields)) +} + +/// Returns the fields of an encoding, as tag and value, and checks the encoding's version, and +/// that its fields hold nothing past their ends and come in the increasing order of their tags. +fn fields_of(bytes: &[u8]) -> Vec { + assert_eq!(bytes[..2], [1, 0], "the version"); + let mut fields: Vec = Vec::new(); + let mut rest = &bytes[2..]; + while let [tag, length, tail @ ..] = rest { + let (value, tail) = tail.split_at(usize::from(*length)); + assert!( + fields.last().is_none_or(|(last, _)| last < tag), + "{tag:#04x}" + ); + fields.push((*tag, value.to_vec())); + rest = tail; + } + assert!(rest.is_empty()); + fields +} + +#[test] +fn the_fields_keep_their_tags_and_names() { + let expected = [ + (Field::Restore, 0x01, "restore"), + (Field::Image, 0x10, "image"), + (Field::ImageIdentity, 0x11, "image identity"), + (Field::Upper, 0x20, "upper"), + (Field::Overlay, 0x30, "overlay"), + (Field::Export, 0x40, "export"), + (Field::Mappings, 0x41, "mappings"), + (Field::Network, 0x50, "network"), + (Field::Dns, 0x51, "DNS"), + (Field::Proxy, 0x52, "proxy"), + (Field::Hostname, 0x60, "hostname"), + (Field::MachineId, 0x61, "machine ID"), + (Field::WorkloadIdentity, 0x62, "workload identity"), + (Field::Resources, 0x70, "resources"), + ]; + assert_eq!(Field::ALL.len(), expected.len()); + for (field, (expected, tag, name)) in Field::ALL.into_iter().zip(expected) { + assert_eq!(field, expected); + assert_eq!(field.tag(), tag); + assert_eq!(field.to_string(), name); + assert_eq!(Field::from_tag(tag), Some(field)); + } + for tag in u8::MIN..=u8::MAX { + if !Field::ALL.iter().any(|field| field.tag() == tag) { + assert_eq!(Field::from_tag(tag), None, "{tag:#04x}"); + } + } +} + +#[test] +fn the_codes_of_the_choices_keep_their_values() { + /// Checks that `choices` have the codes 1, 2, and so on, and that no other code names one. + fn check(choices: &[T]) { + for (index, choice) in choices.iter().enumerate() { + let code = u8::try_from(index + 1).unwrap(); + assert_eq!(choice.code(), code, "{choice:?}"); + assert_eq!(T::from_code(code), Some(*choice)); + } + for code in [0, u8::try_from(choices.len() + 1).unwrap(), u8::MAX] { + assert_eq!(T::from_code(code), None, "{code}"); + } + } + check(&[ImageFormat::Erofs, ImageFormat::Gpt]); + check(&[Journal::Allowed, Journal::Forbidden]); + check(&[OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers]); + check(&[ExportKind::Directory, ExportKind::Aggregate]); + check(&[Publication::Published, Publication::Unpublished]); + check(&[MachineIdMode::Image, MachineIdMode::Generated]); + assert_eq!((TMPFS, SCRATCH), (1, 2)); +} + +#[test] +fn a_cold_record_encodes_to_the_bytes_of_its_definition() { + let record = ActivationRecord::new(None, configuration()).unwrap(); + let bytes = encoding(&cold_fields()); + assert_eq!(record.encode(), bytes); + assert_eq!(ActivationRecord::decode(&bytes), Ok(record)); +} + +#[test] +fn a_clone_record_holds_its_restore_section_first() { + let record = ActivationRecord::new(Some(restore()), configuration()).unwrap(); + // The transaction ID, the store generation, and the identity of the template's key. + let section = [&[0x22; 16][..], &[0x33; 8], &[0x44; 32]].concat(); + let bytes = encoding(&with(cold_fields(), 0x01, §ion)); + assert_eq!(record.encode(), bytes); + assert_eq!(ActivationRecord::decode(&bytes), Ok(record)); +} + +#[test] +fn every_part_of_a_configuration_encodes_to_the_bytes_of_its_definition() { + let mut configuration = configuration(); + configuration.image = Image::Gpt { + binding: Identity::from_bytes([0x77; 32]), + disk_guid: Some("fedcba98-7654-3210-fedc-ba9876543210".parse().unwrap()), + first_partition_bound: 64 << 10, + }; + configuration.upper = Upper::Scratch { + binding: Identity::from_bytes([0x88; 32]), + journal: Journal::Forbidden, + }; + configuration.overlay = OverlayMode::UntrustedLowers; + configuration.export = Some(Export { + kind: ExportKind::Directory, + plan: Identity::from_bytes([0x99; 32]), + }); + configuration.mappings = 0x0102; + configuration.network = Some(Network { + policy: Identity::from_bytes([0xaa; 32]), + dns: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), + publication: Publication::Published, + proxy: Some("http://10.0.0.1:3128".to_owned()), + }); + configuration.workload = WorkloadView { + hostname: b"clone".to_vec(), + machine_id: MachineIdMode::Generated, + identity: Some(WorkloadIdentity { + uid: 1000, + gid: 0x0102_0304, + }), + }; + configuration.resources = ExpectedResources { + cpus: 4, + memory: 0x0102_0304_0506_0708, + }; + let fields = vec![ + ( + 0x10, + [&[2][..], &[0x77; 32], &[0, 0, 1, 0, 0, 0, 0, 0]].concat(), + ), + ( + 0x11, + vec![ + 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, + 0x32, 0x10, + ], + ), + (0x20, [&[2][..], &[0x88; 32], &[2]].concat()), + (0x30, vec![2]), + (0x40, [&[1][..], &[0x99; 32]].concat()), + (0x41, vec![2, 1]), + (0x50, [&[0xaa; 32][..], &[1]].concat()), + (0x51, vec![10, 0, 0, 1]), + (0x52, b"http://10.0.0.1:3128".to_vec()), + (0x60, b"clone".to_vec()), + (0x61, vec![2]), + (0x62, vec![0xe8, 3, 0, 0, 4, 3, 2, 1]), + (0x70, vec![4, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1]), + ]; + let record = ActivationRecord::new(None, configuration.clone()).unwrap(); + assert_eq!(record.encode(), encoding(&fields)); + assert_eq!(decode(&fields), Ok(record)); + // An IPv6 DNS server takes 16 bytes. + let address = Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0xa00, 1); + configuration.network.as_mut().unwrap().dns = Some(IpAddr::V6(address)); + let fields = with(fields, 0x51, &address.octets()); + let record = ActivationRecord::new(None, configuration).unwrap(); + assert_eq!(record.encode(), encoding(&fields)); + assert_eq!(decode(&fields), Ok(record)); +} + +#[test] +fn a_maximal_record_holds_602_bytes_within_the_limit() { + let record = ActivationRecord::new(Some(restore()), maximal()).unwrap(); + let bytes = record.encode(); + assert_eq!(bytes.len(), 602); + assert!(bytes.len() <= MAX_RECORD_BYTES); + assert_eq!( + fields_of(&bytes) + .iter() + .map(|(tag, _)| *tag) + .collect::>(), + Field::ALL.map(Field::tag) + ); + assert_eq!(ActivationRecord::decode(&bytes), Ok(record)); +} + +#[test] +fn every_prefix_of_an_encoding_is_refused() { + for record in [ + ActivationRecord::new(None, configuration()).unwrap(), + ActivationRecord::new(Some(restore()), maximal()).unwrap(), + ] { + let bytes = record.encode(); + for length in 0..bytes.len() { + let error = ActivationRecord::decode(&bytes[..length]).unwrap_err(); + assert!( + matches!( + error, + ActivationError::Truncated | ActivationError::MissingField(_) + ), + "{length}: {error:?}" + ); + } + } + assert_eq!( + ActivationRecord::decode(&[]), + Err(ActivationError::Truncated) + ); + assert_eq!( + ActivationRecord::decode(&[1]), + Err(ActivationError::Truncated) + ); + assert_eq!( + ActivationRecord::decode(&[1, 0]), + Err(ActivationError::MissingField(Field::Image)) + ); +} + +#[test] +fn bytes_past_the_limit_are_refused_before_anything_else() { + let mut bytes = encoding(&cold_fields()); + bytes.resize(MAX_RECORD_BYTES + 1, 0); + assert_eq!( + ActivationRecord::decode(&bytes), + Err(ActivationError::TooLong(MAX_RECORD_BYTES + 1)) + ); + let mut bytes = vec![0xff; MAX_RECORD_BYTES * 2]; + assert_eq!( + ActivationRecord::decode(&bytes), + Err(ActivationError::TooLong(MAX_RECORD_BYTES * 2)) + ); + // At the limit, the bytes are read: these hold another version. + bytes.truncate(MAX_RECORD_BYTES); + assert_eq!( + ActivationRecord::decode(&bytes), + Err(ActivationError::Version(0xffff)) + ); +} + +#[test] +fn other_versions_are_refused() { + for version in [0, 2, 0x0100, u16::MAX] { + assert_eq!( + ActivationRecord::decode(&encoding_of(version, &cold_fields())), + Err(ActivationError::Version(version)), + "{version}" + ); + } +} + +#[test] +fn unknown_fields_are_refused() { + for tag in u8::MIN..=u8::MAX { + if Field::from_tag(tag).is_some() { + continue; + } + assert_eq!( + decode(&with(cold_fields(), tag, &[1])), + Err(ActivationError::UnknownField(tag)), + "{tag:#04x}" + ); + } +} + +#[test] +fn repeated_fields_are_refused() { + for (index, (tag, value)) in cold_fields().into_iter().enumerate() { + let field = Field::from_tag(tag).unwrap(); + let mut fields = cold_fields(); + fields.insert(index + 1, (tag, value.clone())); + assert_eq!( + decode(&fields), + Err(ActivationError::DuplicateField(field)), + "{field}" + ); + // A repetition after other fields is a repetition too. + let mut fields = cold_fields(); + fields.push((tag, value)); + assert_eq!( + decode(&fields), + Err(ActivationError::DuplicateField(field)), + "{field}" + ); + } +} + +#[test] +fn fields_out_of_order_are_refused() { + let fields = cold_fields(); + for index in 1..fields.len() { + let mut swapped = fields.clone(); + swapped.swap(index - 1, index); + let field = Field::from_tag(swapped[index].0).unwrap(); + assert_eq!( + decode(&swapped), + Err(ActivationError::FieldOrder(field)), + "{field}" + ); + } + // The restore section comes first. + let mut fields = cold_fields(); + fields.push((0x01, [&[0x22; 16][..], &[0x33; 8], &[0x44; 32]].concat())); + assert_eq!( + decode(&fields), + Err(ActivationError::FieldOrder(Field::Restore)) + ); +} + +#[test] +fn a_dns_server_or_a_proxy_without_a_network_is_refused() { + let dns = with(cold_fields(), 0x51, &[10, 0, 0, 1]); + assert_eq!( + decode(&dns), + Err(ActivationError::WithoutNetwork(Field::Dns)) + ); + let proxy = with(cold_fields(), 0x52, b"http://10.0.0.1:3128"); + assert_eq!( + decode(&proxy), + Err(ActivationError::WithoutNetwork(Field::Proxy)) + ); + // With the network, both are read. + let fields = with( + with(with_network(), 0x51, &[10, 0, 0, 1]), + 0x52, + b"10.0.0.1:3128", + ); + let network = decode(&fields).unwrap().configuration().network.clone(); + assert_eq!( + network, + Some(Network { + policy: Identity::from_bytes([0x66; 32]), + dns: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), + publication: Publication::Unpublished, + proxy: Some("10.0.0.1:3128".to_owned()), + }) + ); +} + +#[test] +fn values_of_another_length_are_refused() { + let identity = [0x12; 32]; + let cases: Vec<(Field, Vec, usize)> = vec![ + (Field::Restore, with(cold_fields(), 0x01, &[1; 55]), 55), + (Field::Restore, with(cold_fields(), 0x01, &[1; 57]), 57), + (Field::Image, with(cold_fields(), 0x10, &[]), 0), + ( + Field::Image, + with(cold_fields(), 0x10, &[&[1][..], &[0; 31]].concat()), + 32, + ), + ( + Field::Image, + with(cold_fields(), 0x10, &[&[1][..], &[0; 33]].concat()), + 34, + ), + ( + Field::Image, + with(cold_fields(), 0x10, &[&[2][..], &identity].concat()), + 33, + ), + ( + Field::Image, + with( + cold_fields(), + 0x10, + &[&[2][..], &identity, &[1; 9]].concat(), + ), + 42, + ), + ( + Field::ImageIdentity, + with(cold_fields(), 0x11, &[0; 15]), + 15, + ), + ( + Field::ImageIdentity, + with(cold_fields(), 0x11, &[0; 17]), + 17, + ), + (Field::Upper, with(cold_fields(), 0x20, &[]), 0), + (Field::Upper, with(cold_fields(), 0x20, &[1, 1]), 2), + (Field::Upper, with(cold_fields(), 0x20, &[2]), 1), + ( + Field::Upper, + with(cold_fields(), 0x20, &[&[2][..], &identity].concat()), + 33, + ), + ( + Field::Upper, + with( + cold_fields(), + 0x20, + &[&[2][..], &identity, &[1, 1]].concat(), + ), + 35, + ), + (Field::Overlay, with(cold_fields(), 0x30, &[]), 0), + (Field::Overlay, with(cold_fields(), 0x30, &[1, 1]), 2), + ( + Field::Export, + with(with_export([1, 0]), 0x40, &[&[1][..], &[0; 31]].concat()), + 32, + ), + ( + Field::Export, + with(with_export([1, 0]), 0x40, &[&[1][..], &[0; 33]].concat()), + 34, + ), + (Field::Mappings, with(with_export([1, 0]), 0x41, &[1]), 1), + ( + Field::Mappings, + with(with_export([1, 0]), 0x41, &[1, 0, 0]), + 3, + ), + (Field::Network, with(cold_fields(), 0x50, &[0; 32]), 32), + ( + Field::Network, + with(cold_fields(), 0x50, &[&[0; 32][..], &[1, 1]].concat()), + 34, + ), + (Field::Dns, with(with_network(), 0x51, &[]), 0), + (Field::Dns, with(with_network(), 0x51, &[10, 0, 0]), 3), + (Field::Dns, with(with_network(), 0x51, &[10, 0, 0, 1, 0]), 5), + (Field::Dns, with(with_network(), 0x51, &[0; 15]), 15), + (Field::Dns, with(with_network(), 0x51, &[0; 17]), 17), + (Field::MachineId, with(cold_fields(), 0x61, &[]), 0), + (Field::MachineId, with(cold_fields(), 0x61, &[1, 1]), 2), + ( + Field::WorkloadIdentity, + with(cold_fields(), 0x62, &[1; 7]), + 7, + ), + ( + Field::WorkloadIdentity, + with(cold_fields(), 0x62, &[1; 9]), + 9, + ), + (Field::Resources, with(cold_fields(), 0x70, &[1; 11]), 11), + (Field::Resources, with(cold_fields(), 0x70, &[1; 13]), 13), + ]; + for (field, fields, length) in cases { + assert_eq!( + decode(&fields), + Err(ActivationError::FieldLength(field, length)), + "{field} of {length} bytes" + ); + } +} + +#[test] +fn unknown_codes_are_refused() { + let identity = [0x12; 32]; + for code in [0, 3, u8::MAX] { + let cases = [ + ( + Field::Image, + with(cold_fields(), 0x10, &[&[code][..], &identity].concat()), + ), + (Field::Upper, with(cold_fields(), 0x20, &[code])), + ( + Field::Upper, + with( + cold_fields(), + 0x20, + &[&[2][..], &identity, &[code]].concat(), + ), + ), + (Field::Overlay, with(cold_fields(), 0x30, &[code])), + ( + Field::Export, + with( + with_export([1, 0]), + 0x40, + &[&[code][..], &identity].concat(), + ), + ), + ( + Field::Network, + with(cold_fields(), 0x50, &[&identity[..], &[code]].concat()), + ), + (Field::MachineId, with(cold_fields(), 0x61, &[code])), + ]; + for (field, fields) in cases { + assert_eq!( + decode(&fields), + Err(ActivationError::FieldValue(field)), + "{field} of code {code}" + ); + } + } +} + +#[test] +fn a_mapping_count_of_zero_is_refused() { + assert_eq!( + decode(&with_export([0, 0])), + Err(ActivationError::FieldValue(Field::Mappings)) + ); + assert_eq!( + decode(&with(cold_fields(), 0x41, &[0, 0])), + Err(ActivationError::FieldValue(Field::Mappings)) + ); +} + +#[test] +fn a_store_generation_of_0_is_refused() { + let section = [&[0x22; 16][..], &[0; 8], &[0x44; 32]].concat(); + assert_eq!( + decode(&with(cold_fields(), 0x01, §ion)), + Err(ActivationError::FieldValue(Field::Restore)) + ); + // A store's first generation, 1, is read. + let section = [&[0x22; 16][..], &[1, 0, 0, 0, 0, 0, 0, 0], &[0x44; 32]].concat(); + let record = decode(&with(cold_fields(), 0x01, §ion)).unwrap(); + assert_eq!( + record.restore().map(|restore| restore.template.generation), + Some(StoreGeneration::new(NonZeroU64::MIN)) + ); +} + +#[test] +fn an_export_without_mappings_and_mappings_without_an_export_are_refused() { + let export = without(with_export([1, 0]), 0x41); + assert_eq!(decode(&export), Err(ActivationError::ExportWithoutMappings)); + let mappings = without(with_export([1, 0]), 0x40); + assert_eq!( + decode(&mappings), + Err(ActivationError::MappingsWithoutExport) + ); + for (count, mappings) in [([1, 0], 1), ([0, 0x10], MAX_MAPPINGS)] { + let record = decode(&with_export(count)).unwrap(); + assert_eq!(record.configuration().mappings, mappings); + } +} + +#[test] +fn values_past_a_limit_are_refused() { + assert_eq!( + decode(&with_export([1, 0x10])), + Err(ActivationError::TooManyMappings(MAX_MAPPINGS + 1)) + ); + assert_eq!( + decode(&with_export([0xff, 0xff])), + Err(ActivationError::TooManyMappings(usize::from(u16::MAX))) + ); + let hostname = vec![b'a'; MAX_HOSTNAME_BYTES + 1]; + assert_eq!( + decode(&with(cold_fields(), 0x60, &hostname)), + Err(ActivationError::Hostname) + ); + let hostname = vec![b'a'; MAX_HOSTNAME_BYTES]; + decode(&with(cold_fields(), 0x60, &hostname)).unwrap(); + let proxy = vec![b'~'; MAX_PROXY_ENDPOINT_BYTES]; + decode(&with(with_network(), 0x52, &proxy)).unwrap(); +} + +#[test] +fn values_that_a_configuration_refuses_are_refused() { + let identity = [0x12; 32]; + let cases = [ + ( + with( + cold_fields(), + 0x10, + &[&[2][..], &identity, &[0; 8]].concat(), + ), + ActivationError::FirstPartitionBound, + ), + ( + with(with_network(), 0x52, b""), + ActivationError::ProxyEndpoint, + ), + ( + with(with_network(), 0x52, b"http://user:secret@10.0.0.1:3128"), + ActivationError::ProxyEndpoint, + ), + ( + with(with_network(), 0x52, b"http://10.0.0.1:3128 "), + ActivationError::ProxyEndpoint, + ), + ( + with(with_network(), 0x52, b"http://proxy\xff:3128"), + ActivationError::ProxyEndpoint, + ), + ( + with(with_network(), 0x52, "http://\u{e1}:3128".as_bytes()), + ActivationError::ProxyEndpoint, + ), + (with(cold_fields(), 0x60, b""), ActivationError::Hostname), + ( + with(cold_fields(), 0x60, b"nvx\0sandbox"), + ActivationError::Hostname, + ), + ( + with(cold_fields(), 0x62, &[0, 0, 0, 0, 1, 0, 0, 0]), + ActivationError::WorkloadIdentity, + ), + ( + with(cold_fields(), 0x62, &[1, 0, 0, 0, 0, 0, 0, 0]), + ActivationError::WorkloadIdentity, + ), + ( + with(cold_fields(), 0x62, &[0xff, 0xff, 0xff, 0xff, 1, 0, 0, 0]), + ActivationError::WorkloadIdentity, + ), + ( + with(cold_fields(), 0x62, &[1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), + ActivationError::WorkloadIdentity, + ), + ( + with(cold_fields(), 0x70, &[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]), + ActivationError::Cpus, + ), + ( + with(cold_fields(), 0x70, &[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + ActivationError::Memory, + ), + ]; + for (fields, error) in cases { + assert_eq!(decode(&fields), Err(error.clone()), "{error}"); + } +} + +#[test] +fn missing_fields_are_refused() { + for tag in [0x10, 0x20, 0x30, 0x60, 0x61, 0x70] { + let field = Field::from_tag(tag).unwrap(); + assert_eq!( + decode(&without(cold_fields(), tag)), + Err(ActivationError::MissingField(field)), + "{field}" + ); + } +} + +#[test] +fn refusals_follow_the_order_of_their_rules() { + // A field's tag is read before its value's end. + let mut bytes = encoding(&cold_fields()); + bytes.extend_from_slice(&[0xff, 1]); + assert_eq!( + ActivationRecord::decode(&bytes), + Err(ActivationError::UnknownField(0xff)) + ); + // A field whose tag and length end past the bytes is truncated, whatever its tag. + bytes.truncate(bytes.len() - 1); + assert_eq!( + ActivationRecord::decode(&bytes), + Err(ActivationError::Truncated) + ); + // A known field whose value ends past the bytes is truncated. + let mut bytes = encoding(&without(cold_fields(), 0x70)); + bytes.extend_from_slice(&[0x70, 12, 1, 0, 0, 0]); + assert_eq!( + ActivationRecord::decode(&bytes), + Err(ActivationError::Truncated) + ); + // A field's own refusal comes before the record's missing fields. + let fields = with(without(cold_fields(), 0x10), 0x61, &[3]); + assert_eq!( + decode(&fields), + Err(ActivationError::FieldValue(Field::MachineId)) + ); + // Of the missing fields, the first in the order of the tags. + let fields = without(without(cold_fields(), 0x70), 0x20); + assert_eq!( + decode(&fields), + Err(ActivationError::MissingField(Field::Upper)) + ); + // A missing field comes before a DNS server without a network. + let fields = with(without(cold_fields(), 0x60), 0x51, &[10, 0, 0, 1]); + assert_eq!( + decode(&fields), + Err(ActivationError::MissingField(Field::Hostname)) + ); + // A DNS server without a network comes before a broken limit. + let fields = with(with(cold_fields(), 0x51, &[10, 0, 0, 1]), 0x70, &[0; 12]); + assert_eq!( + decode(&fields), + Err(ActivationError::WithoutNetwork(Field::Dns)) + ); +} + +#[test] +fn refusals_name_the_rule_that_they_break() { + for (error, message) in [ + ( + ActivationError::TooLong(1025), + "the record's 1025 bytes are more than the 1024 that a record holds", + ), + ( + ActivationError::Truncated, + "the record ends inside its version or one of its fields", + ), + ( + ActivationError::Version(2), + "the record's version is 2, not 1", + ), + ( + ActivationError::UnknownField(0x0f), + "the record holds a field of the unknown tag 0x0f", + ), + ( + ActivationError::DuplicateField(Field::Hostname), + "the record repeats its hostname field", + ), + ( + ActivationError::FieldOrder(Field::Upper), + "the record's upper field is out of order", + ), + ( + ActivationError::FieldLength(Field::Dns, 5), + "the record's DNS field holds 5 bytes, which its value never takes", + ), + ( + ActivationError::FieldValue(Field::MachineId), + "the record's machine ID field holds a value that no record takes", + ), + ( + ActivationError::MissingField(Field::Resources), + "the record has no resources field", + ), + ( + ActivationError::WithoutNetwork(Field::Proxy), + "the record has a proxy field but no network", + ), + ] { + assert_eq!(error.to_string(), message); + } +} + +/// Returns arbitrary identities. +fn identities() -> impl Strategy { + any::<[u8; 32]>().prop_map(Identity::from_bytes) +} + +/// Returns arbitrary UUIDs, or none. +fn uuids() -> impl Strategy> { + any::>().prop_map(|bytes| bytes.map(Uuid::from_bytes)) +} + +/// Returns arbitrary valid configurations. +fn configurations() -> impl Strategy { + let image = prop_oneof![ + (identities(), uuids()).prop_map(|(binding, uuid)| Image::Erofs { binding, uuid }), + (identities(), uuids(), 1..=u64::MAX).prop_map(|(binding, disk_guid, bound)| { + Image::Gpt { + binding, + disk_guid, + first_partition_bound: bound, + } + }), + ]; + let upper = prop_oneof![ + Just(Upper::Tmpfs), + ( + identities(), + prop_oneof![Just(Journal::Allowed), Just(Journal::Forbidden)] + ) + .prop_map(|(binding, journal)| Upper::Scratch { binding, journal }), + ]; + let overlay = prop_oneof![ + Just(OverlayMode::TrustedLowers), + Just(OverlayMode::UntrustedLowers) + ]; + let export = prop_oneof![ + Just((None::, 0_usize)), + ( + prop_oneof![Just(ExportKind::Directory), Just(ExportKind::Aggregate)], + identities(), + 1..=MAX_MAPPINGS, + ) + .prop_map(|(kind, plan, mappings)| (Some(Export { kind, plan }), mappings)), + ]; + let address = prop_oneof![ + any::<[u8; 4]>().prop_map(IpAddr::from), + any::<[u8; 16]>().prop_map(IpAddr::from), + ]; + // Visible ASCII characters other than `@`. + let proxy = prop::collection::vec( + prop_oneof![0x21_u8..=0x3f, 0x41_u8..=0x7e], + 1..=MAX_PROXY_ENDPOINT_BYTES, + ) + .prop_map(|bytes| String::from_utf8(bytes).unwrap()); + let network = prop::option::of( + ( + identities(), + prop::option::of(address), + prop_oneof![Just(Publication::Published), Just(Publication::Unpublished)], + prop::option::of(proxy), + ) + .prop_map(|(policy, dns, publication, proxy)| Network { + policy, + dns, + publication, + proxy, + }), + ); + let workload = ( + prop::collection::vec(1_u8..=u8::MAX, 1..=MAX_HOSTNAME_BYTES), + prop_oneof![Just(MachineIdMode::Image), Just(MachineIdMode::Generated)], + prop::option::of( + (1..u32::MAX, 1..u32::MAX).prop_map(|(uid, gid)| WorkloadIdentity { uid, gid }), + ), + ) + .prop_map(|(hostname, machine_id, identity)| WorkloadView { + hostname, + machine_id, + identity, + }); + let resources = + (1..=u32::MAX, 1..=u64::MAX).prop_map(|(cpus, memory)| ExpectedResources { cpus, memory }); + (image, upper, overlay, export, network, workload, resources).prop_map( + |(image, upper, overlay, (export, mappings), network, workload, resources)| Configuration { + image, + upper, + overlay, + export, + mappings, + network, + workload, + resources, + }, + ) +} + +/// Returns arbitrary valid records, of cold boots and of clones. +fn records() -> impl Strategy { + let restore = prop::option::of( + (any::<[u8; 16]>(), any::(), identities()).prop_map( + |(transaction, generation, key)| RestoreSection { + transaction: TransactionId::from_bytes(transaction), + template: TemplateReference { + generation: StoreGeneration::new(generation), + key, + }, + }, + ), + ); + (restore, configurations()) + .prop_map(|(restore, configuration)| ActivationRecord::new(restore, configuration).unwrap()) +} + +/// Returns bytes that a decoder may meet: arbitrary bytes, arbitrary fields after the version, +/// mostly of known tags, and valid encodings with bytes overwritten, removed, or repeated. +fn inputs() -> impl Strategy> { + let tag = prop_oneof![ + any::(), + prop::sample::select(Field::ALL.map(Field::tag).to_vec()), + ]; + prop_oneof![ + prop::collection::vec(any::(), 0..64), + prop::collection::vec((tag, prop::collection::vec(any::(), 0..48)), 0..16) + .prop_map(|fields| encoding(&fields)), + ( + records(), + prop::collection::vec((any::(), any::()), 1..4), + ) + .prop_map(|(record, writes)| { + let mut bytes = record.encode(); + for (index, byte) in writes { + let index = index.index(bytes.len()); + bytes[index] = byte; + } + bytes + }), + (records(), any::(), any::()).prop_map( + |(record, index, remove)| { + let mut bytes = record.encode(); + let index = index.index(bytes.len()); + if remove { + bytes.remove(index); + } else { + bytes.insert(index, bytes[index]); + } + bytes + } + ), + ] +} + +proptest! { + /// Every valid record encodes to at most `MAX_RECORD_BYTES` bytes, whose fields come in the + /// order of their tags, and which decode back to the record, while every prefix of them is + /// refused. + #[test] + fn records_round_trip_through_their_canonical_encoding( + record in records(), + cut in any::(), + ) { + let bytes = record.encode(); + prop_assert!(bytes.len() <= MAX_RECORD_BYTES); + fields_of(&bytes); + prop_assert_eq!(ActivationRecord::decode(&bytes), Ok(record)); + prop_assert!(ActivationRecord::decode(&bytes[..cut.index(bytes.len())]).is_err()); + } + + /// The decoder returns, never panics, on any bytes, and accepts only the one encoding of the + /// record that it returns. + #[test] + fn the_decoder_accepts_only_canonical_encodings(bytes in inputs()) { + if let Ok(record) = ActivationRecord::decode(&bytes) { + prop_assert_eq!(record.encode(), bytes); + } + } +} diff --git a/nvx-rs/src/activation/mod.rs b/nvx-rs/src/activation/mod.rs index 59e49c64f..dd25eaaa2 100644 --- a/nvx-rs/src/activation/mod.rs +++ b/nvx-rs/src/activation/mod.rs @@ -21,9 +21,10 @@ //! //! [`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. +//! [`ActivationRecord::encode`] writes a record's canonical [encoding](#encoding), and +//! [`ActivationRecord::decode`] reads it back, by the same rules. 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 //! @@ -61,11 +62,11 @@ //! //! # 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`]. +//! The limits are public constants: a record's encoding 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's encoding 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 @@ -73,6 +74,43 @@ //! characters or with an `@`, which would start credentials, and a zero CPU count, memory size, //! or bound of a GPT disk's first partition. //! +//! # Encoding +//! +//! A record's canonical encoding is the record's [`VERSION`], a little-endian `u16`, then its +//! fields, without padding, in the increasing order of their tags, each at most once. A field is +//! its tag, one byte, the number of bytes of its value, one byte, and its value: +//! +//! | Tag | [`Field`] | Value | Bytes | Present | +//! | --- | --- | --- | --- | --- | +//! | `0x01` | [`Restore`](Field::Restore) | The transaction ID, the store generation, a `u64`, and the identity of the template's key | 56 | In a clone's record | +//! | `0x10` | [`Image`](Field::Image) | The format, `1` for EROFS or `2` for GPT, the identity of the image's binding, and a GPT disk's first-partition bound, a `u64` | 33 for EROFS, 41 for GPT | Always | +//! | `0x11` | [`ImageIdentity`](Field::ImageIdentity) | The UUID or the disk GUID that the guest expects, in the order of its canonical text | 16 | If the guest expects one | +//! | `0x20` | [`Upper`](Field::Upper) | `1` for a tmpfs, or `2` for the scratch, then the identity of the scratch's binding and its journal policy, `1` allowed or `2` forbidden | 1 for a tmpfs, 34 for the scratch | Always | +//! | `0x30` | [`Overlay`](Field::Overlay) | `1` for trusted lowers, or `2` for untrusted lowers | 1 | Always | +//! | `0x40` | [`Export`](Field::Export) | The shape, `1` for a directory or `2` for an aggregate, and the identity of the export's plan | 33 | With an export | +//! | `0x41` | [`Mappings`](Field::Mappings) | The number of mappings, a `u16` from 1 to [`MAX_MAPPINGS`] | 2 | With an export | +//! | `0x50` | [`Network`](Field::Network) | The identity of the policy, and `1` if listeners are published, or `2` if not | 33 | With a network | +//! | `0x51` | [`Dns`](Field::Dns) | The DNS server's IPv4 or IPv6 address | 4 or 16 | With a network that has one | +//! | `0x52` | [`Proxy`](Field::Proxy) | The proxy endpoint | 1 to [`MAX_PROXY_ENDPOINT_BYTES`] | With a network that has one | +//! | `0x60` | [`Hostname`](Field::Hostname) | The hostname | 1 to [`MAX_HOSTNAME_BYTES`] | Always | +//! | `0x61` | [`MachineId`](Field::MachineId) | `1` for the image's machine ID, or `2` for a generated one | 1 | Always | +//! | `0x62` | [`WorkloadIdentity`](Field::WorkloadIdentity) | The UID and the GID, each a `u32` | 8 | With a workload identity | +//! | `0x70` | [`Resources`](Field::Resources) | The CPU count, a `u32`, and the memory size, a `u64` | 12 | Always | +//! +//! Integers are little-endian, and identities, IDs, and addresses are their bytes, in order. The +//! largest record, with every field at its largest, holds 602 bytes. +//! +//! [`ActivationRecord::decode`] refuses bytes that are longer than [`MAX_RECORD_BYTES`], end +//! inside the version or a field, or hold another version; a field with an unknown tag, a +//! repeated field, and a field out of order; a value with another number of bytes than its +//! contents take, or with a code that the version does not define; a mapping count of 0, since +//! a record without mappings leaves the field out, and a store generation of 0, which no store +//! assigns; a record without a field that is always present, or with a DNS server or a proxy but +//! no network; and every configuration that [`Configuration::check`] refuses, such as one with +//! an export without mappings, or with mappings without an export. A record therefore has +//! exactly one encoding, which [`ActivationRecord::encode`] writes, and which the decoder reads +//! back to the same record. +//! //! ``` //! use std::num::NonZeroU64; //! @@ -113,6 +151,9 @@ //! }; //! let clone = ActivationRecord::new(Some(restore), configuration)?; //! assert_eq!(clone.restore(), Some(&restore)); +//! // Its canonical encoding reads back to it. +//! let bytes = clone.encode(); +//! assert_eq!(ActivationRecord::decode(&bytes)?, clone); //! # Ok(()) //! # } //! ``` @@ -125,14 +166,17 @@ use crate::protocol::MAX_HOST_MAPPINGS; use crate::protocol::root::ImageFormat; use crate::uuid::Uuid; +mod codec; #[cfg(test)] mod tests; +pub use self::codec::Field; + /// 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. +/// The most bytes of a record's encoding, 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, @@ -619,6 +663,28 @@ pub enum ActivationError { Cpus, /// The expected memory size is 0. Memory, + /// The bytes hold more than [`MAX_RECORD_BYTES`]: this many. + TooLong(usize), + /// The bytes end inside the record's version or inside one of its fields. + Truncated, + /// The record's version is not [`VERSION`], but this one. + Version(u16), + /// A field has a tag that the record's version does not define: this one. + UnknownField(u8), + /// A field is repeated. + DuplicateField(Field), + /// A field follows a field whose tag is greater. + FieldOrder(Field), + /// A field's value holds another number of bytes than its contents take: this many. + FieldLength(Field, usize), + /// A field's value holds a code that the record's version does not define, or a form that + /// no record takes, such as a mapping count of 0. + FieldValue(Field), + /// A field that every record holds is missing. + MissingField(Field), + /// A field that only a record with a network holds, its DNS server's or its proxy's, is + /// present without one. + WithoutNetwork(Field), } impl fmt::Display for ActivationError { @@ -647,6 +713,44 @@ impl fmt::Display for ActivationError { } Self::Cpus => formatter.write_str("the expected CPU count is 0"), Self::Memory => formatter.write_str("the expected memory size is 0"), + Self::TooLong(length) => write!( + formatter, + "the record's {length} bytes are more than the {MAX_RECORD_BYTES} that a record \ + holds" + ), + Self::Truncated => { + formatter.write_str("the record ends inside its version or one of its fields") + } + Self::Version(version) => { + write!( + formatter, + "the record's version is {version}, not {VERSION}" + ) + } + Self::UnknownField(tag) => { + write!( + formatter, + "the record holds a field of the unknown tag {tag:#04x}" + ) + } + Self::DuplicateField(field) => { + write!(formatter, "the record repeats its {field} field") + } + Self::FieldOrder(field) => { + write!(formatter, "the record's {field} field is out of order") + } + Self::FieldLength(field, length) => write!( + formatter, + "the record's {field} field holds {length} bytes, which its value never takes" + ), + Self::FieldValue(field) => write!( + formatter, + "the record's {field} field holds a value that no record takes" + ), + Self::MissingField(field) => write!(formatter, "the record has no {field} field"), + Self::WithoutNetwork(field) => { + write!(formatter, "the record has a {field} field but no network") + } } } } diff --git a/nvx-rs/src/activation/tests.rs b/nvx-rs/src/activation/tests.rs index 36c031516..aed8595b6 100644 --- a/nvx-rs/src/activation/tests.rs +++ b/nvx-rs/src/activation/tests.rs @@ -10,7 +10,7 @@ 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 { +pub(super) fn configuration() -> Configuration { Configuration { image: Image::Erofs { binding: Identity::from_bytes([0x11; 32]), @@ -37,7 +37,7 @@ fn configuration() -> Configuration { /// 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 { +pub(super) fn maximal() -> Configuration { Configuration { image: Image::Gpt { binding: Identity::from_bytes([0xff; 32]), @@ -76,7 +76,7 @@ fn maximal() -> Configuration { } /// Returns the restore section of the tests' clones. -fn restore() -> RestoreSection { +pub(super) fn restore() -> RestoreSection { RestoreSection { transaction: TransactionId::from_bytes([0x22; 16]), template: TemplateReference {