Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions nvx-rs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,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. `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::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 once the activation applied: a clone's transaction ID, which `ReadyActivation::of` takes from a record, and nothing more for a cold boot, in a canonical encoding of `COLD_LEN` or `CLONE_LEN` bytes, 1 or 17, that `ReadyActivation::encode` writes and `ReadyActivation::decode` reads. The records and both encodings need 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 |
Expand Down Expand Up @@ -1107,12 +1107,14 @@ 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 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
`tests/vectors/nvxl-v1.txt`. The activation codecs decode each record and
Ready activation of `tests/vectors/activation-v1.txt` to its values, encode
them to its bytes, and refuse its invalid bytes. Property tests check that
records and activation records round-trip, that the activation record and
Ready activation decoders accept only canonical encodings, and that the
`NVXS` record parser, those decoders, 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
broker that relays records as OpenVMM's does, with messages of an application
Expand Down
2 changes: 2 additions & 0 deletions nvx-rs/src/activation/codec/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use super::*;
use crate::activation::tests::{configuration, maximal, restore};
use crate::activation::{MAX_HOSTNAME_BYTES, MAX_MAPPINGS, MAX_PROXY_ENDPOINT_BYTES};

mod vectors;

/// A field of an encoding, as the tests build it: its tag and its value.
type RawField = (u8, Vec<u8>);

Expand Down
234 changes: 234 additions & 0 deletions nvx-rs/src/activation/codec/tests/vectors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
//! Tests of the canonical encoding against the conformance vectors, which give records from the
//! encoding's definition, never from the code that they check.

use std::net::IpAddr;
use std::num::NonZeroU64;

use crate::activation::{
ActivationError, ActivationRecord, Configuration, ExpectedResources, Export, ExportKind,
Identity, Image, Journal, MachineIdMode, Network, OverlayMode, Publication, RestoreSection,
StoreGeneration, TemplateReference, TransactionId, Upper, WorkloadIdentity, WorkloadView,
};
use crate::tests::{ACTIVATION_V1, Values, Vector, of_kind};
use crate::uuid::Uuid;

/// The names of the vectors of records, in order.
const RECORDS: [&str; 5] = [
"COLD",
"CLONE",
"SCRATCH_AGGREGATE",
"GPT_DIRECTORY",
"LARGEST",
];

/// Reads a byte string of exactly `N` bytes.
fn array<const N: usize>(values: &mut Values<'_>) -> [u8; N] {
values
.bytes()
.try_into()
.unwrap_or_else(|bytes: Vec<u8>| panic!("{} bytes are not {N}", bytes.len()))
}

/// Reads an identity.
fn identity(values: &mut Values<'_>) -> Identity {
Identity::from_bytes(array(values))
}

/// Reads a UUID or a GUID, in the order of its canonical text.
fn uuid(values: &mut Values<'_>) -> Uuid {
Uuid::from_bytes(array(values))
}

/// Reads a word, which must be one of those of `choices`, and returns the choice that it names.
fn choice<T: Copy>(values: &mut Values<'_>, choices: &[(&str, T)]) -> T {
let word = values.word();
choices
.iter()
.find(|(name, _)| *name == word)
.map(|(_, choice)| *choice)
.unwrap_or_else(|| panic!("{word} names no choice of its value"))
}

/// Returns the record that a `RECORD` vector's values give.
fn record_of(vector: &Vector) -> ActivationRecord {
let mut values = vector.values();
let restore = values.optional(|values| RestoreSection {
transaction: TransactionId::from_bytes(array(values)),
template: TemplateReference {
generation: StoreGeneration::new(
NonZeroU64::new(values.number()).expect("a store generation of 0"),
),
key: identity(values),
},
});
let image = match values.word() {
"EROFS" => Image::Erofs {
binding: identity(&mut values),
uuid: values.optional(uuid),
},
"GPT" => Image::Gpt {
binding: identity(&mut values),
first_partition_bound: values.number(),
disk_guid: values.optional(uuid),
},
format => panic!("{format} is no image format"),
};
let upper = match values.word() {
"TMPFS" => Upper::Tmpfs,
"SCRATCH" => Upper::Scratch {
binding: identity(&mut values),
journal: choice(
&mut values,
&[
("ALLOWED", Journal::Allowed),
("FORBIDDEN", Journal::Forbidden),
],
),
},
upper => panic!("{upper} is no upper"),
};
let overlay = choice(
&mut values,
&[
("TRUSTED_LOWERS", OverlayMode::TrustedLowers),
("UNTRUSTED_LOWERS", OverlayMode::UntrustedLowers),
],
);
let export = values.optional(|values| {
let kind = choice(
values,
&[
("DIRECTORY", ExportKind::Directory),
("AGGREGATE", ExportKind::Aggregate),
],
);
let plan = identity(values);
(Export { kind, plan }, values.number())
});
let (export, mappings) = match export {
Some((export, mappings)) => (Some(export), mappings),
None => (None, 0),
};
let network = values.optional(|values| Network {
policy: identity(values),
publication: choice(
values,
&[
("PUBLISHED", Publication::Published),
("UNPUBLISHED", Publication::Unpublished),
],
),
dns: values.optional(|values| {
let bytes = values.bytes();
match <[u8; 4]>::try_from(&bytes[..]) {
Ok(address) => IpAddr::from(address),
Err(_) => IpAddr::from(<[u8; 16]>::try_from(&bytes[..]).unwrap()),
}
}),
proxy: values.optional(|values| String::from_utf8(values.text()).unwrap()),
});
let hostname = values.text();
let machine_id = choice(
&mut values,
&[
("IMAGE", MachineIdMode::Image),
("GENERATED", MachineIdMode::Generated),
],
);
let workload_identity = values.optional(|values| WorkloadIdentity {
uid: values.number(),
gid: values.number(),
});
let resources = ExpectedResources {
cpus: values.number(),
memory: values.number(),
};
drop(values);
let configuration = Configuration {
image,
upper,
overlay,
export,
mappings,
network,
workload: WorkloadView {
hostname,
machine_id,
identity: workload_identity,
},
resources,
};
ActivationRecord::new(restore, configuration)
.unwrap_or_else(|error| panic!("RECORD {}: {error}", vector.name))
}

#[test]
fn records_match_their_vectors() {
for vector in of_kind(ACTIVATION_V1, "RECORD", &RECORDS) {
let name = vector.name;
let record = record_of(&vector);
assert_eq!(record.encode(), vector.bytes, "{name}");
assert_eq!(
ActivationRecord::decode(&vector.bytes).as_ref(),
Ok(&record),
"{name}"
);
}
}

#[test]
fn invalid_records_are_refused() {
let names = [
"EMPTY",
"TRUNCATED_VERSION",
"NO_FIELDS",
"VERSION_0",
"VERSION_2",
"TOO_LONG",
"TRUNCATED_FIELD_HEADER",
"TRUNCATED_FIELD_VALUE",
"UNKNOWN_FIELD",
"REPEATED_FIELD",
"UPPER_BEFORE_IMAGE",
"RESTORE_LAST",
"EMPTY_OVERLAY",
"LONG_OVERLAY",
"OVERLAY_CODE_0",
"OVERLAY_CODE_3",
"IMAGE_FORMAT_3",
"GPT_WITHOUT_BOUND",
"SHORT_RESTORE",
"SHORT_IMAGE_IDENTITY",
"SCRATCH_WITHOUT_JOURNAL",
"MACHINE_ID_CODE_0",
"DNS_OF_5_BYTES",
"SHORT_RESOURCES",
"ZERO_GENERATION",
"MISSING_IMAGE",
"MISSING_RESOURCES",
"EXPORT_WITHOUT_MAPPINGS",
"MAPPINGS_WITHOUT_EXPORT",
"ZERO_MAPPINGS",
"DNS_WITHOUT_NETWORK",
"PROXY_WITHOUT_NETWORK",
"TOO_MANY_MAPPINGS",
"EMPTY_PROXY",
"PROXY_WITH_CREDENTIALS",
"PROXY_WITH_SPACE",
"PROXY_NOT_ASCII",
"EMPTY_HOSTNAME",
"HOSTNAME_WITH_NUL",
"LONG_HOSTNAME",
"ZERO_FIRST_PARTITION_BOUND",
"ZERO_UID",
"ZERO_GID",
"UNCHANGED_UID",
"UNCHANGED_GID",
"ZERO_CPUS",
"ZERO_MEMORY",
];
for vector in of_kind(ACTIVATION_V1, "INVALID_RECORD", &names) {
let refusal: Result<_, ActivationError> = ActivationRecord::decode(&vector.bytes);
assert!(refusal.is_err(), "{} is admitted", vector.name);
}
}
Loading
Loading