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 {