diff --git a/nvx-rs/README.md b/nvx-rs/README.md index d9695d4ae..9ab0128be 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -158,7 +158,10 @@ the host mapping table, and the `nvx_maps=` and `nvx_workload_account=create` tokens. NVX's own control protocol, `NVXC`, is private to the crate, gated by the features of its users, `cli`, `guest` and `testing`: its application frames, the `EXEC` payload that the host encodes and the guest agent decodes, -and the control features that a guest image advertises. +and the control features that a guest image advertises. Beside version 1's +frames, `protocol::app::v2` holds the codec of version 2's, which carry every +control path, with version 1's payloads, and stream an execution's standard +input, output and error. The [conformance vectors](tests/vectors/README.md) of version 1, `tests/vectors/nvxc-v1.txt`, and OpenVMM's vectors and cases of the `NVXS` diff --git a/nvx-rs/src/protocol/app.rs b/nvx-rs/src/protocol/app.rs index 66b1a0d29..5cf505fd6 100644 --- a/nvx-rs/src/protocol/app.rs +++ b/nvx-rs/src/protocol/app.rs @@ -3,7 +3,8 @@ //! //! A frame is a 24-byte header (the magic, the version, the frame kind, two zero flags bytes, the //! request ID, a status, and the payload length) followed by the payload. Responses repeat the ID -//! of their request. +//! of their request. This module holds version 1, and `v2` version 2, which carries every control +//! path and streams an execution's standard input, output and error. #[cfg(any( feature = "cli", @@ -42,6 +43,20 @@ pub(crate) mod workload { } } +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "SL4.4b's guest loop and fake OpenVMM serve NVXC v2, and SL4.4c's host speaks it" + ) +)] +pub(crate) mod v2; + /// Magic of every application frame. #[cfg(any( feature = "cli", diff --git a/nvx-rs/src/protocol/app/v2.rs b/nvx-rs/src/protocol/app/v2.rs new file mode 100644 index 000000000..d6ba1757a --- /dev/null +++ b/nvx-rs/src/protocol/app/v2.rs @@ -0,0 +1,334 @@ +//! Version 2 of the application frames (magic `NVXC`), which carries every control path and +//! streams an execution's standard input, output and error. +//! +//! A frame has version 1's 24-byte header, with version [`VERSION`]: the magic, the version, the +//! frame kind, two zero flags bytes, the request ID, a status, and the payload length, followed by +//! the payload. Its integers are little-endian. A frame fits one outer data record, so its +//! payload holds at most [`MAX_PAYLOAD`] bytes. Its request ID is never zero: a request takes a +//! new one, its answers repeat it, and so do the frames of the execution that an `EXEC` starts, in +//! both directions. The status is the exit status of an `EXIT` and the error number of an +//! `ERROR`, and zero in every other frame. +//! +//! | Kind | Code | From | Payload | +//! | --- | --- | --- | --- | +//! | `PING` | `0x01` | Host | None | +//! | `EXEC` | `0x02` | Host | An `EXEC` payload | +//! | `STOP` | `0x03` | Host | None | +//! | `CANCEL` | `0x04` | Host | None | +//! | `MAPS` | `0x06` | Host | A `MAPS` payload | +//! | `CAPTURE` | `0x07` | Host | The capture request's flag | +//! | `ACTIVATE` | `0x08` | Host | The activate request's workload identity and activation record | +//! | `STDIN` | `0x09` | Host | 1 or more bytes of an execution's standard input | +//! | `STDIN_END` | `0x0a` | Host | None | +//! | `READY` | `0x81` | Guest | The protocol version, one byte, then, in answer to `ACTIVATE`, what Ready reports about the activation | +//! | `STDOUT` | `0x82` | Guest | 1 or more bytes of an execution's standard output | +//! | `STDERR` | `0x83` | Guest | 1 or more bytes of an execution's standard error | +//! | `EXIT` | `0x84` | Guest | How the execution ended, an [`Outcome`], as text | +//! | `STOPPED` | `0x85` | Guest | None | +//! | `STDIN_ACK` | `0x86` | Guest | How many more bytes of standard input the guest agent wrote, a `u32` from 1 to [`STDIN_WINDOW`] | +//! | `BUSY` | `0x87` | Guest | None | +//! | `ERROR` | `0xff` | Guest | The error category, such as `invalid-request`, as 1 or more bytes of text | +//! +//! The exchanges of version 1 keep their payloads and their order. `PING` asks whether the guest +//! agent is ready, and `READY` answers with the protocol version, which the host checks. `MAPS`, +//! `CAPTURE` and `ACTIVATE` carry the payloads that their codecs encode and decode, as in +//! version 1, and `READY` acknowledges them; in answer to `ACTIVATE`, it also carries what Ready +//! reports about the activation, in the canonical encoding of +//! [`ReadyActivation`](crate::activation::ReadyActivation). `ERROR` refuses a request, and `BUSY` +//! refuses one while an execution runs. `STOP` shuts the VM down, also while an execution runs, +//! and `STOPPED` acknowledges it. +//! +//! An `EXEC` starts an execution, which its `EXEC` payload describes as in version 1, and which +//! its request ID names until the `EXIT` that ends it, or the `ERROR` that refuses it. Its +//! standard output and error come in `STDOUT` and `STDERR` frames, with no total cap. The host +//! sends its standard input in `STDIN` frames, and ends it with `STDIN_END` after the bytes +//! before it. The guest agent acknowledges the bytes of `STDIN` frames with `STDIN_ACK` once it +//! has written them into the execution's standard input, and the host never has more than +//! [`STDIN_WINDOW`] bytes that the guest agent has not acknowledged. `CANCEL` ends the execution, +//! whose `EXIT` reports it. Version 1's `FEATURES` request, `0x05`, has no place in version 2. + +use crate::protocol::{OUTER_MAX_PAYLOAD, ProtocolError, array, violation}; + +/// Version of the frame format and of the protocol, which each header and each `READY` carry. +pub(crate) const VERSION: u8 = 2; +/// Largest payload of a frame, which fits one outer data record with its header. +pub(crate) const MAX_PAYLOAD: usize = OUTER_MAX_PAYLOAD - HEADER_LEN; +/// Most bytes of an execution's standard input that the host may have sent in `STDIN` frames and +/// the guest agent not yet acknowledged with `STDIN_ACK`. +pub(crate) const STDIN_WINDOW: u32 = 64 * 1024; + +/// Length of a frame's header. +const HEADER_LEN: usize = 24; +/// Magic of every frame. +const MAGIC: [u8; 4] = *b"NVXC"; +/// Kind of [`Frame::Ping`]. +const PING: u8 = 0x01; +/// Kind of [`Frame::Exec`]. +const EXEC: u8 = 0x02; +/// Kind of [`Frame::Stop`]. +const STOP: u8 = 0x03; +/// Kind of [`Frame::Cancel`]. +const CANCEL: u8 = 0x04; +/// Kind of [`Frame::Maps`]. +const MAPS: u8 = 0x06; +/// Kind of [`Frame::Capture`]. +const CAPTURE: u8 = 0x07; +/// Kind of [`Frame::Activate`]. +const ACTIVATE: u8 = 0x08; +/// Kind of [`Frame::Stdin`]. +const STDIN: u8 = 0x09; +/// Kind of [`Frame::StdinEnd`]. +const STDIN_END: u8 = 0x0a; +/// Kind of [`Frame::Ready`]. +const READY: u8 = 0x81; +/// Kind of [`Frame::Stdout`]. +const STDOUT: u8 = 0x82; +/// Kind of [`Frame::Stderr`]. +const STDERR: u8 = 0x83; +/// Kind of [`Frame::Exit`]. +const EXIT: u8 = 0x84; +/// Kind of [`Frame::Stopped`]. +const STOPPED: u8 = 0x85; +/// Kind of [`Frame::StdinAck`]. +const STDIN_ACK: u8 = 0x86; +/// Kind of [`Frame::Busy`]. +const BUSY: u8 = 0x87; +/// Kind of [`Frame::Error`]. +const ERROR: u8 = 0xff; + +/// A frame of version 2, without its request ID, which [`Frame::encode`] takes and +/// [`Frame::decode`] returns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Frame<'a> { + /// `PING`: asks whether the guest agent is ready. + Ping, + /// `EXEC`: starts an execution, which the `EXEC` payload describes. + Exec(&'a [u8]), + /// `STOP`: shuts the VM down. + Stop, + /// `CANCEL`: ends the execution that the request ID names. + Cancel, + /// `MAPS`: hands the guest agent entries of the host mapping table, in a `MAPS` payload. + Maps(&'a [u8]), + /// `CAPTURE`: asks nvx-init to capture the VM, with the capture request's payload. + Capture(&'a [u8]), + /// `ACTIVATE`: asks nvx-init to activate a restored VM, with the activate request's payload. + Activate(&'a [u8]), + /// `STDIN`: bytes of the standard input of the execution that the request ID names. + Stdin(&'a [u8]), + /// `STDIN_END`: ends the standard input of the execution that the request ID names. + StdinEnd, + /// `READY`: acknowledges a request. + Ready { + /// The protocol version that the guest agent speaks. + version: u8, + /// What Ready reports about the activation, in answer to `ACTIVATE`; empty otherwise. + activation: &'a [u8], + }, + /// `STDOUT`: bytes of the execution's standard output. + Stdout(&'a [u8]), + /// `STDERR`: bytes of the execution's standard error. + Stderr(&'a [u8]), + /// `EXIT`: ends the execution. + Exit { + /// Its exit status, 128 plus the signal number for [`Outcome::Signal`]. + status: i32, + /// How it ended. + outcome: Outcome, + }, + /// `STOPPED`: acknowledges `STOP`. + Stopped, + /// `STDIN_ACK`: acknowledges this many more bytes of the execution's standard input, which + /// the guest agent wrote into it. + StdinAck(u32), + /// `BUSY`: refuses a request while an execution runs. + Busy, + /// `ERROR`: refuses a request. + Error { + /// The guest's error number. + status: i32, + /// The error category, such as `invalid-request`. + category: &'a [u8], + }, +} + +/// How an execution ended, which [`Frame::Exit`] reports. Version 2 caps no output, so no +/// execution ends at an output limit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Outcome { + /// The workload exited; the status is its exit status. + Exit, + /// A signal ended the workload; the status is 128 plus the signal number. + Signal, + /// The workload's timeout elapsed, and the guest agent killed it. + Timeout, + /// A cancellation ended the workload. + Cancelled, + /// The guest agent could not supervise the workload. + Failed, +} + +impl Outcome { + /// Returns the text that names this outcome in an `EXIT` payload. + fn text(self) -> &'static [u8] { + match self { + Self::Exit => b"exit", + Self::Signal => b"signal", + Self::Timeout => b"timeout", + Self::Cancelled => b"cancelled", + Self::Failed => b"failed", + } + } + + /// Returns the outcome that `text` names, if any. + fn parse(text: &[u8]) -> Option { + [ + Self::Exit, + Self::Signal, + Self::Timeout, + Self::Cancelled, + Self::Failed, + ] + .into_iter() + .find(|outcome| outcome.text() == text) + } +} + +impl<'a> Frame<'a> { + /// Encodes this frame with `request_id`. + /// + /// It refuses what [`Frame::decode`] refuses: a zero request ID, an empty chunk of standard + /// input, output or error, an acknowledgement of no bytes or of more than [`STDIN_WINDOW`], an + /// empty error category, and a payload longer than [`MAX_PAYLOAD`]. + pub(crate) fn encode(&self, request_id: u64) -> Result, ProtocolError> { + self.check(request_id)?; + let ready; + let ack; + let (kind, status, head, data): (u8, i32, &[u8], &[u8]) = match *self { + Self::Ping => (PING, 0, &[], &[]), + Self::Exec(data) => (EXEC, 0, &[], data), + Self::Stop => (STOP, 0, &[], &[]), + Self::Cancel => (CANCEL, 0, &[], &[]), + Self::Maps(data) => (MAPS, 0, &[], data), + Self::Capture(data) => (CAPTURE, 0, &[], data), + Self::Activate(data) => (ACTIVATE, 0, &[], data), + Self::Stdin(data) => (STDIN, 0, &[], data), + Self::StdinEnd => (STDIN_END, 0, &[], &[]), + Self::Ready { + version, + activation, + } => { + ready = [version]; + (READY, 0, &ready, activation) + } + Self::Stdout(data) => (STDOUT, 0, &[], data), + Self::Stderr(data) => (STDERR, 0, &[], data), + Self::Exit { status, outcome } => (EXIT, status, &[], outcome.text()), + Self::Stopped => (STOPPED, 0, &[], &[]), + Self::StdinAck(count) => { + ack = count.to_le_bytes(); + (STDIN_ACK, 0, &ack, &[]) + } + Self::Busy => (BUSY, 0, &[], &[]), + Self::Error { status, category } => (ERROR, status, &[], category), + }; + let length = head.len() + data.len(); + if length > MAX_PAYLOAD { + return Err(violation("an NVXC frame must fit one outer data record")); + } + let mut frame = Vec::with_capacity(HEADER_LEN + length); + frame.extend_from_slice(&MAGIC); + frame.extend_from_slice(&[VERSION, kind, 0, 0]); + frame.extend_from_slice(&request_id.to_le_bytes()); + frame.extend_from_slice(&status.to_le_bytes()); + frame.extend_from_slice(&(length as u32).to_le_bytes()); + frame.extend_from_slice(head); + frame.extend_from_slice(data); + Ok(frame) + } + + /// Decodes a frame, and returns its request ID with it. + /// + /// It refuses bytes that hold no whole frame, or more than one outer data record holds; a + /// header with another magic or version, nonzero flags, or a payload length other than the + /// payload's; an unknown kind; a nonzero status outside `EXIT` and `ERROR`; a payload in a + /// kind that has none; a `READY` without a version; a `STDIN_ACK` without exactly a `u32`; + /// an `EXIT` whose text names no [`Outcome`]; and what [`Frame::encode`] refuses. + pub(crate) fn decode(frame: &'a [u8]) -> Result<(u64, Self), ProtocolError> { + let (header, payload) = frame + .split_at_checked(HEADER_LEN) + .filter(|_| frame.len() <= OUTER_MAX_PAYLOAD) + .ok_or_else(malformed)?; + if header[..4] != MAGIC || header[4] != VERSION { + return Err(violation( + "the control peer sent a frame of another protocol than NVXC version 2", + )); + } + let request_id = u64::from_le_bytes(array(&header[8..16])); + let status = i32::from_le_bytes(array(&header[16..20])); + let length = u32::from_le_bytes(array(&header[20..24])); + if header[6..8] != [0, 0] || length as usize != payload.len() { + return Err(malformed()); + } + let empty = |variant| payload.is_empty().then_some(variant); + let decoded = match header[5] { + PING => empty(Self::Ping), + EXEC => Some(Self::Exec(payload)), + STOP => empty(Self::Stop), + CANCEL => empty(Self::Cancel), + MAPS => Some(Self::Maps(payload)), + CAPTURE => Some(Self::Capture(payload)), + ACTIVATE => Some(Self::Activate(payload)), + STDIN => Some(Self::Stdin(payload)), + STDIN_END => empty(Self::StdinEnd), + READY => payload + .split_first() + .map(|(&version, activation)| Self::Ready { + version, + activation, + }), + STDOUT => Some(Self::Stdout(payload)), + STDERR => Some(Self::Stderr(payload)), + EXIT => Outcome::parse(payload).map(|outcome| Self::Exit { status, outcome }), + STOPPED => empty(Self::Stopped), + STDIN_ACK => payload + .try_into() + .ok() + .map(|count| Self::StdinAck(u32::from_le_bytes(count))), + BUSY => empty(Self::Busy), + ERROR => Some(Self::Error { + status, + category: payload, + }), + _ => None, + } + .filter(|decoded| status == 0 || matches!(decoded, Self::Exit { .. } | Self::Error { .. })) + .ok_or_else(malformed)?; + decoded.check(request_id)?; + Ok((request_id, decoded)) + } + + /// Checks what both directions refuse of a frame with `request_id`, other than its size: a + /// zero request ID, an empty chunk, an acknowledgement of no bytes or of more than + /// [`STDIN_WINDOW`], and an empty error category. + fn check(&self, request_id: u64) -> Result<(), ProtocolError> { + let valid = match *self { + Self::Stdin(data) | Self::Stdout(data) | Self::Stderr(data) => !data.is_empty(), + Self::StdinAck(count) => (1..=STDIN_WINDOW).contains(&count), + Self::Error { category, .. } => !category.is_empty(), + _ => true, + }; + if request_id == 0 || !valid { + return Err(malformed()); + } + Ok(()) + } +} + +/// Returns the error of a frame that version 2 does not allow. +fn malformed() -> ProtocolError { + violation("the NVXC frame of version 2 is malformed") +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/protocol/app/v2/tests.rs b/nvx-rs/src/protocol/app/v2/tests.rs new file mode 100644 index 000000000..1281c5836 --- /dev/null +++ b/nvx-rs/src/protocol/app/v2/tests.rs @@ -0,0 +1,375 @@ +//! Tests of version 2 of the application frames: the bytes of each frame, as the protocol defines +//! them, and its round trip through the codec; the protocol version that `READY` carries; the +//! bounds of the frames of an execution's standard input and output; the frames that version 2 +//! refuses; and the payloads of version 1, which its frames carry unchanged and in order. + +use proptest::prelude::*; + +use super::*; +use crate::protocol::{HostMap, decode_maps_payload, encode_maps_payloads}; +use crate::tests::{ACTIVATION_V2, NVXC_V1, vector, vectors}; + +/// A request ID whose bytes all differ, so that a header that misplaced one would show. +const ID: u64 = 0x0102_0304_0506_0708; + +/// A frame of each kind, and one of each outcome of an execution, with the code of its kind, its +/// status and its payload, as the protocol defines them, independently of the codec. +const FRAMES: [(Frame<'static>, u8, i32, &[u8]); 23] = [ + (Frame::Ping, 0x01, 0, b""), + (Frame::Exec(b"workload"), 0x02, 0, b"workload"), + (Frame::Stop, 0x03, 0, b""), + (Frame::Cancel, 0x04, 0, b""), + (Frame::Maps(b"table"), 0x06, 0, b"table"), + (Frame::Capture(&[1]), 0x07, 0, &[1]), + (Frame::Activate(b"record"), 0x08, 0, b"record"), + (Frame::Stdin(b"input"), 0x09, 0, b"input"), + (Frame::StdinEnd, 0x0a, 0, b""), + (ready(2, b""), 0x81, 0, &[2]), + (ready(2, &[0]), 0x81, 0, &[2, 0]), + (Frame::Stdout(b"output"), 0x82, 0, b"output"), + (Frame::Stderr(b"error"), 0x83, 0, b"error"), + (exit(0, Outcome::Exit), 0x84, 0, b"exit"), + (exit(137, Outcome::Signal), 0x84, 137, b"signal"), + (exit(124, Outcome::Timeout), 0x84, 124, b"timeout"), + (exit(137, Outcome::Cancelled), 0x84, 137, b"cancelled"), + (exit(125, Outcome::Failed), 0x84, 125, b"failed"), + (Frame::Stopped, 0x85, 0, b""), + (Frame::StdinAck(0x0001_0000), 0x86, 0, &[0, 0, 1, 0]), + (Frame::Busy, 0x87, 0, b""), + (error(22, b"invalid-request"), 0xff, 22, b"invalid-request"), + (error(-1, b"x"), 0xff, -1, b"x"), +]; + +/// Returns a `READY` of `version` that reports `activation`. +const fn ready(version: u8, activation: &[u8]) -> Frame<'_> { + Frame::Ready { + version, + activation, + } +} + +/// Returns an `EXIT` with `status` and `outcome`. +const fn exit(status: i32, outcome: Outcome) -> Frame<'static> { + Frame::Exit { status, outcome } +} + +/// Returns an `ERROR` with `status` and `category`. +const fn error(status: i32, category: &[u8]) -> Frame<'_> { + Frame::Error { status, category } +} + +/// Returns the bytes of a frame of `kind` with [`ID`], `status` and `payload`, as the protocol +/// defines them, independently of the codec. +fn wire(kind: u8, status: i32, payload: &[u8]) -> Vec { + let length = u32::try_from(payload.len()).unwrap(); + [ + &b"NVXC\x02"[..], + &[kind, 0, 0], + &ID.to_le_bytes(), + &status.to_le_bytes(), + &length.to_le_bytes(), + payload, + ] + .concat() +} + +#[test] +fn every_frame_round_trips_in_its_wire_form() { + for (frame, kind, status, payload) in FRAMES { + let bytes = wire(kind, status, payload); + assert_eq!(frame.encode(ID).unwrap(), bytes, "{frame:?}"); + assert_eq!(Frame::decode(&bytes).unwrap(), (ID, frame)); + } +} + +/// `READY` carries the current protocol version, then what Ready reports about an activation, +/// unchanged. It decodes another version too, so that its receiver can refuse that version by +/// name. +#[test] +fn ready_round_trips_the_protocol_version() { + assert_eq!(VERSION, 2); + let bytes = ready(VERSION, &[]).encode(ID).unwrap(); + assert_eq!(bytes, wire(0x81, 0, &[2])); + assert_eq!(Frame::decode(&bytes).unwrap(), (ID, ready(VERSION, &[]))); + for vector in vectors(ACTIVATION_V2) { + if vector.kind == "READY" { + let bytes = wire(0x81, 0, &[&[2][..], &vector.bytes[..]].concat()); + let decoded = Frame::decode(&bytes).unwrap(); + assert_eq!( + decoded, + (ID, ready(VERSION, &vector.bytes)), + "{}", + vector.name + ); + } + } + for version in [0, 1, 3, u8::MAX] { + let bytes = ready(version, &[]).encode(ID).unwrap(); + assert_eq!(Frame::decode(&bytes).unwrap(), (ID, ready(version, &[]))); + } +} + +/// A chunk of standard input, output or error fills at most one outer data record, whose payload +/// is smaller than the window of standard input, and an acknowledgement returns 1 to +/// [`STDIN_WINDOW`] bytes to that window. +#[test] +fn stream_frames_keep_their_bounds() { + assert_eq!((STDIN_WINDOW, MAX_PAYLOAD), (64 * 1024, 65_512)); + let largest = vec![0x5a; MAX_PAYLOAD]; + for frame in [ + Frame::Stdin(&largest), + Frame::Stdout(&largest), + Frame::Stderr(&largest), + ] { + let bytes = frame.encode(ID).unwrap(); + assert_eq!(bytes.len(), OUTER_MAX_PAYLOAD); + assert_eq!(Frame::decode(&bytes).unwrap(), (ID, frame)); + } + let longer = vec![0x5a; MAX_PAYLOAD + 1]; + for frame in [ + Frame::Stdin(&longer), + Frame::Stdout(&longer), + Frame::Exec(&longer), + ready(VERSION, &largest), + ] { + assert!(frame.encode(ID).is_err(), "{:?}", frame.encode(ID)); + } + assert!(Frame::decode(&wire(0x82, 0, &longer)).is_err()); + for count in [1, STDIN_WINDOW] { + let bytes = Frame::StdinAck(count).encode(ID).unwrap(); + assert_eq!(bytes, wire(0x86, 0, &count.to_le_bytes())); + assert_eq!(Frame::decode(&bytes).unwrap(), (ID, Frame::StdinAck(count))); + } + for count in [0, STDIN_WINDOW + 1] { + assert!(Frame::StdinAck(count).encode(ID).is_err(), "{count}"); + assert!(Frame::decode(&wire(0x86, 0, &count.to_le_bytes())).is_err()); + } + for (frame, kind) in [ + (Frame::Stdin(&[]), 0x09), + (Frame::Stdout(&[]), 0x82), + (Frame::Stderr(&[]), 0x83), + ] { + assert!(frame.encode(ID).is_err(), "{frame:?}"); + assert!(Frame::decode(&wire(kind, 0, &[])).is_err(), "{frame:?}"); + } +} + +#[test] +fn malformed_frames_are_refused() { + let valid = wire(0x82, 0, b"output"); + for length in 0..valid.len() { + assert!(Frame::decode(&valid[..length]).is_err(), "{length}"); + } + let mut trailing = valid.clone(); + trailing.push(0); + assert!(Frame::decode(&trailing).is_err()); + for (offset, byte) in [(0, b'X'), (4, 3), (6, 1), (7, 1)] { + let mut changed = valid.clone(); + changed[offset] = byte; + assert!(Frame::decode(&changed).is_err(), "{offset}"); + } + // A frame of version 1, as its conformance vector holds it. + let refused = Frame::decode(&vector(NVXC_V1, "FRAME", "PING").bytes).unwrap_err(); + assert_eq!( + refused.message(), + "the control peer sent a frame of another protocol than NVXC version 2" + ); + // Unknown kinds, version 1's FEATURES among them. + for kind in [0x00, 0x05, 0x0b, 0x80, 0x88, 0xfe] { + assert!(Frame::decode(&wire(kind, 0, &[])).is_err(), "{kind:#x}"); + } + let mut zero = wire(0x01, 0, &[]); + zero[8..16].fill(0); + assert!(Frame::decode(&zero).is_err()); + assert!(Frame::Ping.encode(0).is_err()); + for (frame, kind, _, payload) in FRAMES { + if !matches!(frame, Frame::Exit { .. } | Frame::Error { .. }) { + assert!(Frame::decode(&wire(kind, 1, payload)).is_err(), "{frame:?}"); + } + } + // A payload in a kind that has none, a READY without a version, an acknowledgement that is + // not a `u32`, an outcome that version 2 does not have, and an empty error category. + let payloads: [(u8, &[u8]); 13] = [ + (0x01, &[0]), + (0x03, &[0]), + (0x04, &[0]), + (0x0a, &[0]), + (0x85, &[0]), + (0x87, &[0]), + (0x81, &[]), + (0x86, &[1, 0, 0]), + (0x86, &[1, 0, 0, 0, 0]), + (0x84, b"output-limit"), + (0x84, b"canceled"), + (0x84, b""), + (0xff, b""), + ]; + for (kind, payload) in payloads { + let bytes = wire(kind, 0, payload); + assert!(Frame::decode(&bytes).is_err(), "{kind:#x} {payload:?}"); + } + assert!(error(22, &[]).encode(ID).is_err()); +} + +proptest! { + /// No bytes make decoding panic, and the frames that decode encode back to their bytes, so + /// that each frame has one encoding. + #[test] + fn decoded_frames_encode_back_to_their_bytes( + kind in any::(), + status in prop_oneof![Just(0), any::()], + payload in proptest::collection::vec(any::(), 0..8), + cut in any::(), + ) { + let bytes = wire(kind, status, &payload); + if let Ok((id, frame)) = Frame::decode(&bytes) { + prop_assert_eq!(&frame.encode(id).unwrap(), &bytes); + } + prop_assert!(Frame::decode(&bytes[..cut.index(bytes.len())]).is_err()); + } +} + +/// The workload of an `EXEC` crosses version 2 unchanged: its arguments, working directory and +/// environment in their order, and its timeout, as the fake OpenVMM's decoder reads them. +#[cfg(all(feature = "cli", feature = "testing"))] +#[test] +fn exec_requests_carry_their_workload_unchanged() { + use crate::protocol::{ + Workload, WorkloadEnvironment, decode_exec_payload, encode_exec_payload, + }; + + let environment = ["ZED=1".to_owned(), "ALPHA=2".to_owned()]; + let workload = Workload { + argv: ["/bin/sh", "-c", "cat; echo done"] + .map(str::to_owned) + .to_vec(), + timeout_ms: 30_000, + cwd: Some("/work"), + environment: WorkloadEnvironment::Layered(&environment), + }; + let payload = encode_exec_payload(&workload).unwrap(); + let bytes = Frame::Exec(&payload).encode(ID).unwrap(); + let Ok((ID, Frame::Exec(carried))) = Frame::decode(&bytes) else { + panic!("the EXEC frame does not decode"); + }; + assert_eq!(carried, payload); + let decoded = decode_exec_payload(carried).unwrap(); + assert_eq!(decoded.argv, workload.argv); + assert_eq!(decoded.cwd.as_deref(), Some("/work")); + assert_eq!(decoded.environment.as_deref(), Some(&environment[..])); + assert!(decoded.inherit_default_env); + assert_eq!(decoded.timeout_ms, 30_000); +} + +/// Each `EXEC` payload of version 1's conformance vectors crosses version 2 byte for byte. +#[test] +fn exec_payloads_cross_unchanged() { + for vector in vectors(NVXC_V1) { + if vector.kind == "EXEC" { + let bytes = Frame::Exec(&vector.bytes).encode(ID).unwrap(); + assert_eq!(bytes, wire(0x02, 0, &vector.bytes), "{}", vector.name); + let decoded = Frame::decode(&bytes).unwrap(); + assert_eq!(decoded, (ID, Frame::Exec(&vector.bytes)), "{}", vector.name); + } + } +} + +/// A host mapping table that takes several `MAPS` frames reaches the guest whole, in table order, +/// each frame's payload continuing the table where the one before it ended. +#[test] +fn maps_tables_cross_unchanged_in_order() { + let table: Vec<_> = (0..400) + .map(|index| HostMap { + source: format!("share-{index:03}/{}", "s".repeat(200)), + target: format!("/mnt/{index:03}/{}", "t".repeat(200)), + read_only: index % 3 == 0, + }) + .collect(); + let payloads = encode_maps_payloads(&table, MAX_PAYLOAD).unwrap(); + assert!(payloads.len() > 2, "{}", payloads.len()); + let mut received = Vec::new(); + for payload in &payloads { + let bytes = Frame::Maps(payload).encode(ID).unwrap(); + let Ok((ID, Frame::Maps(carried))) = Frame::decode(&bytes) else { + panic!("the MAPS frame does not decode"); + }; + assert_eq!(carried, &payload[..]); + let decoded = decode_maps_payload(carried).unwrap(); + assert_eq!(decoded.first as usize, received.len()); + received.extend(decoded.maps); + } + assert_eq!(received, table); +} + +/// The capture request's flag crosses version 2 unchanged, from the host's encoder to the guest's +/// decoder. +#[cfg(all( + feature = "cli", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] +#[test] +fn capture_requests_carry_their_flag_unchanged() { + use crate::protocol::{decode_capture_payload, encode_capture_payload}; + + for kill_executions in [false, true] { + let payload = encode_capture_payload(kill_executions); + let bytes = Frame::Capture(&payload).encode(ID).unwrap(); + let Ok((ID, Frame::Capture(carried))) = Frame::decode(&bytes) else { + panic!("the CAPTURE frame does not decode"); + }; + assert_eq!(decode_capture_payload(carried).unwrap(), kill_executions); + } +} + +/// The activate exchange crosses version 2 unchanged: the host's activate request reaches the +/// guest's decoder with its record and workload identity, and the guest's `READY` brings the +/// host what Ready reports about that record's activation. +#[cfg(all( + feature = "cli", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] +#[test] +fn the_activate_exchange_carries_its_payloads_unchanged() { + use crate::activation::{ActivationRecord, ReadyActivation}; + use crate::protocol::workload::WorkloadIdentity; + use crate::protocol::{decode_activate_payload, encode_activate_payload}; + + let workload = WorkloadIdentity { + uid: 1000, + gid: 1001, + create_account: true, + }; + for vector in vectors(ACTIVATION_V2) { + if vector.kind != "RECORD" { + continue; + } + let name = vector.name; + let record = ActivationRecord::decode(&vector.bytes).unwrap(); + let payload = encode_activate_payload(&record, &workload).unwrap(); + let bytes = Frame::Activate(&payload).encode(ID).unwrap(); + let Ok((ID, Frame::Activate(carried))) = Frame::decode(&bytes) else { + panic!("the ACTIVATE frame of {name} does not decode"); + }; + let (applied, identity) = decode_activate_payload(carried).unwrap(); + assert_eq!((&applied, identity), (&record, workload), "{name}"); + let reported = ReadyActivation::of(&applied).encode(); + let bytes = ready(VERSION, &reported).encode(ID).unwrap(); + let Ok(( + ID, + Frame::Ready { + version, + activation, + }, + )) = Frame::decode(&bytes) + else { + panic!("the READY of {name} does not decode"); + }; + assert_eq!(version, VERSION, "{name}"); + let activation = ReadyActivation::decode(activation).unwrap(); + assert_eq!(activation, ReadyActivation::of(&record), "{name}"); + } +} diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index ddb768d07..1178a893f 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -17,6 +17,7 @@ "nvx-rs/src/host/platform/windows/process.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1, "nvx-rs/src/host/session.rs": 5, - "nvx-rs/src/host/vm.rs": 2 + "nvx-rs/src/host/vm.rs": 2, + "nvx-rs/src/protocol/app.rs": 1 } }