From 927dce9844649243425ae6317ec0e5c04944a103 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 12 Nov 2026 00:05:23 -0800 Subject: [PATCH] protocol: add NVXC v2's frame codec beside v1 NVXC v1 buffers an execution's input and caps its collected output, so it cannot carry interactive or unbounded exec streams. SL4.4a (#1128) defines version 2's frames and their codec in NVXC's private modules, beside v1, while every peer stays on v1: SL4.4b serves v2 in the guest loop and the fake OpenVMM, and SL4.4c moves the host to it. protocol::app::v2 holds a frame enum with its encoder and decoder. A frame keeps v1's 24-byte header, with version 2, and fits one outer data record. The host sends PING, EXEC, STOP, CANCEL, MAPS, CAPTURE, ACTIVATE, STDIN and STDIN_END, and the guest READY, STDOUT, STDERR, EXIT, STOPPED, STDIN_ACK, BUSY and ERROR. READY carries the protocol version, then, in answer to ACTIVATE, what Ready reports about the activation. EXEC, MAPS, CAPTURE and ACTIVATE carry v1's payloads, which v1's codecs encode and decode, unchanged. STDIN_END ends an execution's input explicitly, and STDIN_ACK returns 1 to 64 KiB of written bytes to its 64 KiB window of unacknowledged input. STDOUT and STDERR have no total cap, so EXIT's outcomes drop v1's output limit. FEATURES has no kind in v2. Both directions refuse a zero request ID, an empty chunk, an acknowledgement outside the window and an empty error category, and the decoder also refuses another magic or version, unknown kinds, a status outside EXIT and ERROR, and a payload that a kind does not allow. Nothing calls the codec yet, so its module is gated by the features of its users, cli, testing, and guest on Linux x86-64, and carries cfg_attr(not(test), expect(dead_code)) naming SL4.4b and SL4.4c, a test hook for which protocol/app.rs takes one seam in the test-layout allowlist. The codec is crate-private, so the public API doesn't change, and BOOT_CONTRACT_VERSION and CONTROL_CONTRACT_REVISION stay as they are. Its tests pin each frame's bytes and round trip, the protocol version that READY carries, the stdin window and the record's bounds, and the refusals, v1's PING vector among them, check that every frame that decodes encodes back to its bytes, and carry v1's EXEC, MAPS, capture and activate payloads across v2 unchanged and in order. Part of #1128 (SL4.4a). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/README.md | 5 +- nvx-rs/src/protocol/app.rs | 17 +- nvx-rs/src/protocol/app/v2.rs | 334 +++++++++++++++++++++++++ nvx-rs/src/protocol/app/v2/tests.rs | 375 ++++++++++++++++++++++++++++ scripts/test-layout-allowlist.json | 3 +- 5 files changed, 731 insertions(+), 3 deletions(-) create mode 100644 nvx-rs/src/protocol/app/v2.rs create mode 100644 nvx-rs/src/protocol/app/v2/tests.rs 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 } }