From 843f8ca88bea78387ec7ff51e44206740f24918e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Wed, 14 Oct 2026 16:10:55 -0300 Subject: [PATCH 1/5] nvx-rs: add the guest side of the control protocol Add the guest attachment, acknowledgement, and credit records and window payloads. Parse the current nvx_maps count contract and expose the feature sets announced by each agent mode. Keep byte-oriented EXEC and MAPS decoders for the Linux guest beside the existing UTF-8 host decoders, including the C guest's 4096-byte cwd bound. Pin the new records and parser with golden and round-trip tests. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/protocol/boot.rs | 37 +++++ nvx-rs/src/protocol/exec.rs | 161 ++++++++++++++++++++++ nvx-rs/src/protocol/features.rs | 9 +- nvx-rs/src/protocol/maps.rs | 90 +++++++++++++ nvx-rs/src/protocol/mod.rs | 21 +-- nvx-rs/src/protocol/outer.rs | 67 ++++++++++ nvx-rs/src/protocol/tests.rs | 230 ++++++++++++++++++++++++++++++++ 7 files changed, 604 insertions(+), 11 deletions(-) diff --git a/nvx-rs/src/protocol/boot.rs b/nvx-rs/src/protocol/boot.rs index 6efe62ce0..c327462d9 100644 --- a/nvx-rs/src/protocol/boot.rs +++ b/nvx-rs/src/protocol/boot.rs @@ -1,5 +1,7 @@ //! Kernel command-line tokens through which the host configures the guest agent at boot. +use super::{MAX_HOST_MAPPINGS, ProtocolError, violation}; + /// Prefix of the token that announces how many host mappings the guest agent receives through /// `MAPS` requests, such as `nvx_maps=3`. The agent refuses workloads until it has mounted that /// many. @@ -8,3 +10,38 @@ pub const MAPS_TOKEN: &str = "nvx_maps="; /// Token with which the guest creates an account for the workload identity when its image lacks /// a usable one. pub const WORKLOAD_ACCOUNT_TOKEN: &str = "nvx_workload_account=create"; + +/// Parses the `nvx_maps=COUNT` token from a Linux kernel command line. +/// +/// No token announces zero mappings. One token may contain only decimal digits and must name a +/// count from 1 through [`MAX_HOST_MAPPINGS`]. A duplicate or malformed `nvx_maps=` token is an +/// error. Retired `nvx_map=` tokens are unrelated and are ignored. +pub fn parse_host_mapping_count(command_line: &[u8]) -> Result { + let mut count = 0; + let mut found = false; + for token in command_line.split(|byte| matches!(byte, b' ' | b'\t' | b'\n')) { + let Some(digits) = token.strip_prefix(MAPS_TOKEN.as_bytes()) else { + continue; + }; + if found || digits.is_empty() { + return Err(violation( + "the nvx_maps kernel parameter is malformed or repeated", + )); + } + let mut value = 0u32; + for &digit in digits { + if !digit.is_ascii_digit() || value > MAX_HOST_MAPPINGS as u32 { + return Err(violation("the nvx_maps kernel parameter is malformed")); + } + value = value * 10 + u32::from(digit - b'0'); + } + if value == 0 || value > MAX_HOST_MAPPINGS as u32 { + return Err(violation(format!( + "the nvx_maps kernel parameter must be from 1 through {MAX_HOST_MAPPINGS}" + ))); + } + count = value; + found = true; + } + Ok(count) +} diff --git a/nvx-rs/src/protocol/exec.rs b/nvx-rs/src/protocol/exec.rs index 6aa92f21a..2d5f620b9 100644 --- a/nvx-rs/src/protocol/exec.rs +++ b/nvx-rs/src/protocol/exec.rs @@ -19,6 +19,9 @@ pub const MAX_ARGUMENT_BYTES: usize = 4096; /// Largest working directory that hosts send: Linux's `PATH_MAX` without the terminating NUL, one /// byte below the agent's bound. pub const MAX_CWD_BYTES: usize = 4095; +/// Largest working directory that the C guest decoder accepts. Unlike host paths, the guest's +/// wire bound includes all 4096 non-NUL bytes because its terminating NUL is allocated separately. +pub const MAX_GUEST_CWD_BYTES: usize = MAX_ARGUMENT_BYTES; /// Largest number of workload environment entries. pub const MAX_ENVIRONMENT: usize = 256; /// Largest workload timeout the guest agent accepts: any value of the exec request's 32-bit @@ -176,6 +179,25 @@ pub struct ExecPayload { pub inherit_default_env: bool, } +/// An `EXEC` payload as the guest agent reads it, without imposing UTF-8 on guest byte strings. +/// +/// Linux arguments, paths, and environment entries are byte strings. Keeping this form separate +/// from [`ExecPayload`] preserves the host and fake-OpenVMM APIs, which intentionally use UTF-8, +/// while accepting every non-NUL byte sequence that the C guest accepted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GuestExecPayload { + /// Timeout in milliseconds; zero disables it. + pub timeout_ms: u32, + /// The absolute program, followed by its arguments. + pub argv: Vec>, + /// Absolute guest directory to start the program in; `None` selects `/`. + pub cwd: Option>, + /// Explicit `KEY=VALUE` entries, or `None` for the default environment. + pub environment: Option>>, + /// Whether explicit entries layer over the default environment. + pub inherit_default_env: bool, +} + /// Decodes the payload of an `EXEC` request. /// /// It refuses what the guest agent refuses: a truncated payload, a number of arguments outside 1 @@ -275,6 +297,103 @@ pub fn decode_exec_payload(payload: &[u8]) -> Result }) } +/// Decodes an `EXEC` payload with exactly the C guest agent's byte-oriented acceptance rules. +/// +/// This differs from [`decode_exec_payload`] only where Linux permits bytes that are not UTF-8 +/// and at the C guest's 4096-byte working-directory boundary. The host-facing decoder remains +/// text-oriented, so this guest decoder does not broaden host behavior. +pub fn decode_guest_exec_payload(payload: &[u8]) -> Result { + let truncated = || violation("the exec request is truncated"); + if payload.len() < 8 { + return Err(truncated()); + } + let timeout_ms = u32_at(payload, 0).ok_or_else(truncated)?; + let count = usize::from(u16_at(payload, 4).ok_or_else(truncated)?); + let extension = u16_at(payload, 6).ok_or_else(truncated)?; + if count == 0 || count > MAX_ARGUMENTS { + return Err(violation(format!( + "exec requires 1 to {MAX_ARGUMENTS} arguments" + ))); + } + let (flags, environment_count, cwd_len, mut offset) = match extension { + 0 => (0, 0, 0, 8), + EXEC_EXTENDED if payload.len() >= 16 => { + let flags = u16_at(payload, 8).ok_or_else(truncated)?; + let environment_count = usize::from(u16_at(payload, 10).ok_or_else(truncated)?); + let cwd_len = u32_at(payload, 12).ok_or_else(truncated)? as usize; + let known = EXEC_CWD_PRESENT | EXEC_ENVIRONMENT_PRESENT | EXEC_INHERIT_DEFAULT_ENV; + let cwd_valid = if flags & EXEC_CWD_PRESENT == 0 { + cwd_len == 0 + } else { + (1..=MAX_GUEST_CWD_BYTES).contains(&cwd_len) + }; + let environment_valid = flags & EXEC_ENVIRONMENT_PRESENT != 0 + || (environment_count == 0 && flags & EXEC_INHERIT_DEFAULT_ENV == 0); + if flags & !known != 0 + || !cwd_valid + || !environment_valid + || environment_count > MAX_ENVIRONMENT + { + return Err(violation( + "the exec request has unknown flags or inconsistent fields", + )); + } + (flags, environment_count, cwd_len, 16) + } + EXEC_EXTENDED => return Err(truncated()), + _ => return Err(violation("the exec request has an unknown extension")), + }; + let mut argv = Vec::with_capacity(count); + for _ in 0..count { + argv.push(prefixed_bytes(payload, &mut offset, "arguments")?); + } + if argv.first().and_then(|argument| argument.first()) != Some(&b'/') { + return Err(violation("exec program must be an absolute guest path")); + } + let cwd = if flags & EXEC_CWD_PRESENT == 0 { + None + } else { + let cwd = bytes_at(payload, offset, cwd_len, "working directory")?.to_vec(); + offset = offset + .checked_add(cwd_len) + .ok_or_else(|| violation("the exec request is truncated"))?; + if cwd.first() != Some(&b'/') { + return Err(violation( + "exec working directory must be an absolute guest path", + )); + } + Some(cwd) + }; + let mut environment = Vec::with_capacity(environment_count); + let mut names = BTreeSet::new(); + for _ in 0..environment_count { + let entry = prefixed_bytes(payload, &mut offset, "environment entries")?; + let Some(equals) = entry + .iter() + .position(|&byte| byte == b'=') + .filter(|&at| at != 0) + else { + return Err(violation( + "exec environment entries must be KEY=VALUE byte strings", + )); + }; + if !names.insert(entry[..equals].to_vec()) { + return Err(violation("exec environment names must be unique")); + } + environment.push(entry); + } + if offset != payload.len() { + return Err(violation("the exec request has trailing bytes")); + } + Ok(GuestExecPayload { + timeout_ms, + argv, + cwd, + environment: (flags & EXEC_ENVIRONMENT_PRESENT != 0).then_some(environment), + inherit_default_env: flags & EXEC_INHERIT_DEFAULT_ENV != 0, + }) +} + /// Reads the text at `offset` that its `u32` length precedes, of 1 to [`MAX_ARGUMENT_BYTES`] /// bytes, and advances `offset` past it. fn prefixed_text(payload: &[u8], offset: &mut usize, what: &str) -> Result { @@ -311,3 +430,45 @@ fn text_at( String::from_utf8(bytes.to_vec()) .map_err(|_| violation(format!("exec {what} must be valid UTF-8"))) } + +/// Reads one little-endian length-prefixed byte string and advances `offset`. +fn prefixed_bytes( + payload: &[u8], + offset: &mut usize, + what: &str, +) -> Result, ProtocolError> { + let length = u32_at(payload, *offset) + .ok_or_else(|| violation("the exec request is truncated"))? as usize; + *offset = offset + .checked_add(4) + .ok_or_else(|| violation("the exec request is truncated"))?; + if length == 0 || length > MAX_ARGUMENT_BYTES { + return Err(violation(format!( + "exec {what} must be 1 to {MAX_ARGUMENT_BYTES} bytes" + ))); + } + let bytes = bytes_at(payload, *offset, length, what)?.to_vec(); + *offset = offset + .checked_add(length) + .ok_or_else(|| violation("the exec request is truncated"))?; + Ok(bytes) +} + +/// Returns one bounded non-NUL byte range without imposing UTF-8. +fn bytes_at<'a>( + payload: &'a [u8], + offset: usize, + length: usize, + what: &str, +) -> Result<&'a [u8], ProtocolError> { + let bytes = offset + .checked_add(length) + .and_then(|end| payload.get(offset..end)) + .ok_or_else(|| violation("the exec request is truncated"))?; + if bytes.contains(&0) { + return Err(violation(format!( + "exec {what} must not contain NUL characters" + ))); + } + Ok(bytes) +} diff --git a/nvx-rs/src/protocol/features.rs b/nvx-rs/src/protocol/features.rs index 900d6ab31..2252b2239 100644 --- a/nvx-rs/src/protocol/features.rs +++ b/nvx-rs/src/protocol/features.rs @@ -36,8 +36,8 @@ impl GuestFeatures { /// `nvx_maps=` kernel command-line token announces, keeps the shared export root-only, and /// refuses workloads until every entry is mounted. pub const HOST_MAPPING_TABLE: Self = Self(1 << 6); - /// The features that the host depends on. - pub const REQUIRED: Self = Self( + /// Features announced by the direct-mode managed agent. + pub const DIRECT_AGENT: Self = Self( Self::CANCEL.0 | Self::WORKLOAD_ACCOUNT.0 | Self::EXEC_CGROUP.0 @@ -45,6 +45,11 @@ impl GuestFeatures { | Self::EXEC_CWD.0 | Self::HOST_MAPPING_TABLE.0, ); + /// Features announced by the managed agent that launches a sandbox container. + pub const SANDBOX_AGENT: Self = + Self(Self::CANCEL.0 | Self::WORKLOAD_ACCOUNT.0 | Self::EXEC_ENVIRONMENT.0); + /// The features that the host depends on. + pub const REQUIRED: Self = Self::DIRECT_AGENT; /// Human-readable names of every required feature, used in compatibility errors. pub(crate) const NAMES: [(Self, &'static str); 6] = [ diff --git a/nvx-rs/src/protocol/maps.rs b/nvx-rs/src/protocol/maps.rs index 8a85f082f..78de1379a 100644 --- a/nvx-rs/src/protocol/maps.rs +++ b/nvx-rs/src/protocol/maps.rs @@ -95,6 +95,26 @@ pub struct MapsPayload { pub maps: Vec, } +/// One host mapping as the guest reads it, without imposing UTF-8 on Linux paths. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GuestHostMap { + /// Path relative to the guest's mount of the aggregate export. + pub source: Vec, + /// Absolute path at which the guest bind-mounts the source. + pub target: Vec, + /// Whether the bind is remounted read-only. + pub read_only: bool, +} + +/// A `MAPS` payload as the byte-oriented guest decoder reads it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GuestMapsPayload { + /// Index in the table of the payload's first entry. + pub first: u32, + /// Entries in table order. + pub maps: Vec, +} + /// Decodes the payload of an `APP_MAPS` request. /// /// It refuses what the guest agent refuses of a payload and its entries: a truncated payload, @@ -132,6 +152,7 @@ pub fn decode_maps_payload(payload: &[u8]) -> Result refuses", )); } + maps.push(HostMap { source, target, @@ -144,6 +165,59 @@ pub fn decode_maps_payload(payload: &[u8]) -> Result Ok(MapsPayload { first, maps }) } +/// Decodes a `MAPS` payload with the C guest agent's byte-oriented path rules. +/// +/// [`decode_maps_payload`] remains UTF-8 for host and fake-OpenVMM callers. This decoder accepts +/// every non-NUL Linux path byte that the guest can mount. +pub fn decode_guest_maps_payload(payload: &[u8]) -> Result { + let truncated = || violation("the host mapping request is truncated"); + let first = u32_at(payload, 0).ok_or_else(truncated)?; + let count = u32_at(payload, 4).ok_or_else(truncated)?; + let capacity = usize::try_from(count) + .ok() + .filter(|count| *count <= MAX_HOST_MAPPINGS) + .ok_or_else(|| violation("the host mapping request has too many entries"))?; + let mut maps = Vec::with_capacity(capacity); + let mut offset = MAPS_HEADER_LEN; + for _ in 0..count { + let (Some(flags), Some(source_len), Some(target_len)) = ( + u16_at(payload, offset), + u16_at(payload, offset.saturating_add(2)), + u16_at(payload, offset.saturating_add(4)), + ) else { + return Err(truncated()); + }; + offset = offset + .checked_add(MAP_ENTRY_HEADER_LEN) + .ok_or_else(truncated)?; + let mut take = |length: u16| -> Result, ProtocolError> { + let end = offset + .checked_add(usize::from(length)) + .ok_or_else(truncated)?; + let bytes = payload.get(offset..end).ok_or_else(truncated)?.to_vec(); + offset = end; + Ok(bytes) + }; + let source = take(source_len)?; + let target = take(target_len)?; + if flags & !MAP_READ_ONLY != 0 || !acceptable_bytes(&source, &target) { + return Err(violation( + "a host mapping has unknown flags or a source or target that the guest agent \ + refuses", + )); + } + maps.push(GuestHostMap { + source, + target, + read_only: flags & MAP_READ_ONLY != 0, + }); + } + if offset != payload.len() { + return Err(violation("the host mapping request has trailing bytes")); + } + Ok(GuestMapsPayload { first, maps }) +} + /// Whether the guest agent accepts the paths of a mapping: a relative `source` and an absolute /// `target` of at most [`MAX_MAP_PATH_BYTES`] without NUL characters, whose `/`-separated /// components are neither empty, `.`, nor `..`. @@ -160,3 +234,19 @@ fn acceptable(source: &str, target: &str) -> bool { && normal(source) && target.strip_prefix('/').is_some_and(normal) } + +/// Applies the guest agent's byte-oriented source and target path rules. +fn acceptable_bytes(source: &[u8], target: &[u8]) -> bool { + let normal = |components: &[u8]| { + !components.is_empty() + && components + .split(|&byte| byte == b'/') + .all(|component| !matches!(component, b"" | b"." | b"..")) + }; + [source, target] + .iter() + .all(|path| !path.is_empty() && path.len() <= MAX_MAP_PATH_BYTES && !path.contains(&0)) + && source != b"." + && normal(source) + && target.strip_prefix(b"/").is_some_and(normal) +} diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index 96118f220..776c66075 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -37,22 +37,25 @@ pub use app::{ BUSY, CWD_FAILED, ExitCategory, INVALID_REQUEST, LAUNCH_FAILED, MAPPING_FAILED, MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, decode_app, encode_app, }; -pub use boot::{MAPS_TOKEN, WORKLOAD_ACCOUNT_TOKEN}; +pub use boot::{MAPS_TOKEN, WORKLOAD_ACCOUNT_TOKEN, parse_host_mapping_count}; pub use exec::{ EXEC_CWD_PRESENT, EXEC_ENVIRONMENT_PRESENT, EXEC_EXTENDED, EXEC_INHERIT_DEFAULT_ENV, - ExecPayload, MAX_ARGUMENT_BYTES, MAX_ARGUMENTS, MAX_CWD_BYTES, MAX_ENVIRONMENT, - MAX_OUTPUT_BYTES, MAX_TIMEOUT_MS, Workload, WorkloadEnvironment, decode_exec_payload, - encode_exec_payload, + ExecPayload, GuestExecPayload, MAX_ARGUMENT_BYTES, MAX_ARGUMENTS, MAX_CWD_BYTES, + MAX_ENVIRONMENT, MAX_GUEST_CWD_BYTES, MAX_OUTPUT_BYTES, MAX_TIMEOUT_MS, Workload, + WorkloadEnvironment, decode_exec_payload, decode_guest_exec_payload, encode_exec_payload, }; pub use features::GuestFeatures; pub use maps::{ - HostMap, MAP_ENTRY_HEADER_LEN, MAP_READ_ONLY, MAPS_HEADER_LEN, MAX_HOST_MAPPINGS, - MAX_MAP_PATH_BYTES, MapsPayload, decode_maps_payload, encode_maps_payloads, + GuestHostMap, GuestMapsPayload, HostMap, MAP_ENTRY_HEADER_LEN, MAP_READ_ONLY, MAPS_HEADER_LEN, + MAX_HOST_MAPPINGS, MAX_MAP_PATH_BYTES, MapsPayload, decode_guest_maps_payload, + decode_maps_payload, encode_maps_payloads, }; pub use outer::{ - CAPABILITY_LEN, OUTER_DATA, OUTER_ERROR, OUTER_HEADER_LEN, OUTER_HOST_ATTACH, OUTER_MAGIC, - OUTER_MAX_PAYLOAD, OUTER_READY, OUTER_RESET, OUTER_VERSION, OUTER_WAIT, OuterHeader, - decode_outer_header, encode_outer, + CAPABILITY_LEN, OUTER_ACK, OUTER_CREDIT, OUTER_DATA, OUTER_ERROR, OUTER_GUEST_ATTACH, + OUTER_HEADER_LEN, OUTER_HOST_ATTACH, OUTER_MAGIC, OUTER_MAX_PAYLOAD, OUTER_MAX_RECEIVE_WINDOW, + OUTER_MIN_RECEIVE_WINDOW, OUTER_READY, OUTER_RECEIVE_WINDOW, OUTER_RESET, OUTER_VERSION, + OUTER_WAIT, OuterHeader, decode_ack_payload, decode_credit_payload, decode_outer_header, + encode_ack_payload, encode_credit_payload, encode_outer, }; /// Protocol violation detected while encoding or decoding. diff --git a/nvx-rs/src/protocol/outer.rs b/nvx-rs/src/protocol/outer.rs index 7116fcadc..de62b475a 100644 --- a/nvx-rs/src/protocol/outer.rs +++ b/nvx-rs/src/protocol/outer.rs @@ -4,6 +4,15 @@ //! A record is a 44-byte header (the magic, the version, the record type, a zero flags byte, the //! session's 16-byte instance ID, its epoch, the record's sequence number, and the payload length) //! followed by the payload. +//! +//! The guest first sends [`OUTER_GUEST_ATTACH`] with zero identity fields. The broker answers +//! with [`OUTER_RESET`]. A valid reset has no payload and a nonzero epoch; the guest adopts its +//! identity, expects the next broker-to-guest sequence after the reset's sequence, resets its own +//! sequence to zero, and sends [`OUTER_ACK`] at sequence zero with [`OUTER_RECEIVE_WINDOW`]. +//! Guest records continue at sequence one. Each [`OUTER_DATA`] must match the current identity, +//! epoch, and next sequence exactly. Once the guest consumes its payload, it sends +//! [`OUTER_CREDIT`] for the payload byte count. ACK, CREDIT, and guest DATA share the ordered +//! guest sequence; a later RESET starts a new epoch and abandons work from the old one. use super::{ProtocolError, array, violation}; @@ -15,11 +24,21 @@ pub const OUTER_VERSION: u16 = 1; pub const OUTER_HEADER_LEN: usize = 44; /// Largest payload of one outer record. pub const OUTER_MAX_PAYLOAD: usize = 65_536; +/// Receive window that the NVX guest advertises after a reset. +pub const OUTER_RECEIVE_WINDOW: u32 = 1024 * 1024; +/// Smallest receive window that the broker accepts in an acknowledgement. +pub const OUTER_MIN_RECEIVE_WINDOW: u32 = OUTER_MAX_PAYLOAD as u32; +/// Largest receive window that the broker accepts. +pub const OUTER_MAX_RECEIVE_WINDOW: u32 = 4 * 1024 * 1024 + 10; +/// Bootstrap record with which the guest agent attaches to the broker. +pub const OUTER_GUEST_ATTACH: u8 = 1; /// Record with which the host client attaches, carrying the launch capability. pub const OUTER_HOST_ATTACH: u8 = 2; /// Record with which OpenVMM resets the session. pub const OUTER_RESET: u8 = 3; +/// Record with which the guest acknowledges a reset and advertises its receive window. +pub const OUTER_ACK: u8 = 4; /// Record that carries an application frame. pub const OUTER_DATA: u8 = 5; /// Record with which OpenVMM asks an attaching host client to wait, because another client holds @@ -30,6 +49,8 @@ pub const OUTER_WAIT: u8 = 6; pub const OUTER_READY: u8 = 7; /// Record with which OpenVMM rejects the host client's capability. pub const OUTER_ERROR: u8 = 8; +/// Record with which the guest returns bytes to its receive window. +pub const OUTER_CREDIT: u8 = 9; /// Length of the launch capability that authenticates the host client. pub const CAPABILITY_LEN: usize = 32; @@ -97,3 +118,49 @@ pub fn decode_outer_header(header: &[u8; OUTER_HEADER_LEN]) -> Result Result<[u8; 4], ProtocolError> { + if !(OUTER_MIN_RECEIVE_WINDOW..=OUTER_MAX_RECEIVE_WINDOW).contains(&window) { + return Err(violation( + "control acknowledgement has an invalid receive window", + )); + } + Ok(window.to_le_bytes()) +} + +/// Decodes the initial receive window of an [`OUTER_ACK`] record. +pub fn decode_ack_payload(payload: &[u8]) -> Result { + let window = decode_window(payload, "acknowledgement")?; + if !(OUTER_MIN_RECEIVE_WINDOW..=OUTER_MAX_RECEIVE_WINDOW).contains(&window) { + return Err(violation( + "control acknowledgement has an invalid receive window", + )); + } + Ok(window) +} + +/// Encodes the number of consumed bytes in an [`OUTER_CREDIT`] record. +pub fn encode_credit_payload(bytes: u32) -> Result<[u8; 4], ProtocolError> { + if bytes == 0 || bytes > OUTER_MAX_RECEIVE_WINDOW { + return Err(violation("control credit has an invalid byte count")); + } + Ok(bytes.to_le_bytes()) +} + +/// Decodes the number of consumed bytes in an [`OUTER_CREDIT`] record. +pub fn decode_credit_payload(payload: &[u8]) -> Result { + let bytes = decode_window(payload, "credit")?; + if bytes == 0 || bytes > OUTER_MAX_RECEIVE_WINDOW { + return Err(violation("control credit has an invalid byte count")); + } + Ok(bytes) +} + +/// Decodes the common four-byte ACK or CREDIT receive-window payload. +fn decode_window(payload: &[u8], record: &str) -> Result { + let bytes: [u8; 4] = payload + .try_into() + .map_err(|_| violation(format!("control {record} payload is not four bytes")))?; + Ok(u32::from_le_bytes(bytes)) +} diff --git a/nvx-rs/src/protocol/tests.rs b/nvx-rs/src/protocol/tests.rs index 4acc57145..506e3799b 100644 --- a/nvx-rs/src/protocol/tests.rs +++ b/nvx-rs/src/protocol/tests.rs @@ -957,8 +957,15 @@ fn record_definitions_match_the_guest_agent_source() { format!("#define OUTER_HEADER_LEN {OUTER_HEADER_LEN}U"), format!("#define OUTER_MAX_PAYLOAD {OUTER_MAX_PAYLOAD}U"), format!("#define OUTER_VERSION {OUTER_VERSION}U"), + format!("#define OUTER_GUEST_ATTACH {OUTER_GUEST_ATTACH}U"), format!("#define OUTER_RESET {OUTER_RESET}U"), + format!("#define OUTER_ACK {OUTER_ACK}U"), format!("#define OUTER_DATA {OUTER_DATA}U"), + format!("#define OUTER_CREDIT {OUTER_CREDIT}U"), + format!( + "#define OUTER_RECEIVE_WINDOW ({}U * 1024U)", + OUTER_RECEIVE_WINDOW / 1024 + ), format!("#define APP_HEADER_LEN {APP_HEADER_LEN}U"), format!("#define APP_VERSION {APP_VERSION}U"), format!("#define APP_PING {APP_PING}U"), @@ -978,3 +985,226 @@ fn record_definitions_match_the_guest_agent_source() { ); } } + +#[test] +fn guest_outer_records_match_golden_bytes() { + let instance = core::array::from_fn(|index| index as u8); + let attach = encode_outer(OUTER_GUEST_ATTACH, &[0; 16], 0, 0, &[]).unwrap(); + assert_eq!( + hex(&attach), + concat!( + "4e56585301000100", + "00000000000000000000000000000000", + "0000000000000000", + "0000000000000000", + "00000000", + ) + ); + + let ack_payload = encode_ack_payload(OUTER_RECEIVE_WINDOW).unwrap(); + let ack = encode_outer(OUTER_ACK, &instance, 7, 0, &ack_payload).unwrap(); + assert_eq!( + hex(&ack), + concat!( + "4e56585301000400", + "000102030405060708090a0b0c0d0e0f", + "0700000000000000", + "0000000000000000", + "04000000", + "00001000", + ) + ); + let header = decode_outer_header(&array(&ack[..OUTER_HEADER_LEN])).unwrap(); + assert_eq!((header.record_type, header.sequence), (OUTER_ACK, 0)); + assert_eq!( + decode_ack_payload(&ack[OUTER_HEADER_LEN..]).unwrap(), + OUTER_RECEIVE_WINDOW + ); + + let credit_payload = encode_credit_payload(24).unwrap(); + let credit = encode_outer(OUTER_CREDIT, &instance, 7, 1, &credit_payload).unwrap(); + assert_eq!( + hex(&credit), + concat!( + "4e56585301000900", + "000102030405060708090a0b0c0d0e0f", + "0700000000000000", + "0100000000000000", + "04000000", + "18000000", + ) + ); + let header = decode_outer_header(&array(&credit[..OUTER_HEADER_LEN])).unwrap(); + assert_eq!((header.record_type, header.sequence), (OUTER_CREDIT, 1)); + assert_eq!( + decode_credit_payload(&credit[OUTER_HEADER_LEN..]).unwrap(), + 24 + ); +} + +#[test] +fn guest_window_payloads_enforce_broker_bounds() { + for window in [ + OUTER_MIN_RECEIVE_WINDOW, + OUTER_RECEIVE_WINDOW, + OUTER_MAX_RECEIVE_WINDOW, + ] { + let payload = encode_ack_payload(window).unwrap(); + assert_eq!(decode_ack_payload(&payload).unwrap(), window); + } + for window in [ + 0, + OUTER_MIN_RECEIVE_WINDOW - 1, + OUTER_MAX_RECEIVE_WINDOW + 1, + ] { + assert!(encode_ack_payload(window).is_err(), "{window}"); + assert!( + decode_ack_payload(&window.to_le_bytes()).is_err(), + "{window}" + ); + } + for credit in [1, 24, OUTER_MAX_RECEIVE_WINDOW] { + let payload = encode_credit_payload(credit).unwrap(); + assert_eq!(decode_credit_payload(&payload).unwrap(), credit); + } + for credit in [0, OUTER_MAX_RECEIVE_WINDOW + 1] { + assert!(encode_credit_payload(credit).is_err(), "{credit}"); + assert!( + decode_credit_payload(&credit.to_le_bytes()).is_err(), + "{credit}" + ); + } + assert!(decode_ack_payload(&[0; 3]).is_err()); + assert!(decode_credit_payload(&[0; 5]).is_err()); +} + +#[test] +fn host_mapping_count_matches_the_guest_command_line_parser() { + for (command_line, expected) in [ + (&b""[..], 0), + (b"quiet nvx_map=0,/legacy,rw", 0), + (b"quiet\tnvx_maps=1\nlegacy", 1), + (b"nvx_maps=0001", 1), + (b"nvx_maps=4096", 4096), + ] { + assert_eq!( + parse_host_mapping_count(command_line).unwrap(), + expected, + "{:?}", + String::from_utf8_lossy(command_line) + ); + } + for command_line in [ + &b"nvx_maps="[..], + b"nvx_maps=0", + b"nvx_maps=4097", + b"nvx_maps=99999999999", + b"nvx_maps=1x", + b"nvx_maps=1 nvx_maps=1", + ] { + assert!( + parse_host_mapping_count(command_line).is_err(), + "{:?}", + String::from_utf8_lossy(command_line) + ); + } +} + +#[test] +fn agent_feature_sets_match_the_c_modes() { + assert_eq!(GuestFeatures::DIRECT_AGENT.bits(), 0x7d); + assert_eq!(GuestFeatures::SANDBOX_AGENT.bits(), 0x15); + assert_eq!( + GuestFeatures::DIRECT_AGENT.bits() & (1 << 1), + 0, + "retired bit one stays clear" + ); +} + +#[test] +fn host_exec_encoding_round_trips_through_the_guest_decoder() { + let environment = strings(&["FOO=a b", "EMPTY="]); + let workload = Workload { + argv: strings(&["/bin/sh", "-c", "printf ok"]), + timeout_ms: u32::MAX, + cwd: Some("/work"), + environment: WorkloadEnvironment::Layered(&environment), + }; + let payload = encode_exec_payload(&workload).unwrap(); + let decoded = decode_guest_exec_payload(&payload).unwrap(); + assert_eq!(decoded.timeout_ms, u32::MAX); + assert_eq!( + decoded.argv, + [b"/bin/sh".to_vec(), b"-c".to_vec(), b"printf ok".to_vec()] + ); + assert_eq!(decoded.cwd, Some(b"/work".to_vec())); + assert_eq!( + decoded.environment, + Some(vec![b"FOO=a b".to_vec(), b"EMPTY=".to_vec()]) + ); + assert!(decoded.inherit_default_env); +} + +#[test] +fn guest_exec_decoder_accepts_linux_bytes_and_the_c_cwd_boundary() { + let mut payload = Vec::new(); + payload.extend_from_slice(&0u32.to_le_bytes()); + payload.extend_from_slice(&1u16.to_le_bytes()); + payload.extend_from_slice(&EXEC_EXTENDED.to_le_bytes()); + payload.extend_from_slice(&(EXEC_CWD_PRESENT | EXEC_ENVIRONMENT_PRESENT).to_le_bytes()); + payload.extend_from_slice(&1u16.to_le_bytes()); + payload.extend_from_slice(&(MAX_GUEST_CWD_BYTES as u32).to_le_bytes()); + payload.extend_from_slice(&2u32.to_le_bytes()); + payload.extend_from_slice(b"/\xff"); + payload.push(b'/'); + payload.extend(std::iter::repeat_n(b'x', MAX_GUEST_CWD_BYTES - 1)); + payload.extend_from_slice(&3u32.to_le_bytes()); + payload.extend_from_slice(b"K=\xff"); + + assert!(decode_exec_payload(&payload).is_err()); + let decoded = decode_guest_exec_payload(&payload).unwrap(); + assert_eq!(decoded.argv, [b"/\xff".to_vec()]); + assert_eq!(decoded.cwd.as_ref().unwrap().len(), MAX_GUEST_CWD_BYTES); + assert_eq!(decoded.environment, Some(vec![b"K=\xff".to_vec()])); +} + +#[test] +fn host_maps_encoding_round_trips_through_the_guest_decoder() { + let maps = [ + map("0/src", "/work", true), + map("1/file", "/etc/value", false), + ]; + let payloads = encode_maps_payloads(&maps, OUTER_MAX_PAYLOAD - APP_HEADER_LEN).unwrap(); + let decoded = decode_guest_maps_payload(&payloads[0]).unwrap(); + assert_eq!(decoded.first, 0); + assert_eq!( + decoded.maps, + [ + GuestHostMap { + source: b"0/src".to_vec(), + target: b"/work".to_vec(), + read_only: true, + }, + GuestHostMap { + source: b"1/file".to_vec(), + target: b"/etc/value".to_vec(), + read_only: false, + }, + ] + ); +} + +#[test] +fn guest_maps_decoder_accepts_non_utf8_linux_paths() { + let mut payload = encode_maps_payloads( + &[map("0/x", "/work", false)], + OUTER_MAX_PAYLOAD - APP_HEADER_LEN, + ) + .unwrap() + .remove(0); + let source_offset = MAPS_HEADER_LEN + MAP_ENTRY_HEADER_LEN; + payload[source_offset + 2] = 0xff; + assert!(decode_maps_payload(&payload).is_err()); + let decoded = decode_guest_maps_payload(&payload).unwrap(); + assert_eq!(decoded.maps[0].source, b"0/\xff"); +} From ec5894ed75097e89de2462e23e2102842a68b3f5 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Wed, 14 Oct 2026 16:10:56 -0300 Subject: [PATCH 2/5] nvx-rs: port the managed guest agent Port the managed agent as a static Rust guest binary while preserving its control session, execution, cgroup, host-mapping, sealed configuration, helper, and diagnostic contracts. Follow the guest programs' conventions. The agent and its execution helper fail with the shared CommandError and exit with the new shared ExitStatus::AgentFailure, 125. Errors are nix's Errno, whose descriptions the diagnostics print, and the safe nvx::guest::sys wrappers make the system calls through nix. Only the forked child, the raw wait-status decoding, which keeps real-time signals, and the duplication of the inherited descriptor reach libc, through nix's re-export. The standard library parses the decimal identities and descriptor, and no resource is dropped by hand. Prepare argv, envp, physical-PWD storage, and descriptor actions before fork. The child allocates nothing and terminates through exec or _exit. The helper builds the workload's environment in memory and passes it to execve, so no process changes its own environment and the binary needs no unsafe entry point. Keep the privileged seam for production wrappers and failure-injecting tests, and cover the complete path with the host-to-agent loopback. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/Cargo.toml | 7 + nvx-rs/README.md | 7 +- nvx-rs/src/bin/nvx-managed-agent.rs | 19 + nvx-rs/src/guest/managed_agent/cgroup.rs | 313 ++++++++ nvx-rs/src/guest/managed_agent/diagnostics.rs | 26 + nvx-rs/src/guest/managed_agent/dispatch.rs | 392 ++++++++++ nvx-rs/src/guest/managed_agent/environment.rs | 99 +++ nvx-rs/src/guest/managed_agent/exec.rs | 715 ++++++++++++++++++ nvx-rs/src/guest/managed_agent/exec_config.rs | 219 ++++++ nvx-rs/src/guest/managed_agent/helper.rs | 295 ++++++++ nvx-rs/src/guest/managed_agent/launch.rs | 366 +++++++++ nvx-rs/src/guest/managed_agent/loopback.rs | 562 ++++++++++++++ nvx-rs/src/guest/managed_agent/mappings.rs | 333 ++++++++ nvx-rs/src/guest/managed_agent/mod.rs | 203 +++++ nvx-rs/src/guest/managed_agent/seam.rs | 331 ++++++++ nvx-rs/src/guest/managed_agent/session.rs | 240 ++++++ nvx-rs/src/guest/managed_agent/tty.rs | 48 ++ nvx-rs/src/guest/mod.rs | 10 +- nvx-rs/src/guest/parse.rs | 48 +- nvx-rs/src/guest/status.rs | 9 +- nvx-rs/src/guest/sys/fd.rs | 114 ++- nvx-rs/src/guest/sys/filesystem.rs | 69 ++ nvx-rs/src/guest/sys/mod.rs | 48 +- nvx-rs/src/guest/sys/poll.rs | 34 + nvx-rs/src/guest/sys/process.rs | 317 +++++++- nvx-rs/src/guest/sys/spawn.rs | 623 +++++++++++++++ nvx-rs/src/guest/sys/terminal.rs | 18 + 27 files changed, 5440 insertions(+), 25 deletions(-) create mode 100644 nvx-rs/src/bin/nvx-managed-agent.rs create mode 100644 nvx-rs/src/guest/managed_agent/cgroup.rs create mode 100644 nvx-rs/src/guest/managed_agent/diagnostics.rs create mode 100644 nvx-rs/src/guest/managed_agent/dispatch.rs create mode 100644 nvx-rs/src/guest/managed_agent/environment.rs create mode 100644 nvx-rs/src/guest/managed_agent/exec.rs create mode 100644 nvx-rs/src/guest/managed_agent/exec_config.rs create mode 100644 nvx-rs/src/guest/managed_agent/helper.rs create mode 100644 nvx-rs/src/guest/managed_agent/launch.rs create mode 100644 nvx-rs/src/guest/managed_agent/loopback.rs create mode 100644 nvx-rs/src/guest/managed_agent/mappings.rs create mode 100644 nvx-rs/src/guest/managed_agent/mod.rs create mode 100644 nvx-rs/src/guest/managed_agent/seam.rs create mode 100644 nvx-rs/src/guest/managed_agent/session.rs create mode 100644 nvx-rs/src/guest/managed_agent/tty.rs create mode 100644 nvx-rs/src/guest/sys/filesystem.rs create mode 100644 nvx-rs/src/guest/sys/poll.rs create mode 100644 nvx-rs/src/guest/sys/spawn.rs create mode 100644 nvx-rs/src/guest/sys/terminal.rs diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index 67e466d76..f95ed0e08 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -121,6 +121,13 @@ required-features = ["guest"] doc = false test = false +[[bin]] +name = "nvx-managed-agent" +path = "src/bin/nvx-managed-agent.rs" +required-features = ["guest"] +doc = false +test = false + # The guest binaries: small, static, and stripped. [profile.guest] inherits = "release" diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 89120a594..252fb95c7 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -165,6 +165,7 @@ executables as `/sbin/nvx-*`. | `nvx-reseed [--generation-only] SEED_FILE [PREVIOUS]`, `nvx-reseed --sample` | Reseeds the kernel's random number generator from a seed file and checks its VM generation ID, or prints a random sample | | `nvx-exit [CODE]` | Stops the VM with an 8-bit exit code through OpenVMM's control port 0x604 (x86_64) | | `nvx-device-io file ...`, `nvx-device-io network ...` | The benchmark helper that measures file and UDP round-trip I/O and prints one JSON line (x86_64) | +| `nvx-managed-agent CONTROL_TTY ROOTFS HOSTNAME UID GID USER HOME` | Serves the managed control session and supervises sequential workloads; `--exec-config-fd FD -- WORKLOAD ...` is its internal launch helper | Each binary's command line, exit statuses, and byte output are compatibility contracts; the [`nvx::guest`](src/guest/mod.rs) documentation describes each @@ -187,8 +188,10 @@ NVX_BUNDLE=skip cargo test --all-features # unit, golden-fixture, and fake-Ope The tests need no hypervisor. The fake-OpenVMM test drives `Instance` end to end against `nvx-fake-openvmm`, and the protocol tests compare the wire constants with the guest agent's source in `guest/common/nvx-managed-agent.c`. On Linux, -`nvx.py` also lints and tests the guest binaries, and runs their integration -tests against a build of them: +a loopback test drives the host `ControlSession` through a broker shim into the +real managed agent's session, dispatch, and execution engine, and `nvx.py` also +lints and tests the guest binaries, and runs their integration tests against a +build of them: ```bash python3 ../scripts/nvx.py check-guest-binaries diff --git a/nvx-rs/src/bin/nvx-managed-agent.rs b/nvx-rs/src/bin/nvx-managed-agent.rs new file mode 100644 index 000000000..d9514315e --- /dev/null +++ b/nvx-rs/src/bin/nvx-managed-agent.rs @@ -0,0 +1,19 @@ +//! The managed guest agent. It serves NVX's authenticated control session on the guest's control +//! TTY and runs one workload at a time, either as the guest's PID 1 or in a sandbox's root +//! filesystem. +//! +//! `nvx-managed-agent CONTROL_TTY ROOTFS HOSTNAME UID GID USER HOME` starts the agent, and +//! `nvx-managed-agent --exec-config-fd FD -- WORKLOAD...` is its internal execution helper. See +//! `nvx::guest::managed_agent`. + +#[cfg(target_os = "linux")] +fn main() -> std::process::ExitCode { + nvx::guest::managed_agent::main() +} + +// The agent runs only in Linux guests. Elsewhere this stub fails, so that every target that CI +// checks with `--all-features` still builds. +#[cfg(not(target_os = "linux"))] +fn main() -> std::process::ExitCode { + std::process::ExitCode::FAILURE +} diff --git a/nvx-rs/src/guest/managed_agent/cgroup.rs b/nvx-rs/src/guest/managed_agent/cgroup.rs new file mode 100644 index 000000000..ee0726b05 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/cgroup.rs @@ -0,0 +1,313 @@ +use std::path::Path; +use std::time::Duration; + +use crate::guest::sys::{self, ChildStatus, Errno, Mode, MountFlags, OpenFlags, ProcessId}; + +use super::seam::{System, path_c_string}; + +/// Number of ten-millisecond retries after killing a cgroup. +const SETTLE_ATTEMPTS: usize = 200; +/// Delay between cgroup-emptiness checks. +const SETTLE_DELAY: Duration = Duration::from_millis(10); + +/// Mounts cgroup v2 when needed and prepares the per-execution cgroup. +pub(crate) fn prepare(system: &mut impl System) -> Result<(), Errno> { + let root = system.paths().cgroup_root.clone(); + if std::fs::metadata(root.join("cgroup.procs")).is_err() { + mkdir(&root)?; + let target = path_c_string(&root)?; + system.mount( + Some(c"cgroup2"), + &target, + Some(c"cgroup2"), + MountFlags::MS_NOSUID | MountFlags::MS_NODEV | MountFlags::MS_NOEXEC, + )?; + } + let execution = root.join("nvx-exec"); + mkdir(&execution)?; + match std::fs::metadata(execution.join("pids.current")) { + Ok(_) => Ok(()), + Err(error) => match sys::errno_of(&error) { + Errno::ENOENT => write_path(&root.join("cgroup.subtree_control"), b"+pids"), + error => Err(error), + }, + } +} + +/// Returns whether the execution cgroup reports one or more tasks. +pub(crate) fn has_tasks(system: &impl System) -> Result { + let path = system + .paths() + .cgroup_root + .join("nvx-exec") + .join("pids.current"); + let path = path_c_string(&path)?; + let descriptor = sys::open( + &path, + OpenFlags::O_RDONLY | OpenFlags::O_CLOEXEC, + Mode::empty(), + )?; + let mut buffer = [0u8; 32]; + let mut length = 0; + let result = loop { + match descriptor.read(&mut buffer[length..]) { + Ok(count) if count > 0 => { + length += count; + if length < buffer.len() { + continue; + } + break Err(Errno::EOVERFLOW); + } + Ok(_) => break parse_count(&buffer[..length]), + Err(Errno::EINTR) => {} + Err(error) => break Err(error), + } + }; + match result { + Ok(value) => descriptor.close().map(|()| value), + Err(error) => Err(error), + } +} + +/// Parses the canonical decimal form of `pids.current`. +fn parse_count(bytes: &[u8]) -> Result { + if bytes.len() < 2 + || bytes.last() != Some(&b'\n') + || (bytes.first() == Some(&b'0') && bytes.len() != 2) + || !bytes[..bytes.len() - 1].iter().all(u8::is_ascii_digit) + { + return Err(Errno::EPROTO); + } + Ok(bytes.first() != Some(&b'0')) +} + +/// Requests atomic killing of every process in the execution cgroup. +pub(crate) fn kill(system: &mut impl System) -> Result<(), Errno> { + let path = system + .paths() + .cgroup_root + .join("nvx-exec") + .join("cgroup.kill"); + let result = write_path(&path, b"1"); + if let Err(error) = result { + super::diagnostics::error(system, "exec-cgroup-kill", error as i32); + } + result +} + +/// Reaps every currently available child and records the workload's first terminal state. +pub(crate) fn reap(child: ProcessId, status: &mut Option) { + while let Some(observed) = sys::reap_any_nonblocking() { + if observed.process == child && status.is_none() { + *status = Some(observed.status); + } + } +} + +/// Reaps orphaned descendants and returns the cgroup's current occupancy. +pub(crate) fn verify_empty(system: &impl System) -> Result { + while sys::reap_any_nonblocking().is_some() {} + has_tasks(system) +} + +/// Kills and reaps the execution cgroup until it is empty or the deadline expires. +pub(crate) fn settle(system: &mut impl System) -> Result<(), Errno> { + kill(system)?; + for attempt in 0..=SETTLE_ATTEMPTS { + if !verify_empty(system)? { + return Ok(()); + } + if attempt != SETTLE_ATTEMPTS { + std::thread::sleep(SETTLE_DELAY); + } + } + Err(Errno::ETIMEDOUT) +} + +/// Kills the workload process group and, in direct mode, its cgroup. +pub(crate) fn terminate(system: &mut impl System, direct: bool, child: ProcessId) { + sys::kill_process_group(child); + if direct { + let _ = kill(system); + } +} + +/// Moves a sandbox launcher into the container cgroup. +pub(crate) fn write_container_pid(system: &impl System, child: ProcessId) -> Result<(), Errno> { + write_path(&system.paths().container_cgroup_procs, &child.line()) +} + +/// Creates one cgroup directory with mode 0755, accepting an existing path. +fn mkdir(path: &Path) -> Result<(), Errno> { + let path = path_c_string(path)?; + match sys::create_directory(&path, Mode::from_bits_retain(0o755)) { + Ok(()) | Err(Errno::EEXIST) => Ok(()), + Err(error) => Err(error), + } +} + +/// Writes a complete control value and reports explicit close failure. +fn write_path(path: &Path, bytes: &[u8]) -> Result<(), Errno> { + let path = path_c_string(path)?; + let descriptor = sys::open( + &path, + OpenFlags::O_WRONLY | OpenFlags::O_CLOEXEC, + Mode::empty(), + )?; + descriptor.write_all(bytes)?; + descriptor.close() +} + +#[cfg(test)] +mod tests { + use std::process::Command; + + use super::super::seam::{Paths, TestSystem}; + use super::*; + + fn setup() -> (tempfile::TempDir, TestSystem) { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("cgroup"); + let execution = root.join("nvx-exec"); + std::fs::create_dir_all(&execution).unwrap(); + std::fs::write(root.join("cgroup.procs"), b"").unwrap(); + std::fs::write(root.join("cgroup.subtree_control"), b"").unwrap(); + std::fs::write(execution.join("pids.current"), b"0\n").unwrap(); + std::fs::write(execution.join("cgroup.kill"), b"").unwrap(); + std::fs::write(execution.join("cgroup.procs"), b"").unwrap(); + let paths = Paths { + cgroup_root: root, + ..Paths::default() + }; + (temporary, TestSystem::new(paths)) + } + + #[test] + fn task_count_requires_a_canonical_decimal() { + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (_temporary, system) = setup(); + let tasks = system.paths.cgroup_root.join("nvx-exec/pids.current"); + for (contents, expected) in [ + (b"0\n".as_slice(), false), + (b"1\n", true), + (b"10\n", true), + (b"4194304\n", true), + ] { + std::fs::write(&tasks, contents).unwrap(); + assert_eq!(has_tasks(&system).unwrap(), expected, "{contents:?}"); + } + for contents in [ + b"".as_slice(), + b"\n", + b"0", + b"00\n", + b"01\n", + b"-1\n", + b"+1\n", + b" 1\n", + b"1 \n", + b"0\n0\n", + b"1\0\n", + b"max\n", + ] { + std::fs::write(&tasks, contents).unwrap(); + assert_eq!( + has_tasks(&system).unwrap_err(), + Errno::EPROTO, + "{contents:?}" + ); + } + std::fs::write(&tasks, [b'1'; 40]).unwrap(); + assert_eq!(has_tasks(&system).unwrap_err(), Errno::EOVERFLOW); + } + + #[test] + fn missing_and_unreadable_task_counts_are_errors() { + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (_temporary, system) = setup(); + let tasks = system.paths.cgroup_root.join("nvx-exec/pids.current"); + std::fs::remove_file(&tasks).unwrap(); + assert_eq!(has_tasks(&system).unwrap_err(), Errno::ENOENT); + std::fs::create_dir(&tasks).unwrap(); + assert!(has_tasks(&system).is_err()); + } + + #[test] + fn preparation_enables_the_pids_controller_once() { + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (_temporary, mut system) = setup(); + let root = system.paths.cgroup_root.clone(); + prepare(&mut system).unwrap(); + assert_eq!( + std::fs::read(root.join("cgroup.subtree_control")).unwrap(), + b"" + ); + std::fs::remove_file(root.join("nvx-exec/pids.current")).unwrap(); + prepare(&mut system).unwrap(); + assert_eq!( + std::fs::read(root.join("cgroup.subtree_control")).unwrap(), + b"+pids" + ); + std::fs::remove_file(root.join("cgroup.subtree_control")).unwrap(); + assert_eq!(prepare(&mut system).unwrap_err(), Errno::ENOENT); + } + + #[test] + fn settlement_verifies_emptiness_and_propagates_failures() { + if super::super::isolate_process_test( + "NVX_CGROUP_SETTLEMENT_TEST_CHILD", + "guest::managed_agent::cgroup::tests::settlement_verifies_emptiness_and_propagates_failures", + ) { + return; + } + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (_temporary, mut system) = setup(); + let execution = system.paths.cgroup_root.join("nvx-exec"); + settle(&mut system).unwrap(); + assert_eq!(std::fs::read(execution.join("cgroup.kill")).unwrap(), b"1"); + + std::fs::remove_file(execution.join("cgroup.kill")).unwrap(); + assert_eq!(settle(&mut system).unwrap_err(), Errno::ENOENT); + std::fs::write(execution.join("cgroup.kill"), b"").unwrap(); + std::fs::write(execution.join("pids.current"), b"bad\n").unwrap(); + assert_eq!(settle(&mut system).unwrap_err(), Errno::EPROTO); + } + + #[test] + fn verification_reaps_orphaned_children() { + if super::super::isolate_process_test( + "NVX_CGROUP_REAP_TEST_CHILD", + "guest::managed_agent::cgroup::tests::verification_reaps_orphaned_children", + ) { + return; + } + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (_temporary, system) = setup(); + let mut child = Command::new("/bin/true").spawn().unwrap(); + let process = ProcessId::from_child(&child); + while !sys::has_exited(process).unwrap() { + std::thread::yield_now(); + } + assert!(!verify_empty(&system).unwrap()); + assert_eq!(sys::wait(process), None); + let _ = child.wait(); + } + + #[test] + fn unreaped_tasks_at_deadline_are_not_settled() { + if super::super::isolate_process_test( + "NVX_CGROUP_DEADLINE_TEST_CHILD", + "guest::managed_agent::cgroup::tests::unreaped_tasks_at_deadline_are_not_settled", + ) { + return; + } + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (_temporary, mut system) = setup(); + std::fs::write( + system.paths.cgroup_root.join("nvx-exec/pids.current"), + b"1\n", + ) + .unwrap(); + assert_eq!(settle(&mut system).unwrap_err(), Errno::ETIMEDOUT); + } +} diff --git a/nvx-rs/src/guest/managed_agent/diagnostics.rs b/nvx-rs/src/guest/managed_agent/diagnostics.rs new file mode 100644 index 000000000..d802aeb2d --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/diagnostics.rs @@ -0,0 +1,26 @@ +use crate::guest::sys::{self, Errno}; + +use super::seam::System; + +/// Reports a structured fatal or lifecycle error through the diagnostic port. +pub(crate) fn error(system: &mut impl System, stage: &str, status: i32) { + let message = format!("NVX-MANAGED-ERROR: stage={stage} status={status}\n"); + let _ = system.write_diagnostic(message.as_bytes()); +} + +/// Reports a host-mapping failure through both diagnostic channels. +pub(crate) fn mapping_error(system: &mut impl System, status: i32, entry: u32) { + error(system, "host-mapping", status); + let message = format!("NVX-MANAGED-ERROR: stage=host-mapping status={status} entry={entry}\n"); + write_stderr(message.as_bytes()); +} + +/// Writes a diagnostic to the process's standard error, ignoring write failures. +pub(crate) fn write_stderr(message: &[u8]) { + sys::write_standard_error(message); +} + +/// Returns `prefix`, the description of `error`, and a newline. +pub(crate) fn with_description(prefix: &[u8], error: Errno) -> Vec { + [prefix, error.desc().as_bytes(), b"\n"].concat() +} diff --git a/nvx-rs/src/guest/managed_agent/dispatch.rs b/nvx-rs/src/guest/managed_agent/dispatch.rs new file mode 100644 index 000000000..ccd83d505 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/dispatch.rs @@ -0,0 +1,392 @@ +use crate::guest::sys::Errno; +use crate::protocol::{ + self, APP_CANCEL, APP_EXEC, APP_FEATURES, APP_MAPS, APP_PING, APP_READY, APP_STOP, APP_STOPPED, + GuestFeatures, INVALID_REQUEST, MAPPINGS_INCOMPLETE, OUTER_DATA, OUTER_RESET, + UNSUPPORTED_OPERATION, +}; + +use super::diagnostics; +use super::mappings::{HostMappings, MappingError}; +use super::seam::System; +use super::session::{Record, Session}; +use super::{AgentConfig, exec, tty}; + +/// Terminal outcome of the long-lived dispatch loop. +#[derive(Debug)] +pub(crate) enum Outcome { + /// The host requested and acknowledged an orderly stop. + Stopped, +} + +/// Serves control requests until STOP or a fatal session error. +pub(crate) fn run( + system: &mut impl System, + session: &mut Session, + config: &AgentConfig, + mappings: &mut HostMappings, +) -> Result { + session.send_attach()?; + loop { + let record = session.read_record().inspect_err(|error| { + diagnostics::error(system, "outer-read", *error as i32); + })?; + match record.header.record_type { + OUTER_RESET => session.acknowledge_reset(&record).inspect_err(|error| { + diagnostics::error(system, "reset-ack", *error as i32); + })?, + // As in the C agent, a DATA record that fails also reports the `data` stage, after any + // more specific stage. Its status is the error that ends the session, which + // `control-session` reports too: `EPROTO` where C's `errno` was incidental, as after + // a validation failure. + OUTER_DATA => match handle_data(system, session, config, mappings, &record) { + Ok(Some(outcome)) => return Ok(outcome), + Ok(None) => {} + Err(error) => { + diagnostics::error(system, "data", error as i32); + return Err(error); + } + }, + record_type => { + diagnostics::error(system, "outer-type", i32::from(record_type)); + return Err(Errno::EPROTO); + } + } + } +} + +/// Validates one DATA record and serves its application request, returning the outcome that +/// ends the session, if any. +fn handle_data( + system: &mut impl System, + session: &mut Session, + config: &AgentConfig, + mappings: &mut HostMappings, + record: &Record, +) -> Result, Errno> { + let request = match session.validate_data(record) { + Ok(request) => request, + Err(failure) => { + diagnostics::error(system, failure.kind.stage(), failure.status); + return Err(failure.into_error()); + } + }; + match request.kind { + APP_PING => { + if request.payload.is_empty() { + session + .send_app(APP_READY, request.request_id, 0, &[]) + .inspect_err(|error| { + diagnostics::error(system, "ping-ready", *error as i32); + })?; + } else { + diagnostics::error(system, "ping-payload", request.payload.len() as i32); + session.send_error(request.request_id, Errno::EINVAL as i32, INVALID_REQUEST)?; + } + } + APP_EXEC => { + if mappings.applied != mappings.expected { + session.send_error( + request.request_id, + Errno::EAGAIN as i32, + MAPPINGS_INCOMPLETE, + )?; + return Ok(None); + } + let execution = match protocol::decode_guest_exec_payload(request.payload) { + Ok(execution) => execution, + Err(_) => { + session.send_error( + request.request_id, + Errno::EINVAL as i32, + INVALID_REQUEST, + )?; + return Ok(None); + } + }; + exec::run(system, session, config, request.request_id, &execution)?; + } + APP_CANCEL => { + // The targeted workload has already reported its outcome. + } + APP_FEATURES => { + if request.payload.is_empty() { + let features = if config.direct { + GuestFeatures::DIRECT_AGENT + } else { + GuestFeatures::SANDBOX_AGENT + }; + session.send_app(APP_READY, request.request_id, 0, &features.encode())?; + } else { + session.send_error(request.request_id, Errno::EINVAL as i32, INVALID_REQUEST)?; + } + } + APP_MAPS => match super::mappings::apply_request(system, mappings, request.payload) { + Ok(()) => session.send_app(APP_READY, request.request_id, 0, &[])?, + Err(MappingError::Invalid) => { + session.send_error(request.request_id, Errno::EINVAL as i32, INVALID_REQUEST)? + } + Err(MappingError::Mount { status, entry }) => { + diagnostics::mapping_error(system, status, entry); + session.send_error(request.request_id, status, protocol::MAPPING_FAILED)?; + } + }, + APP_STOP => { + if !request.payload.is_empty() { + return Err(Errno::EPROTO); + } + session.send_app(APP_STOPPED, request.request_id, 0, &[])?; + tty::drain(system, session.descriptor()); + return Ok(Some(Outcome::Stopped)); + } + _ => session.send_error( + request.request_id, + Errno::ENOTSUP as i32, + std::str::from_utf8(UNSUPPORTED_OPERATION).expect("the category is ASCII"), + )?, + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::io::{Read, Write}; + use std::os::unix::net::UnixStream; + use std::thread; + + use crate::protocol::{ + APP_ERROR, OUTER_ACK, OUTER_CREDIT, OUTER_GUEST_ATTACH, OUTER_HEADER_LEN, + OUTER_RECEIVE_WINDOW, OuterHeader, decode_ack_payload, decode_app, decode_credit_payload, + decode_outer_header, encode_app, encode_outer, + }; + + use super::super::seam::{Paths, TestSystem}; + use super::*; + use crate::guest::sys::FileDescriptor; + + const INSTANCE: [u8; 16] = [7; 16]; + + struct Response { + kind: u8, + request_id: u64, + status: i32, + payload: Vec, + } + + struct Harness { + host: UnixStream, + thread: thread::JoinHandle<(Result, TestSystem)>, + host_sequence: u64, + guest_sequence: u64, + } + + impl Harness { + fn new(direct: bool) -> Self { + let (host, guest) = UnixStream::pair().unwrap(); + let descriptor = FileDescriptor::from_owned(guest.into()); + let thread = thread::spawn(move || { + let mut system = TestSystem::new(Paths::default()); + let config = AgentConfig { + rootfs: CString::new(if direct { "-" } else { "/rootfs" }).unwrap(), + hostname: c"test".to_owned(), + uid: c"1000".to_owned(), + gid: c"1000".to_owned(), + user: c"workload".to_owned(), + home: c"/home/workload".to_owned(), + direct, + }; + let mut session = Session::new(descriptor); + let mut mappings = HostMappings::default(); + let outcome = run(&mut system, &mut session, &config, &mut mappings); + (outcome, system) + }); + let mut harness = Self { + host, + thread, + host_sequence: 8, + guest_sequence: 1, + }; + let (header, payload) = harness.read_outer(); + assert_eq!(header.record_type, OUTER_GUEST_ATTACH); + assert!(payload.is_empty()); + harness + .host + .write_all(&encode_outer(OUTER_RESET, &INSTANCE, 1, 7, &[]).unwrap()) + .unwrap(); + let (header, payload) = harness.read_outer(); + assert_eq!((header.record_type, header.sequence), (OUTER_ACK, 0)); + assert_eq!(decode_ack_payload(&payload).unwrap(), OUTER_RECEIVE_WINDOW); + harness + } + + fn request(&mut self, kind: u8, payload: &[u8]) -> Response { + let frame = encode_app(kind, 42, 0, payload); + self.host + .write_all( + &encode_outer(OUTER_DATA, &INSTANCE, 1, self.host_sequence, &frame).unwrap(), + ) + .unwrap(); + self.host_sequence += 1; + let (credit, payload) = self.read_outer(); + assert_eq!( + (credit.record_type, credit.sequence), + (OUTER_CREDIT, self.guest_sequence) + ); + assert_eq!(decode_credit_payload(&payload).unwrap(), frame.len() as u32); + self.guest_sequence += 1; + let (data, frame) = self.read_outer(); + assert_eq!( + (data.record_type, data.sequence), + (OUTER_DATA, self.guest_sequence) + ); + self.guest_sequence += 1; + let frame = decode_app(&frame).unwrap(); + Response { + kind: frame.kind, + request_id: frame.request_id, + status: frame.status, + payload: frame.payload.to_vec(), + } + } + + fn stop(mut self) -> TestSystem { + let response = self.request(APP_STOP, &[]); + assert_eq!( + ( + response.kind, + response.request_id, + response.status, + response.payload.as_slice() + ), + (APP_STOPPED, 42, 0, b"".as_slice()) + ); + let (outcome, system) = self.thread.join().unwrap(); + assert!(matches!(outcome, Ok(Outcome::Stopped))); + system + } + + fn fail(mut self, record: &[u8]) -> Vec { + self.host.write_all(record).unwrap(); + let (outcome, system) = self.thread.join().unwrap(); + assert!(outcome.is_err()); + system.diagnostics + } + + fn read_outer(&mut self) -> (OuterHeader, Vec) { + let mut header = [0u8; OUTER_HEADER_LEN]; + self.host.read_exact(&mut header).unwrap(); + let header = decode_outer_header(&header).unwrap(); + let mut payload = vec![0; header.length]; + self.host.read_exact(&mut payload).unwrap(); + (header, payload) + } + } + + #[test] + fn agent_advertises_the_control_features_of_its_mode_and_stops() { + for (direct, expected) in [ + (true, GuestFeatures::DIRECT_AGENT), + (false, GuestFeatures::SANDBOX_AGENT), + ] { + let mut harness = Harness::new(direct); + let response = harness.request(APP_FEATURES, &[]); + assert_eq!( + (response.kind, response.request_id, response.status), + (APP_READY, 42, 0) + ); + assert_eq!(GuestFeatures::decode(&response.payload).unwrap(), expected); + let system = harness.stop(); + assert_eq!(system.tty_drained, 1); + } + } + + #[test] + fn agent_refuses_feature_payloads_and_unannounced_mapping_tables() { + let mut harness = Harness::new(true); + for (kind, payload) in [ + (APP_FEATURES, b"abc".as_slice()), + (APP_MAPS, b"\0\0\0\0\x01\0\0\0\0\0\x01\0\x02\0".as_slice()), + ] { + let response = harness.request(kind, payload); + assert_eq!( + (response.kind, response.request_id, response.status), + (APP_ERROR, 42, Errno::EINVAL as i32) + ); + assert_eq!(response.payload, INVALID_REQUEST.as_bytes()); + } + harness.stop(); + } + + #[test] + fn agent_refuses_malformed_working_directories() { + let mut harness = Harness::new(true); + let argument = [9u32.to_le_bytes().as_slice(), b"/bin/true"].concat(); + let mut payload = Vec::new(); + payload.extend_from_slice(&0u32.to_le_bytes()); + payload.extend_from_slice(&1u16.to_le_bytes()); + payload.extend_from_slice(&2u16.to_le_bytes()); + payload.extend_from_slice(&argument); + let response = harness.request(APP_EXEC, &payload); + assert_eq!( + (response.kind, response.status, response.payload.as_slice()), + (APP_ERROR, Errno::EINVAL as i32, INVALID_REQUEST.as_bytes()) + ); + harness.stop(); + } + + #[test] + fn data_validation_diagnostics_preserve_the_c_failure_stage_and_status() { + let data = format!( + "NVX-MANAGED-ERROR: stage=data status={}\n", + Errno::EPROTO as i32 + ); + let frame = encode_app(APP_PING, 42, 0, &[]); + let diagnostics = + Harness::new(true).fail(&encode_outer(OUTER_DATA, &INSTANCE, 1, 9, &frame).unwrap()); + assert_eq!( + diagnostics, + [ + b"NVX-MANAGED-ERROR: stage=data-sequence status=9\n", + data.as_bytes() + ] + .concat() + ); + + let diagnostics = + Harness::new(true).fail(&encode_outer(OUTER_DATA, &INSTANCE, 2, 8, &frame).unwrap()); + assert_eq!( + diagnostics, + [ + b"NVX-MANAGED-ERROR: stage=data-identity status=2\n", + data.as_bytes() + ] + .concat() + ); + + let diagnostics = + Harness::new(true).fail(&encode_outer(OUTER_DATA, &INSTANCE, 1, 8, b"x").unwrap()); + assert_eq!( + diagnostics, + [ + b"NVX-MANAGED-ERROR: stage=data-application status=1\n", + data.as_bytes() + ] + .concat() + ); + } + + #[test] + fn a_request_that_fails_after_validation_reports_the_data_stage() { + let frame = encode_app(APP_STOP, 42, 0, b"x"); + let diagnostics = + Harness::new(true).fail(&encode_outer(OUTER_DATA, &INSTANCE, 1, 8, &frame).unwrap()); + assert_eq!( + diagnostics, + format!( + "NVX-MANAGED-ERROR: stage=data status={}\n", + Errno::EPROTO as i32 + ) + .as_bytes() + ); + } +} diff --git a/nvx-rs/src/guest/managed_agent/environment.rs b/nvx-rs/src/guest/managed_agent/environment.rs new file mode 100644 index 000000000..05bb83564 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/environment.rs @@ -0,0 +1,99 @@ +//! Environments that the agent and its helper build in memory and pass to `execve`. +//! +//! An environment changes as C's own did under musl's environment functions: setting a variable, +//! as `setenv` and `putenv` do, replaces its first entry in place or appends one, and unsetting +//! it, as `unsetenv` does, removes every entry. + +use std::ffi::CString; +use std::os::unix::ffi::OsStringExt; + +use crate::guest::sys::{Errno, c_string}; + +/// Copies the current process environment as `NAME=value` entries. +pub(super) fn inherited() -> Result, Errno> { + std::env::vars_os() + .map(|(name, value)| { + let mut entry = name.into_vec(); + entry.push(b'='); + entry.extend(value.into_vec()); + c_string(&entry) + }) + .collect() +} + +/// Sets `name` to `value`, or `EINVAL` if either contains a NUL. +pub(super) fn set(environment: &mut Vec, name: &[u8], value: &[u8]) -> Result<(), Errno> { + let mut entry = Vec::with_capacity(name.len() + value.len() + 1); + entry.extend_from_slice(name); + entry.push(b'='); + entry.extend_from_slice(value); + let entry = c_string(&entry)?; + match environment.iter_mut().find(|current| names(current, name)) { + Some(current) => *current = entry, + None => environment.push(entry), + } + Ok(()) +} + +/// Removes every entry of `name`. +pub(super) fn unset(environment: &mut Vec, name: &[u8]) { + environment.retain(|entry| !names(entry, name)); +} + +/// Returns whether `entry` sets the variable `name`. +fn names(entry: &CString, name: &[u8]) -> bool { + entry + .as_bytes() + .strip_prefix(name) + .is_some_and(|rest| rest.first() == Some(&b'=')) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entries(environment: &[CString]) -> Vec<&[u8]> { + environment.iter().map(|entry| entry.as_bytes()).collect() + } + + #[test] + fn setting_replaces_the_first_entry_in_place_or_appends_one() { + let mut environment = vec![ + c"A=1".to_owned(), + c"AB=2".to_owned(), + c"B=3".to_owned(), + c"A=4".to_owned(), + ]; + set(&mut environment, b"A", b"5").unwrap(); + set(&mut environment, b"C", b"").unwrap(); + assert_eq!( + entries(&environment), + [b"A=5".as_slice(), b"AB=2", b"B=3", b"A=4", b"C="] + ); + assert_eq!(set(&mut environment, b"D", b"\0"), Err(Errno::EINVAL)); + } + + #[test] + fn unsetting_removes_every_entry_of_exactly_that_name() { + let mut environment = vec![ + c"A=1".to_owned(), + c"AB=2".to_owned(), + c"A=3".to_owned(), + c"B=A=4".to_owned(), + ]; + unset(&mut environment, b"A"); + unset(&mut environment, b"MISSING"); + assert_eq!(entries(&environment), [b"AB=2".as_slice(), b"B=A=4"]); + } + + #[test] + fn the_inherited_environment_has_every_variable() { + let environment = inherited().unwrap(); + assert_eq!(environment.len(), std::env::vars_os().count()); + assert!( + environment + .iter() + .all(|entry| entry.as_bytes().contains(&b'=')) + ); + } +} diff --git a/nvx-rs/src/guest/managed_agent/exec.rs b/nvx-rs/src/guest/managed_agent/exec.rs new file mode 100644 index 000000000..9045e0980 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/exec.rs @@ -0,0 +1,715 @@ +use std::time::{Duration, Instant}; + +use crate::guest::sys::{ + self, ChildStatus, Errno, FileDescriptor, Mode, OpenFlags, ProcessId, c_string, +}; +use crate::protocol::{ + self, APP_CANCEL, APP_EXIT, APP_STDERR, APP_STDOUT, BUSY, CWD_FAILED, GuestExecPayload, + LAUNCH_FAILED, OUTER_DATA, OUTER_RESET, +}; + +use super::AgentConfig; +use super::cgroup; +use super::diagnostics::with_description; +use super::exec_config; +use super::launch::{LAUNCH_STAGE_CWD, SpawnRequest}; +use super::seam::{System, path_c_string}; +use super::session::{DataFailureKind, Session}; +use super::tty; + +/// Maximum bytes read from one output descriptor at a time. +const OUTPUT_CHUNK_BYTES: usize = 32 * 1024; +/// Readiness-poll interval, in milliseconds. +const POLL_INTERVAL_MS: u16 = 25; +/// Number of bounded attempts to open a sandbox barrier writer. +const BARRIER_ATTEMPTS: usize = 500; + +/// Executes one validated workload request and streams its terminal outcome. +pub(crate) fn run( + system: &mut impl System, + session: &mut Session, + config: &AgentConfig, + request_id: u64, + execution: &GuestExecPayload, +) -> Result<(), Errno> { + if config.direct { + if let Err(error) = cgroup::prepare(system) { + super::diagnostics::error(system, "exec-cgroup", error as i32); + return session.send_error(request_id, 125, LAUNCH_FAILED); + } + match cgroup::verify_empty(system) { + Ok(false) => {} + Ok(true) => { + super::diagnostics::error(system, "exec-cgroup-verify", Errno::EBUSY as i32); + return session.send_error(request_id, 125, "containment-failed"); + } + Err(error) => { + super::diagnostics::error(system, "exec-cgroup-verify", error as i32); + return session.send_error(request_id, 125, "containment-failed"); + } + } + } + if let Err(failure) = validate_launch_environment(system, config, execution) { + match failure { + EnvironmentFailure::Child(error, diagnostic) => { + let message = with_description(diagnostic, error); + session.send_app(APP_STDERR, request_id, 0, &message)?; + return session.send_app(APP_EXIT, request_id, 125, b"exit"); + } + EnvironmentFailure::Launch(error) => { + super::diagnostics::error(system, "exec-launch", error as i32); + return session.send_error(request_id, 125, LAUNCH_FAILED); + } + } + } + if !config.direct && create_barrier(system).is_err() { + return session.send_error(request_id, 125, LAUNCH_FAILED); + } + + let Ok(Launched { + child, + stdout, + stderr, + report, + }) = launch(system, config, execution) + else { + unlink_barrier(system); + return session.send_error(request_id, 125, LAUNCH_FAILED); + }; + let mut launch_failed = + tty::make_nonblocking(&stdout).is_err() || tty::make_nonblocking(&stderr).is_err(); + if !launch_failed && !config.direct { + launch_failed = cgroup::write_container_pid(system, child).is_err() + || release_barrier(system, child).is_err(); + } + if launch_failed { + cgroup::terminate(system, config.direct, child); + let _ = sys::wait(child); + unlink_barrier(system); + return session.send_error(request_id, 125, LAUNCH_FAILED); + } + unlink_barrier(system); + let mut stdout_pipe = Some(stdout); + let mut stderr_pipe = Some(stderr); + + let started = Instant::now(); + let mut output_bytes = 0usize; + let mut stdout_open = true; + let mut stderr_open = true; + let mut timed_out = false; + let mut output_limited = false; + let mut cancelled = false; + let mut session_lost = false; + let mut stragglers_killed = false; + let mut wait_status = None; + + while wait_status.is_none() || stdout_open || stderr_open { + let ready = match sys::readable_or_hung_up( + [ + if stdout_open { + stdout_pipe.as_ref() + } else { + None + }, + if stderr_open { + stderr_pipe.as_ref() + } else { + None + }, + (!session_lost).then_some(session.descriptor()), + ], + POLL_INTERVAL_MS, + ) { + Ok(ready) => ready, + Err(Errno::EINTR) => [false; 3], + Err(_) => { + cgroup::terminate(system, config.direct, child); + [false; 3] + } + }; + if !session_lost && ready[2] { + let control = match handle_control(system, session, request_id) { + Ok(control) => control, + Err(error) => { + cgroup::terminate(system, config.direct, child); + let _ = sys::wait(child); + return Err(error); + } + }; + match control { + ExecControl::None => {} + ExecControl::Cancel if wait_status.is_none() && !cancelled => { + cancelled = true; + cgroup::terminate(system, config.direct, child); + } + ExecControl::Cancel => {} + ExecControl::SessionLost => { + session_lost = true; + cgroup::terminate(system, config.direct, child); + } + } + } + let stdout_result = if stdout_open && ready[0] { + if session_lost { + drain(stdout_pipe.as_ref().expect("stdout reader is open")) + } else { + stream( + session, + request_id, + stdout_pipe.as_ref().expect("stdout reader is open"), + APP_STDOUT, + &mut output_bytes, + ) + } + } else { + StreamResult::Open + }; + let stderr_result = if stderr_open && ready[1] { + if session_lost { + drain(stderr_pipe.as_ref().expect("stderr reader is open")) + } else { + stream( + session, + request_id, + stderr_pipe.as_ref().expect("stderr reader is open"), + APP_STDERR, + &mut output_bytes, + ) + } + } else { + StreamResult::Open + }; + if stdout_result == StreamResult::Closed { + stdout_pipe = None; + stdout_open = false; + } + if stderr_result == StreamResult::Closed { + stderr_pipe = None; + stderr_open = false; + } + if stdout_result.is_failure() || stderr_result.is_failure() { + output_limited = + stdout_result == StreamResult::Limited || stderr_result == StreamResult::Limited; + cgroup::terminate(system, config.direct, child); + } + cgroup::reap(child, &mut wait_status); + if wait_status.is_some() + && config.direct + && !stragglers_killed + && (stdout_open || stderr_open) + { + stragglers_killed = true; + let _ = cgroup::kill(system); + } + if wait_status.is_none() + && execution.timeout_ms != 0 + && started.elapsed() >= Duration::from_millis(u64::from(execution.timeout_ms)) + { + timed_out = true; + cgroup::terminate(system, config.direct, child); + } + if (timed_out || output_limited || cancelled || session_lost) + && wait_status.is_none() + && let Some(status) = sys::wait(child) + { + wait_status = Some(status); + } + } + + let launch_report = report.map_or(Ok(None), read_launch_failure); + if config.direct + && let Err(error) = cgroup::settle(system) + { + super::diagnostics::error(system, "exec-cgroup-settle", error as i32); + return if session_lost { + Ok(()) + } else { + session.send_error(request_id, 125, "containment-failed") + }; + } + if session_lost { + return Ok(()); + } + match launch_report { + Ok(Some((LAUNCH_STAGE_CWD, status))) => { + return refuse_cwd(session, request_id, execution, status); + } + Ok(Some((_, status))) => { + super::diagnostics::error(system, "exec-launch", status); + return session.send_error(request_id, 125, LAUNCH_FAILED); + } + Err(error) => { + super::diagnostics::error(system, "exec-launch-report", error as i32); + } + Ok(None) => {} + } + if cancelled { + session.send_app( + APP_EXIT, + request_id, + 128 + sys::Signal::SIGKILL as i32, + b"cancelled", + ) + } else if timed_out { + session.send_app(APP_EXIT, request_id, 124, b"timeout") + } else if output_limited { + session.send_app(APP_EXIT, request_id, 125, b"output-limit") + } else { + match wait_status { + Some(ChildStatus::Exited(status)) => { + session.send_app(APP_EXIT, request_id, status, b"exit") + } + Some(ChildStatus::Signaled(signal)) => { + session.send_app(APP_EXIT, request_id, 128 + signal, b"signal") + } + Some(ChildStatus::Other) | None => { + session.send_app(APP_EXIT, request_id, 125, b"failed") + } + } + } +} + +/// A spawned workload and the parent's ends of its pipes. +struct Launched { + /// The workload process, which leads its own process group. + child: ProcessId, + /// Reader of the workload's standard output. + stdout: FileDescriptor, + /// Reader of the workload's standard error. + stderr: FileDescriptor, + /// Reader of the direct child's launch report, absent in sandbox mode. + report: Option, +} + +/// Creates the workload's pipes and sealed configuration, then spawns it. +/// +/// The configuration and the child's pipe ends close when this returns, as the C agent closed +/// them after `fork`. +fn launch( + system: &mut impl System, + config: &AgentConfig, + execution: &GuestExecPayload, +) -> Result { + let (stdout, stdout_writer) = sys::create_pipe()?; + let (stderr, stderr_writer) = sys::create_pipe()?; + let config_fd = exec_config::create(execution, config.direct)?; + let (report, report_writer) = if config.direct { + let (reader, writer) = sys::create_pipe()?; + (Some(reader), Some(writer)) + } else { + (None, None) + }; + let child = system.spawn(SpawnRequest { + config, + execution, + config_fd: &config_fd, + stdout_pipe: (&stdout, &stdout_writer), + stderr_pipe: (&stderr, &stderr_writer), + launch_pipe: report.as_ref().zip(report_writer.as_ref()), + })?; + Ok(Launched { + child, + stdout, + stderr, + report, + }) +} + +/// Failure discovered before the fork boundary. +#[derive(Debug)] +enum EnvironmentFailure { + /// Failure whose exact stderr text belongs to the would-be child. + Child(Errno, &'static [u8]), + /// Parent-side launch validation failure. + Launch(Errno), +} + +/// Validates all environment mutations before allocating child resources. +fn validate_launch_environment( + system: &mut impl System, + config: &AgentConfig, + execution: &GuestExecPayload, +) -> Result<(), EnvironmentFailure> { + if config.direct { + for (name, value) in [ + (c"HOME", config.home.as_c_str()), + (c"USER", config.user.as_c_str()), + (c"LOGNAME", config.user.as_c_str()), + ] { + if let Err(error) = system.validate_environment(name, value) { + return Err(EnvironmentFailure::Child( + error, + b"nvx-managed-agent: cannot configure workload environment: ", + )); + } + } + if execution.environment.is_none() || execution.inherit_default_env { + let pwd = c_string(execution.cwd.as_deref().unwrap_or(b"/")) + .map_err(EnvironmentFailure::Launch)?; + system + .validate_environment(c"PWD", &pwd) + .map_err(EnvironmentFailure::Launch)?; + } + } else if let Err(error) = system.validate_environment(c"NVX_EXEC_CONFIG_FD", c"0") { + return Err(EnvironmentFailure::Child( + error, + b"nvx-managed-agent: cannot export execution configuration: ", + )); + } + Ok(()) +} + +/// Sends the C-compatible stderr and application error for a cwd failure. +fn refuse_cwd( + session: &mut Session, + request_id: u64, + execution: &GuestExecPayload, + status: i32, +) -> Result<(), Errno> { + let cwd = execution.cwd.as_deref().unwrap_or(b"/"); + let prefix = [ + b"nvx-managed-agent: cannot enter working directory ".as_slice(), + cwd, + b": ", + ] + .concat(); + let diagnostic = with_description(&prefix, Errno::from_raw(status)); + session.send_app(APP_STDERR, request_id, 0, &diagnostic)?; + session.send_error(request_id, status, CWD_FAILED) +} + +/// Control action observed while a workload is active. +#[derive(Clone, Copy)] +enum ExecControl { + /// No action applies to the active workload. + None, + /// The host cancelled the active workload. + Cancel, + /// A reset detached this workload's session. + SessionLost, +} + +/// Processes one control-console record while a workload runs. +fn handle_control( + system: &mut impl System, + session: &mut Session, + exec_id: u64, +) -> Result { + let record = session.read_record().inspect_err(|error| { + super::diagnostics::error(system, "exec-outer-read", *error as i32); + })?; + if record.header.record_type == OUTER_RESET { + session.acknowledge_reset(&record)?; + return Ok(ExecControl::SessionLost); + } + if record.header.record_type != OUTER_DATA { + super::diagnostics::error(system, "exec-record", i32::from(record.header.record_type)); + return Err(Errno::EPROTO); + } + let request = match session.validate_data(&record) { + Ok(request) => request, + Err(failure) => { + if failure.kind != DataFailureKind::Credit { + super::diagnostics::error( + system, + "exec-record", + i32::from(record.header.record_type), + ); + } + return Err(failure.into_error()); + } + }; + if request.kind == APP_CANCEL { + return Ok( + if request.payload.is_empty() && request.request_id == exec_id { + ExecControl::Cancel + } else { + ExecControl::None + }, + ); + } + session.send_error(request.request_id, Errno::EBUSY as i32, BUSY)?; + Ok(ExecControl::None) +} + +/// Result of draining one nonblocking output descriptor. +#[derive(Clone, Copy, PartialEq, Eq)] +enum StreamResult { + /// The descriptor remains open and currently has no more data. + Open, + /// End of file closed the descriptor. + Closed, + /// Reading or forwarding output failed. + Failed, + /// Forwarding another chunk would exceed the output cap. + Limited, +} + +impl StreamResult { + /// Returns whether the workload must be terminated for this result. + fn is_failure(self) -> bool { + matches!(self, Self::Failed | Self::Limited) + } +} + +/// Reads and forwards all currently available output from one descriptor. +fn stream( + session: &mut Session, + request_id: u64, + descriptor: &FileDescriptor, + kind: u8, + output_bytes: &mut usize, +) -> StreamResult { + let mut buffer = [0u8; OUTPUT_CHUNK_BYTES]; + loop { + let count = descriptor.read(&mut buffer); + if let Ok(count) = count + && count > 0 + { + if *output_bytes > protocol::MAX_OUTPUT_BYTES - count { + return StreamResult::Limited; + } + *output_bytes += count; + if session + .send_app(kind, request_id, 0, &buffer[..count]) + .is_err() + { + return StreamResult::Failed; + } + } else if matches!(count, Ok(0)) { + return StreamResult::Closed; + } else { + match count { + Err(sys::Errno::EINTR) => {} + Err(sys::Errno::EAGAIN) => return StreamResult::Open, + Err(_) | Ok(_) => return StreamResult::Failed, + } + } + } +} + +/// Discards currently available bytes after the control session is lost. +fn drain(descriptor: &FileDescriptor) -> StreamResult { + let mut buffer = [0u8; OUTPUT_CHUNK_BYTES]; + loop { + match descriptor.read(&mut buffer) { + Ok(count) if count > 0 => {} + Ok(_) => return StreamResult::Closed, + Err(sys::Errno::EINTR) => {} + Err(sys::Errno::EAGAIN) => return StreamResult::Open, + Err(_) => return StreamResult::Failed, + } + } +} + +/// Reads the optional fixed-size direct-child launch report. +/// +/// The reader closes when this returns, as the C agent closed it after reading. +fn read_launch_failure(descriptor: FileDescriptor) -> Result, Errno> { + let mut bytes = [0u8; 8]; + let mut length = 0; + while length < bytes.len() { + match descriptor.read(&mut bytes[length..]) { + Ok(count) if count > 0 => length += count, + Ok(_) => break, + Err(Errno::EINTR) => {} + Err(error) => return Err(error), + } + } + match length { + 0 => Ok(None), + 8 => Ok(Some(( + i32::from_ne_bytes(bytes[..4].try_into().expect("four bytes")), + i32::from_ne_bytes(bytes[4..].try_into().expect("four bytes")), + ))), + _ => Err(Errno::EPROTO), + } +} + +/// Creates a fresh launch-barrier FIFO after removing a stale entry. +fn create_barrier(system: &impl System) -> Result<(), Errno> { + unlink_barrier(system); + let path = path_c_string(&system.paths().barrier)?; + sys::create_fifo(&path, Mode::from_bits_retain(0o600)) +} + +/// Best-effort removes the launch-barrier FIFO. +fn unlink_barrier(system: &impl System) { + if let Ok(path) = path_c_string(&system.paths().barrier) { + let _ = sys::unlink(&path); + } +} + +/// Opens and releases the sandbox barrier, bounded by child exit and retry count. +fn release_barrier(system: &mut impl System, child: ProcessId) -> Result<(), Errno> { + let path = path_c_string(&system.paths().barrier)?; + for _ in 0..BARRIER_ATTEMPTS { + match sys::open( + &path, + OpenFlags::O_WRONLY | OpenFlags::O_CLOEXEC | OpenFlags::O_NONBLOCK, + Mode::empty(), + ) { + Ok(descriptor) => return write_barrier(system, &descriptor), + Err(Errno::ENXIO | Errno::EINTR) => {} + Err(error) => return Err(error), + } + match sys::has_exited(child) { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(Errno::EINTR) => continue, + Err(error) => return Err(error), + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(Errno::ETIMEDOUT) +} + +/// Writes the barrier with SIGPIPE temporarily ignored and exactly restored. +fn write_barrier(system: &mut impl System, descriptor: &FileDescriptor) -> Result<(), Errno> { + sys::with_sigpipe_ignored(|| system.write_barrier(descriptor, b"start\n")) +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::io::Read; + use std::os::unix::net::UnixStream; + use std::process::Command; + use std::thread; + + use super::super::seam::{Paths, TestSystem}; + use super::*; + + fn system() -> (tempfile::TempDir, TestSystem) { + let temporary = tempfile::tempdir().unwrap(); + let paths = Paths { + barrier: temporary.path().join("managed-container-start"), + ..Paths::default() + }; + (temporary, TestSystem::new(paths)) + } + + #[test] + fn parent_barrier_release_is_bounded_without_a_child_reader() { + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (_temporary, mut system) = system(); + create_barrier(&system).unwrap(); + let mut child = Command::new("/bin/true").spawn().unwrap(); + let process = ProcessId::from_child(&child); + release_barrier(&mut system, process).unwrap(); + assert!(child.wait().unwrap().success()); + } + + #[test] + fn parent_barrier_release_handles_a_reader_closing_during_write() { + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (_temporary, mut system) = system(); + create_barrier(&system).unwrap(); + let path = path_c_string(&system.paths.barrier).unwrap(); + let reader = sys::open( + &path, + OpenFlags::O_RDONLY | OpenFlags::O_CLOEXEC | OpenFlags::O_NONBLOCK, + Mode::empty(), + ) + .unwrap(); + system.close_barrier_reader = Some(reader); + system.fail_barrier_write = Some(Errno::EPIPE); + let mut child = Command::new("/bin/sleep").arg("5").spawn().unwrap(); + let error = release_barrier(&mut system, ProcessId::from_child(&child)).unwrap_err(); + assert_eq!(error, Errno::EPIPE); + child.kill().unwrap(); + let _ = child.wait(); + } + + #[test] + fn parent_barrier_release_preserves_a_live_child_start() { + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (_temporary, mut system) = system(); + create_barrier(&system).unwrap(); + let path = system.paths.barrier.clone(); + let reader = thread::spawn(move || { + let mut fifo = std::fs::OpenOptions::new().read(true).open(path).unwrap(); + let mut message = [0u8; 6]; + fifo.read_exact(&mut message).unwrap(); + message + }); + let mut child = Command::new("/bin/sleep").arg("5").spawn().unwrap(); + release_barrier(&mut system, ProcessId::from_child(&child)).unwrap(); + assert_eq!(reader.join().unwrap(), *b"start\n"); + child.kill().unwrap(); + let _ = child.wait(); + } + + #[test] + fn write_barrier_restores_the_sigpipe_disposition() { + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (_temporary, mut system) = system(); + let (left, right) = UnixStream::pair().unwrap(); + let writer = FileDescriptor::from_owned(left.into()); + let reader = FileDescriptor::from_owned(right.into()); + system.close_barrier_reader = Some(reader); + system.fail_barrier_write = Some(Errno::EPIPE); + let before = sys::sigpipe_disposition().unwrap(); + assert_eq!( + write_barrier(&mut system, &writer).unwrap_err(), + Errno::EPIPE + ); + let after = sys::sigpipe_disposition().unwrap(); + assert_eq!(before, after); + } + + #[test] + fn environment_failures_stop_direct_and_sandbox_launches_before_exec() { + let config = |direct| AgentConfig { + rootfs: CString::new(if direct { "-" } else { "/rootfs" }).unwrap(), + hostname: c"test".to_owned(), + uid: c"1000".to_owned(), + gid: c"1000".to_owned(), + user: c"workload".to_owned(), + home: c"/home/workload".to_owned(), + direct, + }; + let execution = GuestExecPayload { + timeout_ms: 0, + argv: vec![b"/bin/true".to_vec()], + cwd: None, + environment: None, + inherit_default_env: false, + }; + for (direct, variable, diagnostic) in [ + ( + true, + b"HOME".as_slice(), + b"cannot configure workload environment".as_slice(), + ), + ( + false, + b"NVX_EXEC_CONFIG_FD".as_slice(), + b"cannot export execution configuration".as_slice(), + ), + ] { + let mut system = TestSystem::new(Paths::default()); + system.fail_environment = Some(variable.to_vec()); + let failure = + validate_launch_environment(&mut system, &config(direct), &execution).unwrap_err(); + let EnvironmentFailure::Child(error, message) = failure else { + panic!("expected a child environment failure"); + }; + assert_eq!(error, Errno::ENOMEM); + assert!( + message + .windows(diagnostic.len()) + .any(|part| part == diagnostic), + "{message:?}" + ); + assert!(system.execs.is_empty()); + } + + let mut system = TestSystem::new(Paths::default()); + system.fail_environment = Some(b"PWD".to_vec()); + let failure = + validate_launch_environment(&mut system, &config(true), &execution).unwrap_err(); + assert!(matches!(failure, EnvironmentFailure::Launch(Errno::ENOMEM))); + assert!(system.execs.is_empty()); + } +} diff --git a/nvx-rs/src/guest/managed_agent/exec_config.rs b/nvx-rs/src/guest/managed_agent/exec_config.rs new file mode 100644 index 000000000..8472b3ec2 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/exec_config.rs @@ -0,0 +1,219 @@ +use crate::guest::sys::{self, Errno, FileDescriptor}; +use crate::protocol::{ + EXEC_CWD_PRESENT, EXEC_ENVIRONMENT_PRESENT, EXEC_INHERIT_DEFAULT_ENV, GuestExecPayload, +}; + +/// Private helper-format flag indicating that the launcher already entered the cwd. +pub(crate) const EXEC_CWD_ENTERED: u16 = 0x8000; + +/// Decoded sealed configuration consumed by helper mode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HelperConfig { + /// Requested absolute working directory, unless already entered. + pub(crate) cwd: Option>, + /// Exact or layered environment entries. + pub(crate) environment: Option>>, + /// Whether explicit entries layer over the inherited environment. + pub(crate) inherit_default_env: bool, + /// Whether the privileged child already entered the working directory. + pub(crate) cwd_entered: bool, +} + +/// Encodes `execution` in an immutable close-on-exec memfd. +pub(crate) fn create( + execution: &GuestExecPayload, + cwd_entered: bool, +) -> Result { + let descriptor = sys::create_memfd(c"nvx-exec-config")?; + let bytes = encode(execution, cwd_entered)?; + descriptor.write_all(&bytes)?; + descriptor.rewind()?; + descriptor.seal_immutable()?; + Ok(descriptor) +} + +/// Serializes the private helper configuration format. +fn encode(execution: &GuestExecPayload, cwd_entered: bool) -> Result, Errno> { + let mut flags = 0u16; + let cwd = if cwd_entered { + flags |= EXEC_CWD_ENTERED; + None + } else { + if execution.cwd.is_some() { + flags |= EXEC_CWD_PRESENT; + } + execution.cwd.as_deref() + }; + if execution.environment.is_some() { + flags |= EXEC_ENVIRONMENT_PRESENT; + } + if execution.inherit_default_env { + flags |= EXEC_INHERIT_DEFAULT_ENV; + } + let environment = execution.environment.as_deref().unwrap_or_default(); + let count = u16::try_from(environment.len()).map_err(|_| Errno::EOVERFLOW)?; + let cwd_len = u32::try_from(cwd.map_or(0, <[u8]>::len)).map_err(|_| Errno::EOVERFLOW)?; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&flags.to_le_bytes()); + bytes.extend_from_slice(&count.to_le_bytes()); + bytes.extend_from_slice(&cwd_len.to_le_bytes()); + if let Some(cwd) = cwd { + bytes.extend_from_slice(cwd); + } + for entry in environment { + let length = u32::try_from(entry.len()).map_err(|_| Errno::EOVERFLOW)?; + bytes.extend_from_slice(&length.to_le_bytes()); + bytes.extend_from_slice(entry); + } + Ok(bytes) +} + +/// Decodes a helper configuration from its sealed descriptor. +pub(crate) fn read(descriptor: &FileDescriptor) -> Result { + let mut header = [0u8; 8]; + descriptor.read_exact(&mut header)?; + let flags = u16::from_le_bytes([header[0], header[1]]); + let environment_count = usize::from(u16::from_le_bytes([header[2], header[3]])); + let cwd_len = u32::from_le_bytes(header[4..8].try_into().expect("four bytes")) as usize; + let known = + EXEC_CWD_PRESENT | EXEC_ENVIRONMENT_PRESENT | EXEC_INHERIT_DEFAULT_ENV | EXEC_CWD_ENTERED; + let cwd_entered = flags & EXEC_CWD_ENTERED != 0; + if flags & !known != 0 + || (cwd_entered && flags & EXEC_CWD_PRESENT != 0) + || environment_count > crate::protocol::MAX_ENVIRONMENT + || (flags & EXEC_CWD_PRESENT == 0 && cwd_len != 0) + || (flags & EXEC_CWD_PRESENT != 0 + && !(1..=crate::protocol::MAX_ARGUMENT_BYTES).contains(&cwd_len)) + || (flags & EXEC_ENVIRONMENT_PRESENT == 0 + && (environment_count != 0 || flags & EXEC_INHERIT_DEFAULT_ENV != 0)) + { + return Err(Errno::EINVAL); + } + let cwd = if cwd_len == 0 { + None + } else { + let mut cwd = vec![0; cwd_len]; + descriptor.read_exact(&mut cwd)?; + Some(cwd) + }; + let mut environment = Vec::with_capacity(environment_count); + for _ in 0..environment_count { + let mut length = [0u8; 4]; + descriptor.read_exact(&mut length)?; + let length = u32::from_le_bytes(length) as usize; + if !(1..=crate::protocol::MAX_ARGUMENT_BYTES).contains(&length) { + return Err(Errno::EINVAL); + } + let mut entry = vec![0; length]; + descriptor.read_exact(&mut entry)?; + if entry.contains(&0) || entry.first() == Some(&b'=') || !entry.contains(&b'=') { + return Err(Errno::EINVAL); + } + environment.push(entry); + } + Ok(HelperConfig { + cwd, + environment: (flags & EXEC_ENVIRONMENT_PRESENT != 0).then_some(environment), + inherit_default_env: flags & EXEC_INHERIT_DEFAULT_ENV != 0, + cwd_entered, + }) +} + +#[cfg(test)] +mod tests { + use std::io::{Seek, Write}; + + use super::*; + + fn execution() -> GuestExecPayload { + GuestExecPayload { + timeout_ms: 0, + argv: vec![b"/bin/true".to_vec()], + cwd: Some(b"/tmp".to_vec()), + environment: Some(vec![b"EMPTY=".to_vec(), b"VALUE=space = value".to_vec()]), + inherit_default_env: false, + } + } + + fn file_with(bytes: &[u8]) -> FileDescriptor { + let mut file = tempfile::tempfile().unwrap(); + file.write_all(bytes).unwrap(); + file.rewind().unwrap(); + FileDescriptor::from_owned(file.into()) + } + + #[test] + fn helper_configuration_round_trips_empty_exact_and_large_environments() { + for environment in [ + Vec::new(), + vec![b"EMPTY=".to_vec(), b"VALUE=space = value".to_vec()], + (0..12) + .map(|index| { + let mut entry = format!("KEY{index}=").into_bytes(); + entry.extend(std::iter::repeat_n(b'x', 4000)); + entry + }) + .collect(), + ] { + let mut execution = execution(); + execution.environment = Some(environment.clone()); + let descriptor = create(&execution, false).unwrap(); + let decoded = read(&descriptor).unwrap(); + assert_eq!(decoded.cwd, execution.cwd); + assert_eq!(decoded.environment, Some(environment)); + assert!(!decoded.cwd_entered); + } + } + + #[test] + fn helper_configuration_marks_an_entered_directory_and_is_sealed() { + let execution = execution(); + let descriptor = create(&execution, true).unwrap(); + assert_eq!(descriptor.is_sealed_immutable(), Ok(true)); + let decoded = read(&descriptor).unwrap(); + assert!(decoded.cwd_entered); + assert_eq!(decoded.cwd, None); + } + + #[test] + fn helper_defensively_rejects_unknown_flags_and_malformed_environment() { + for bytes in [ + { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&0x4000u16.to_le_bytes()); + bytes.extend_from_slice(&0u16.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes + }, + { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&EXEC_ENVIRONMENT_PRESENT.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&9u32.to_le_bytes()); + bytes.extend_from_slice(b"NO_EQUALS"); + bytes + }, + { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&EXEC_ENVIRONMENT_PRESENT.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&6u32.to_le_bytes()); + bytes.extend_from_slice(b"=value"); + bytes + }, + { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&EXEC_CWD_ENTERED.to_le_bytes()); + bytes.extend_from_slice(&0u16.to_le_bytes()); + bytes.extend_from_slice(&1u32.to_le_bytes()); + bytes.push(b'/'); + bytes + }, + ] { + let descriptor = file_with(&bytes); + assert!(read(&descriptor).is_err(), "{bytes:?}"); + } + } +} diff --git a/nvx-rs/src/guest/managed_agent/helper.rs b/nvx-rs/src/guest/managed_agent/helper.rs new file mode 100644 index 000000000..f066ab261 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/helper.rs @@ -0,0 +1,295 @@ +use std::ffi::{CStr, CString}; + +use crate::guest::status::CommandError; +use crate::guest::sys::{self, Errno, c_string}; + +use super::diagnostics::{with_description, write_stderr}; +use super::environment; +use super::exec_config::{self, HelperConfig}; +use super::seam::System; + +/// Runs the short-lived execution-helper mode. +/// +/// Every failure is [`CommandError::Agent`]. As in the C helper, only working-directory, +/// environment, and `exec` failures print why. +pub(crate) fn run(system: &mut impl System, arguments: &[CString]) -> Result<(), CommandError> { + if arguments.len() < 5 + || arguments[1].as_bytes() != b"--exec-config-fd" + || arguments[3].as_bytes() != b"--" + || arguments[4].as_bytes().first() != Some(&b'/') + { + return Err(CommandError::Agent); + } + let config = read_config(system, &arguments[2]).map_err(|_| CommandError::Agent)?; + if let Some(cwd) = &config.cwd + && (cwd.first() != Some(&b'/') || cwd.contains(&0)) + { + write_stderr(b"nvx-managed-agent: invalid working directory\n"); + return Err(CommandError::Agent); + } + let mut environment = environment::inherited().map_err(|_| CommandError::Agent)?; + environment::unset(&mut environment, b"NVX_EXEC_CONFIG_FD"); + if !config.cwd_entered { + enter_directory(system, &config, &mut environment)?; + } + if let Some(entries) = &config.environment { + if !config.inherit_default_env { + environment.clear(); + } + for entry in entries { + put(system, &mut environment, entry).map_err(|_| CommandError::Agent)?; + } + } + if let Err(error) = system.exec(&arguments[4], &arguments[4..], Some(&environment)) { + write_stderr(&with_description( + b"nvx-managed-agent: cannot execute workload: ", + error, + )); + return Err(CommandError::Agent); + } + Ok(()) +} + +/// Reads the sealed configuration through the inherited descriptor that `text` names. +/// +/// The configuration's descriptors close no later than the workload's `exec`, as the C helper +/// closed its descriptor before it. +fn read_config(system: &mut impl System, text: &CStr) -> Result { + let descriptor = system.take_inherited_descriptor(text)?; + exec_config::read(&descriptor) +} + +/// Enters the requested working directory, or `/` without one, and sets `PWD` unless the +/// configuration replaces the inherited environment. +fn enter_directory( + system: &mut impl System, + config: &HelperConfig, + environment: &mut Vec, +) -> Result<(), CommandError> { + let directory = config.cwd.as_deref().unwrap_or(b"/"); + let directory_c = c_string(directory).map_err(|_| CommandError::Agent)?; + if let Err(error) = sys::change_directory(&directory_c) { + let prefix = [ + b"nvx-managed-agent: cannot use working directory ".as_slice(), + directory, + b": ", + ] + .concat(); + write_stderr(&with_description(&prefix, error)); + return Err(CommandError::Agent); + } + if (config.environment.is_none() || config.inherit_default_env) + && let Err(error) = set_pwd(system, environment, directory) + { + write_stderr(&with_description( + b"nvx-managed-agent: cannot configure working directory environment: ", + error, + )); + return Err(CommandError::Agent); + } + Ok(()) +} + +/// Sets `PWD` to the requested spelling, or to the physical path of a noncanonical one. +fn set_pwd( + system: &mut impl System, + environment: &mut Vec, + directory: &[u8], +) -> Result<(), Errno> { + let pwd = if directory == b"/" || safe_path(directory) { + directory.to_vec() + } else { + sys::current_directory(crate::protocol::MAX_ARGUMENT_BYTES)? + }; + system.validate_environment(c"PWD", &c_string(&pwd)?)?; + environment::set(environment, b"PWD", &pwd) +} + +/// Sets the variable of one validated `NAME=value` configuration entry, as C's `putenv` did. +fn put( + system: &mut impl System, + environment: &mut Vec, + entry: &[u8], +) -> Result<(), Errno> { + let equals = entry + .iter() + .position(|&byte| byte == b'=') + .ok_or(Errno::EINVAL)?; + let (name, value) = (&entry[..equals], &entry[equals + 1..]); + system.validate_environment(&c_string(name)?, &c_string(value)?)?; + environment::set(environment, name, value) +} + +/// Returns whether an absolute path has only canonical nonempty components. +pub(crate) fn safe_path(path: &[u8]) -> bool { + let Some(components) = path.strip_prefix(b"/") else { + return false; + }; + !components.is_empty() + && components + .split(|&byte| byte == b'/') + .all(|component| !matches!(component, b"" | b"." | b"..")) +} + +#[cfg(test)] +mod tests { + use std::io::{Seek, Write}; + + use crate::guest::sys::FileDescriptor; + use crate::protocol::{self, GuestExecPayload}; + + use super::super::seam::{Paths, TestSystem}; + use super::*; + + fn execution(environment: Option>>, inherit: bool) -> GuestExecPayload { + GuestExecPayload { + timeout_ms: 0, + argv: vec![b"/bin/true".to_vec()], + cwd: None, + environment, + inherit_default_env: inherit, + } + } + + fn arguments( + system: &mut TestSystem, + descriptor: FileDescriptor, + workload: &[&[u8]], + ) -> Vec { + let mut arguments = vec![ + c_string(b"nvx-managed-agent").unwrap(), + c_string(b"--exec-config-fd").unwrap(), + system.transfer_inherited_descriptor(descriptor), + c_string(b"--").unwrap(), + ]; + arguments.extend(workload.iter().map(|argument| c_string(argument).unwrap())); + arguments + } + + fn file_with(bytes: &[u8]) -> FileDescriptor { + let mut file = tempfile::tempfile().unwrap(); + file.write_all(bytes).unwrap(); + file.rewind().unwrap(); + FileDescriptor::from_owned(file.into()) + } + + /// Returns the inherited environment without the helper's descriptor variable. + fn inherited() -> Vec> { + let mut environment = environment::inherited().unwrap(); + environment::unset(&mut environment, b"NVX_EXEC_CONFIG_FD"); + environment + .into_iter() + .map(|entry| entry.into_bytes()) + .collect() + } + + /// Returns the environment of the helper's only `exec`. + fn executed_environment(system: &TestSystem) -> Vec> { + let [(_, _, environment)] = system.execs.as_slice() else { + panic!("expected one exec, not {}", system.execs.len()); + }; + environment + .clone() + .expect("the helper passes an environment") + } + + #[test] + fn helper_applies_exact_empty_and_layered_environments() { + let fd = exec_config::create(&execution(Some(Vec::new()), false), true).unwrap(); + let mut system = TestSystem::new(Paths::default()); + let helper_arguments = arguments(&mut system, fd, &[b"/usr/bin/env"]); + assert_eq!(run(&mut system, &helper_arguments), Ok(())); + assert!(executed_environment(&system).is_empty()); + + let layered = vec![b"VALUE=layered".to_vec(), b"VALUE=again".to_vec()]; + let fd = exec_config::create(&execution(Some(layered), true), true).unwrap(); + let mut system = TestSystem::new(Paths::default()); + let helper_arguments = arguments(&mut system, fd, &[b"/usr/bin/env"]); + assert_eq!(run(&mut system, &helper_arguments), Ok(())); + let mut expected: Vec<_> = inherited() + .into_iter() + .map(|entry| CString::new(entry).unwrap()) + .collect(); + environment::set(&mut expected, b"VALUE", b"again").unwrap(); + let expected: Vec<_> = expected.into_iter().map(CString::into_bytes).collect(); + assert_eq!(executed_environment(&system), expected); + } + + #[test] + fn helper_rejects_bad_syntax_relative_cwd_and_conflicting_cwd_flags() { + let mut system = TestSystem::new(Paths::default()); + assert_eq!( + run(&mut system, &[c_string(b"agent").unwrap()]), + Err(CommandError::Agent) + ); + assert!(system.execs.is_empty()); + + let mut relative = Vec::new(); + relative.extend_from_slice(&protocol::EXEC_CWD_PRESENT.to_le_bytes()); + relative.extend_from_slice(&0u16.to_le_bytes()); + relative.extend_from_slice(&1u32.to_le_bytes()); + relative.push(b'.'); + let fd = file_with(&relative); + let helper_arguments = arguments(&mut system, fd, &[b"/bin/pwd"]); + assert_eq!( + run(&mut system, &helper_arguments), + Err(CommandError::Agent) + ); + + let mut conflicting = Vec::new(); + conflicting.extend_from_slice( + &(protocol::EXEC_CWD_PRESENT | exec_config::EXEC_CWD_ENTERED).to_le_bytes(), + ); + conflicting.extend_from_slice(&0u16.to_le_bytes()); + conflicting.extend_from_slice(&1u32.to_le_bytes()); + conflicting.push(b'/'); + let fd = file_with(&conflicting); + let helper_arguments = arguments(&mut system, fd, &[b"/bin/pwd"]); + assert_eq!( + run(&mut system, &helper_arguments), + Err(CommandError::Agent) + ); + assert!(system.execs.is_empty()); + } + + #[test] + fn helper_does_not_exec_when_an_environment_export_fails() { + let fd = exec_config::create( + &execution(Some(vec![b"VALUE=layered".to_vec()]), true), + true, + ) + .unwrap(); + let mut system = TestSystem::new(Paths::default()); + system.fail_environment = Some(b"VALUE".to_vec()); + let helper_arguments = arguments(&mut system, fd, &[b"/bin/true"]); + assert_eq!( + run(&mut system, &helper_arguments), + Err(CommandError::Agent) + ); + assert!(system.execs.is_empty()); + } + + #[test] + fn helper_keeps_a_directory_that_the_caller_entered() { + let fd = exec_config::create(&execution(None, false), true).unwrap(); + let mut system = TestSystem::new(Paths::default()); + let helper_arguments = arguments(&mut system, fd, &[b"/bin/pwd"]); + assert_eq!(run(&mut system, &helper_arguments), Ok(())); + assert_eq!(executed_environment(&system), inherited()); + assert_eq!(system.execs[0].1, [b"/bin/pwd".to_vec()]); + } + + #[test] + fn safe_working_directory_names_match_the_c_helper() { + for path in [b"/".as_slice(), b"/tmp", b"/a/b", b"relative"] { + assert_eq!( + safe_path(path), + path != b"/" && path != b"relative", + "{path:?}" + ); + } + for path in [b"/a//b".as_slice(), b"/a/./b", b"/a/../b", b"/a/"] { + assert!(!safe_path(path), "{path:?}"); + } + } +} diff --git a/nvx-rs/src/guest/managed_agent/launch.rs b/nvx-rs/src/guest/managed_agent/launch.rs new file mode 100644 index 000000000..8e7132c98 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/launch.rs @@ -0,0 +1,366 @@ +use std::os::unix::ffi::OsStrExt; + +use crate::guest::parse::parse_decimal; +use crate::guest::sys::{self, Errno, FileDescriptor, Mode, OpenFlags, ProcessId, c_string}; +use crate::protocol::GuestExecPayload; + +use super::AgentConfig; +use super::environment; +use super::seam::{Paths, path_c_string}; + +/// Child report stage for failure to enter the requested working directory. +pub(crate) const LAUNCH_STAGE_CWD: i32 = sys::CHILD_STAGE_CWD; +/// Borrowed launch inputs and owned descriptor endpoints prepared by the execution engine. +pub(crate) struct SpawnRequest<'a> { + /// Agent mode and configured workload identity. + pub(crate) config: &'a AgentConfig, + /// Decoded workload request. + pub(crate) execution: &'a GuestExecPayload, + /// Sealed helper configuration. + pub(crate) config_fd: &'a FileDescriptor, + /// Parent reader and child writer for standard output. + pub(crate) stdout_pipe: (&'a FileDescriptor, &'a FileDescriptor), + /// Parent reader and child writer for standard error. + pub(crate) stderr_pipe: (&'a FileDescriptor, &'a FileDescriptor), + /// Direct-child launch report pipe, absent in sandbox mode. + pub(crate) launch_pipe: Option<(&'a FileDescriptor, &'a FileDescriptor)>, +} + +/// Safe, fully allocated fork/exec plan. +struct Plan { + /// System-layer plan that owns every child input. + inner: sys::SpawnPlan, +} + +/// Builds a launch plan and crosses the safe fork/exec boundary. +pub(crate) fn spawn(paths: &Paths, request: SpawnRequest<'_>) -> Result { + let mut plan = Plan::new(paths, &request)?; + let descriptors = sys::SpawnDescriptors::managed( + request.config_fd, + request.stdout_pipe, + request.stderr_pipe, + request.launch_pipe, + ); + sys::spawn(&mut plan.inner, descriptors) +} + +impl Plan { + /// Allocates and validates every input needed after `fork`. + fn new(paths: &Paths, request: &SpawnRequest<'_>) -> Result { + let config_fd = request.config_fd.argument(); + let directory = c_string(request.execution.cwd.as_deref().unwrap_or(b"/"))?; + let uid = parse_id(request.config.uid.as_bytes())?; + let gid = parse_id(request.config.gid.as_bytes())?; + let helper = path_c_string(&paths.helper)?; + let launcher = path_c_string(&paths.launcher)?; + let setpriv = path_c_string(&paths.setpriv)?; + let mut arguments = Vec::new(); + let program; + let mut environment = environment::inherited()?; + let mut physical_pwd = None; + if request.config.direct { + program = setpriv; + for value in [ + b"setpriv".as_slice(), + b"--reuid", + request.config.uid.as_bytes(), + b"--regid", + request.config.gid.as_bytes(), + b"--clear-groups", + b"--no-new-privs", + b"--bounding-set=-all", + b"--inh-caps=-all", + b"--ambient-caps=-all", + helper.as_bytes(), + b"--exec-config-fd", + config_fd.as_bytes(), + b"--", + ] { + arguments.push(c_string(value)?); + } + environment::set(&mut environment, b"HOME", request.config.home.as_bytes())?; + environment::set(&mut environment, b"USER", request.config.user.as_bytes())?; + environment::set(&mut environment, b"LOGNAME", request.config.user.as_bytes())?; + if request.execution.environment.is_none() || request.execution.inherit_default_env { + if directory.to_bytes() == b"/" || super::helper::safe_path(directory.to_bytes()) { + environment::set(&mut environment, b"PWD", directory.to_bytes())?; + } else { + physical_pwd = Some(sys::PhysicalPwd::new( + &mut environment, + b"PWD=".len() + crate::protocol::MAX_ARGUMENT_BYTES, + )?); + } + } + } else { + program = launcher.clone(); + for value in [ + launcher.as_bytes(), + paths.barrier.as_os_str().as_bytes(), + request.config.rootfs.as_bytes(), + request.config.hostname.as_bytes(), + request.config.uid.as_bytes(), + request.config.gid.as_bytes(), + request.config.user.as_bytes(), + request.config.home.as_bytes(), + ] { + arguments.push(c_string(value)?); + } + environment::set( + &mut environment, + b"NVX_EXEC_CONFIG_FD", + config_fd.as_bytes(), + )?; + } + for argument in &request.execution.argv { + arguments.push(c_string(argument)?); + } + let cgroup_procs = if request.config.direct { + let path = path_c_string(&paths.cgroup_root.join("nvx-exec/cgroup.procs"))?; + Some(sys::open( + &path, + OpenFlags::O_WRONLY | OpenFlags::O_CLOEXEC, + Mode::empty(), + )?) + } else { + None + }; + Ok(Self { + inner: sys::SpawnPlan::managed( + program, + arguments, + environment, + physical_pwd, + directory, + uid, + gid, + cgroup_procs, + path_c_string(&paths.null_device)?, + request.config.direct, + )?, + }) + } +} + +/// Parses one nonzero decimal UID or GID while excluding the unchanged-ID sentinel. +fn parse_id(text: &[u8]) -> Result { + parse_decimal(text) + .filter(|&id| id != 0 && id < u64::from(u32::MAX)) + .and_then(|id| u32::try_from(id).ok()) + .ok_or(Errno::EINVAL) +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + + use crate::protocol::GuestExecPayload; + + use super::*; + + fn config(direct: bool) -> AgentConfig { + AgentConfig { + rootfs: CString::new(if direct { "-" } else { "/rootfs" }).unwrap(), + hostname: c"sandbox".to_owned(), + uid: c"1000".to_owned(), + gid: c"1001".to_owned(), + user: c"workload".to_owned(), + home: c"/home/workload".to_owned(), + direct, + } + } + + fn execution() -> GuestExecPayload { + GuestExecPayload { + timeout_ms: 0, + argv: vec![b"/bin/echo".to_vec(), b"ok".to_vec()], + cwd: Some(b"/work".to_vec()), + environment: None, + inherit_default_env: false, + } + } + + fn descriptor() -> FileDescriptor { + FileDescriptor::from_owned(tempfile::tempfile().unwrap().into()) + } + + fn request<'a>( + config: &'a AgentConfig, + execution: &'a GuestExecPayload, + descriptor: &'a FileDescriptor, + ) -> SpawnRequest<'a> { + SpawnRequest { + config, + execution, + config_fd: descriptor, + stdout_pipe: (descriptor, descriptor), + stderr_pipe: (descriptor, descriptor), + launch_pipe: Some((descriptor, descriptor)), + } + } + + #[test] + fn direct_launch_uses_setpriv_and_clears_every_capability_set() { + let temporary = tempfile::tempdir().unwrap(); + std::fs::create_dir(temporary.path().join("nvx-exec")).unwrap(); + std::fs::write(temporary.path().join("nvx-exec/cgroup.procs"), b"").unwrap(); + let paths = Paths { + cgroup_root: temporary.path().to_owned(), + setpriv: "/test/setpriv".into(), + helper: "/test/nvx-managed-agent".into(), + ..Paths::default() + }; + let config = config(true); + let execution = execution(); + let descriptor = descriptor(); + let descriptor_argument = descriptor.argument(); + let plan = Plan::new(&paths, &request(&config, &execution, &descriptor)).unwrap(); + let arguments: Vec<_> = plan + .inner + .arguments() + .iter() + .map(|argument| argument.to_bytes()) + .collect(); + assert_eq!( + arguments, + [ + b"setpriv".as_slice(), + b"--reuid", + b"1000", + b"--regid", + b"1001", + b"--clear-groups", + b"--no-new-privs", + b"--bounding-set=-all", + b"--inh-caps=-all", + b"--ambient-caps=-all", + b"/test/nvx-managed-agent", + b"--exec-config-fd", + descriptor_argument.as_bytes(), + b"--", + b"/bin/echo", + b"ok", + ] + ); + assert_eq!(plan.inner.program().as_bytes(), b"/test/setpriv"); + let environment: Vec<_> = plan + .inner + .environment() + .iter() + .map(|entry| entry.to_bytes()) + .collect(); + for expected in [ + b"HOME=/home/workload".as_slice(), + b"USER=workload", + b"LOGNAME=workload", + b"PWD=/work", + ] { + assert!(environment.contains(&expected), "{expected:?}"); + } + assert!(plan.inner.has_cgroup()); + } + + #[test] + fn sandbox_launch_exports_only_the_deliberate_config_descriptor() { + let paths = Paths { + launcher: "/test/nvx-container-launch".into(), + barrier: "/test/start".into(), + ..Paths::default() + }; + let config = config(false); + let execution = execution(); + let descriptor = descriptor(); + let plan = Plan::new(&paths, &request(&config, &execution, &descriptor)).unwrap(); + let arguments: Vec<_> = plan + .inner + .arguments() + .iter() + .map(|argument| argument.to_bytes()) + .collect(); + assert_eq!( + arguments, + [ + b"/test/nvx-container-launch".as_slice(), + b"/test/start", + b"/rootfs", + b"sandbox", + b"1000", + b"1001", + b"workload", + b"/home/workload", + b"/bin/echo", + b"ok", + ] + ); + assert!( + plan.inner + .environment() + .iter() + .any(|entry| entry.as_bytes().starts_with(b"NVX_EXEC_CONFIG_FD=")) + ); + assert!(!plan.inner.has_cgroup()); + } + + #[test] + fn workload_ids_are_nonzero_decimal_and_not_the_unchanged_value() { + for (text, expected) in [ + (b"1".as_slice(), Some(1)), + (b"1000", Some(1000)), + (b"4294967294", Some(u32::MAX - 1)), + (b"0", None), + (b"4294967295", None), + (b"4294967296", None), + (b"+1", None), + (b"-1", None), + (b" 1", None), + (b"1x", None), + ] { + assert_eq!(parse_id(text).ok(), expected, "{text:?}"); + } + } + + #[test] + fn direct_pwd_is_deferred_only_for_noncanonical_names() { + let temporary = tempfile::tempdir().unwrap(); + std::fs::create_dir(temporary.path().join("nvx-exec")).unwrap(); + std::fs::write(temporary.path().join("nvx-exec/cgroup.procs"), b"").unwrap(); + let paths = Paths { + cgroup_root: temporary.path().to_owned(), + ..Paths::default() + }; + let config = config(true); + for cwd in [b"/a/./b".as_slice(), b"/a/../b", b"/a//b", b"/a/b/"] { + let execution = GuestExecPayload { + cwd: Some(cwd.to_vec()), + ..execution() + }; + let descriptor = descriptor(); + let plan = Plan::new(&paths, &request(&config, &execution, &descriptor)).unwrap(); + let slot = plan.inner.physical_pwd().expect("PWD is deferred"); + assert_eq!(&slot[..4], b"PWD="); + assert!(slot[4..].iter().all(|&byte| byte == 0)); + assert!( + plan.inner + .environment() + .iter() + .all(|entry| !entry.as_bytes().starts_with(b"PWD=")) + ); + assert!(plan.inner.physical_pwd_index().is_some()); + assert!(plan.inner.has_cgroup()); + } + + let execution = GuestExecPayload { + cwd: Some(b"/symlinked/component".to_vec()), + ..execution() + }; + let descriptor = descriptor(); + let plan = Plan::new(&paths, &request(&config, &execution, &descriptor)).unwrap(); + assert!(plan.inner.physical_pwd().is_none()); + assert!( + plan.inner + .environment() + .iter() + .any(|entry| entry.as_bytes() == b"PWD=/symlinked/component") + ); + assert!(plan.inner.has_cgroup()); + } +} diff --git a/nvx-rs/src/guest/managed_agent/loopback.rs b/nvx-rs/src/guest/managed_agent/loopback.rs new file mode 100644 index 000000000..db7504575 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/loopback.rs @@ -0,0 +1,562 @@ +use std::collections::VecDeque; +use std::ffi::{CStr, CString}; +use std::io::{self, Read, Write}; +use std::os::unix::net::UnixStream; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::guest::sys::{self, Errno, FileDescriptor, Mode, MountFlags, ProcessId, c_string}; +use crate::host::platform::Transport; +use crate::protocol::{ + self, OUTER_ACK, OUTER_CREDIT, OUTER_DATA, OUTER_GUEST_ATTACH, OUTER_HEADER_LEN, + OUTER_HOST_ATTACH, OUTER_READY, OUTER_RESET, OuterHeader, +}; + +use super::launch::SpawnRequest; +use super::seam::{Paths, System, TestSystem}; + +struct BrokerCore { + guest: UnixStream, + host_output: VecDeque, + instance_id: [u8; 16], + epoch: u64, + initialized: bool, + active_host: bool, + host_receive_sequence: u64, + host_send_sequence: u64, + guest_send_sequence: u64, + guest_receive_sequence: u64, + receive_window: u32, + receive_credit: u32, +} + +impl BrokerCore { + fn new(guest: UnixStream) -> Self { + Self { + guest, + host_output: VecDeque::new(), + instance_id: core::array::from_fn(|index| index as u8 + 1), + epoch: 0, + initialized: false, + active_host: false, + host_receive_sequence: 0, + host_send_sequence: 0, + guest_send_sequence: 0, + guest_receive_sequence: 0, + receive_window: 0, + receive_credit: 0, + } + } + + fn attach_host(&mut self, header: OuterHeader, payload: &[u8]) -> io::Result<()> { + if header.record_type != OUTER_HOST_ATTACH + || header.instance_id != [0; 16] + || header.epoch != 0 + || header.sequence != 0 + || payload.len() != protocol::CAPABILITY_LEN + || self.active_host + { + return Err(protocol_error()); + } + self.host_output.clear(); + self.host_receive_sequence = 0; + self.host_send_sequence = 1; + if !self.initialized { + let (attach, payload) = self.read_guest(Some(Duration::from_secs(5)))?; + if attach.record_type != OUTER_GUEST_ATTACH + || attach.instance_id != [0; 16] + || attach.epoch != 0 + || attach.sequence != 0 + || !payload.is_empty() + { + return Err(protocol_error()); + } + self.epoch = 1; + self.reset_guest()?; + self.initialized = true; + } + self.active_host = true; + self.queue_host(OUTER_READY, 0, &[]) + } + + fn accept_host_data(&mut self, header: OuterHeader, payload: &[u8]) -> io::Result<()> { + if !self.active_host + || header.record_type != OUTER_DATA + || header.instance_id != self.instance_id + || header.epoch != self.epoch + || header.sequence != self.host_receive_sequence + || payload.is_empty() + || payload.len() > self.receive_credit as usize + { + return Err(protocol_error()); + } + self.host_receive_sequence = self.host_receive_sequence.wrapping_add(1); + self.receive_credit -= payload.len() as u32; + let record = protocol::encode_outer( + OUTER_DATA, + &self.instance_id, + self.epoch, + self.guest_send_sequence, + payload, + ) + .map_err(|_| protocol_error())?; + self.guest_send_sequence = self.guest_send_sequence.wrapping_add(1); + self.guest.write_all(&record) + } + + fn pump_guest(&mut self, timeout: Option) -> io::Result<()> { + loop { + let (header, payload) = self.read_guest(timeout)?; + if header.instance_id != self.instance_id + || header.epoch != self.epoch + || header.sequence != self.guest_receive_sequence + { + return Err(protocol_error()); + } + self.guest_receive_sequence = self.guest_receive_sequence.wrapping_add(1); + match header.record_type { + OUTER_CREDIT => { + let credit = + protocol::decode_credit_payload(&payload).map_err(|_| protocol_error())?; + let available = self + .receive_credit + .checked_add(credit) + .filter(|available| *available <= self.receive_window) + .ok_or_else(protocol_error)?; + self.receive_credit = available; + } + OUTER_DATA => { + self.queue_host(OUTER_DATA, self.host_send_sequence, &payload)?; + self.host_send_sequence = self.host_send_sequence.wrapping_add(1); + return Ok(()); + } + _ => return Err(protocol_error()), + } + } + } + + fn reset_active_host(&mut self) -> io::Result<()> { + if !self.initialized { + return Err(protocol_error()); + } + self.epoch = self + .epoch + .checked_add(1) + .ok_or_else(|| io::Error::from_raw_os_error(libc::EOVERFLOW))?; + self.host_output.clear(); + self.receive_credit = 0; + self.reset_guest()?; + self.queue_host(OUTER_RESET, 0, &[]) + } + + fn reset_guest(&mut self) -> io::Result<()> { + self.guest_send_sequence = 1; + self.guest_receive_sequence = 0; + let reset = protocol::encode_outer(OUTER_RESET, &self.instance_id, self.epoch, 0, &[]) + .map_err(|_| protocol_error())?; + self.guest.write_all(&reset)?; + loop { + let (header, payload) = self.read_guest(Some(Duration::from_secs(5)))?; + if header.record_type == OUTER_ACK + && header.instance_id == self.instance_id + && header.epoch == self.epoch + && header.sequence == 0 + { + let window = + protocol::decode_ack_payload(&payload).map_err(|_| protocol_error())?; + self.receive_window = window; + self.receive_credit = window; + self.guest_receive_sequence = 1; + return Ok(()); + } + // The real broker discards records from the retired epoch while reset drains. + } + } + + fn queue_host(&mut self, record_type: u8, sequence: u64, payload: &[u8]) -> io::Result<()> { + let record = protocol::encode_outer( + record_type, + &self.instance_id, + self.epoch, + sequence, + payload, + ) + .map_err(|_| protocol_error())?; + self.host_output.extend(record); + Ok(()) + } + + fn read_guest(&mut self, timeout: Option) -> io::Result<(OuterHeader, Vec)> { + self.guest.set_read_timeout(timeout)?; + let mut header = [0u8; OUTER_HEADER_LEN]; + self.guest.read_exact(&mut header).map_err(timeout_error)?; + let header = protocol::decode_outer_header(&header).map_err(|_| protocol_error())?; + let mut payload = vec![0; header.length]; + self.guest.read_exact(&mut payload).map_err(timeout_error)?; + Ok((header, payload)) + } +} + +struct BrokerTransport { + core: Arc>, +} + +impl Drop for BrokerTransport { + fn drop(&mut self) { + if let Ok(mut core) = self.core.lock() { + core.active_host = false; + } + } +} + +impl Transport for BrokerTransport { + fn read(&mut self, buffer: &mut [u8], timeout: Option) -> io::Result { + let mut core = self.core.lock().unwrap(); + if core.host_output.is_empty() { + core.pump_guest(timeout)?; + } + let count = buffer.len().min(core.host_output.len()); + for byte in &mut buffer[..count] { + *byte = core.host_output.pop_front().expect("the count is bounded"); + } + Ok(count) + } + + fn write_all(&mut self, data: &[u8], _timeout: Option) -> io::Result<()> { + if data.len() < OUTER_HEADER_LEN { + return Err(protocol_error()); + } + let header = protocol::decode_outer_header( + data[..OUTER_HEADER_LEN] + .try_into() + .map_err(|_| protocol_error())?, + ) + .map_err(|_| protocol_error())?; + let payload = data + .get(OUTER_HEADER_LEN..) + .filter(|payload| payload.len() == header.length) + .ok_or_else(protocol_error)?; + let mut core = self.core.lock().unwrap(); + match header.record_type { + OUTER_HOST_ATTACH => core.attach_host(header, payload), + OUTER_DATA => core.accept_host_data(header, payload), + _ => Err(protocol_error()), + } + } +} + +#[derive(Clone)] +struct BrokerHandle { + core: Arc>, +} + +impl BrokerHandle { + fn transport(&self) -> Box { + Box::new(BrokerTransport { + core: Arc::clone(&self.core), + }) + } + + fn reset(&self) { + self.core.lock().unwrap().reset_active_host().unwrap(); + } +} + +struct LoopbackSystem { + inner: TestSystem, +} + +impl System for LoopbackSystem { + fn paths(&self) -> &Paths { + self.inner.paths() + } + + fn mount( + &mut self, + source: Option<&CStr>, + target: &CStr, + filesystem: Option<&CStr>, + flags: MountFlags, + ) -> Result<(), Errno> { + self.inner.mount(source, target, filesystem, flags) + } + + fn chown(&mut self, path: &CStr, uid: u32, gid: u32) -> Result<(), Errno> { + self.inner.chown(path, uid, gid) + } + + fn chmod(&mut self, path: &CStr, mode: Mode) -> Result<(), Errno> { + self.inner.chmod(path, mode) + } + + fn configure_tty(&mut self, descriptor: &FileDescriptor) -> Result<(), Errno> { + self.inner.configure_tty(descriptor) + } + + fn drain_tty(&mut self, descriptor: &FileDescriptor) -> Result<(), Errno> { + self.inner.drain_tty(descriptor) + } + + fn write_diagnostic(&mut self, message: &[u8]) -> Result<(), Errno> { + self.inner.write_diagnostic(message) + } + + fn write_barrier(&mut self, descriptor: &FileDescriptor, message: &[u8]) -> Result<(), Errno> { + self.inner.write_barrier(descriptor, message) + } + + fn take_inherited_descriptor(&mut self, text: &CStr) -> Result { + self.inner.take_inherited_descriptor(text) + } + + fn validate_environment(&mut self, name: &CStr, value: &CStr) -> Result<(), Errno> { + self.inner.validate_environment(name, value) + } + + fn exec( + &mut self, + path: &CStr, + arguments: &[CString], + environment: Option<&[CString]>, + ) -> Result<(), Errno> { + self.inner.exec(path, arguments, environment) + } + + fn spawn(&mut self, request: SpawnRequest<'_>) -> Result { + spawn_unprivileged(request) + } +} + +fn spawn_unprivileged(request: SpawnRequest<'_>) -> Result { + let arguments: Vec = request + .execution + .argv + .iter() + .map(|argument| c_string(argument)) + .collect::>()?; + let directory = c_string(request.execution.cwd.as_deref().unwrap_or(b"/"))?; + let program = arguments.first().cloned().ok_or(Errno::EPROTO)?; + let mut plan = sys::SpawnPlan::plain(program, arguments, directory, c"/dev/null".to_owned()); + let descriptors = sys::SpawnDescriptors::plain( + request.config_fd, + request.stdout_pipe, + request.stderr_pipe, + request.launch_pipe, + ); + sys::spawn(&mut plan, descriptors) +} + +fn protocol_error() -> io::Error { + io::Error::from_raw_os_error(libc::EPROTO) +} + +fn timeout_error(error: io::Error) -> io::Error { + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) { + io::ErrorKind::TimedOut.into() + } else { + error + } +} + +#[cfg(test)] +mod tests { + use std::thread; + use std::time::Instant; + + use crate::host::session::{ControlSession, ExecEvent, SessionError}; + use crate::protocol::{ + CAPABILITY_LEN, ExitCategory, GuestFeatures, HostMap, Workload, WorkloadEnvironment, + }; + + use super::super::AgentConfig; + use super::super::dispatch; + use super::super::mappings::HostMappings; + use super::super::session::Session; + use super::*; + + fn deadline() -> Instant { + Instant::now() + Duration::from_secs(10) + } + + fn run_exec( + session: &mut ControlSession, + workload: &Workload<'_>, + ) -> (Vec, Vec, ExecEvent) { + let id = session.start_exec(workload, deadline()).unwrap(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + loop { + match session.next_exec_event(id, Some(deadline())).unwrap() { + ExecEvent::Stdout(bytes) => stdout.extend(bytes), + ExecEvent::Stderr(bytes) => stderr.extend(bytes), + event @ ExecEvent::Exit { .. } | event @ ExecEvent::Rejected { .. } => { + return (stdout, stderr, event); + } + } + } + } + + #[test] + fn control_session_broker_and_real_agent_loop_back() { + if super::super::isolate_process_test( + "NVX_AGENT_LOOPBACK_TEST_CHILD", + "guest::managed_agent::loopback::tests::control_session_broker_and_real_agent_loop_back", + ) { + return; + } + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let temporary = tempfile::tempdir().unwrap(); + let cgroup = temporary.path().join("cgroup"); + std::fs::create_dir_all(cgroup.join("nvx-exec")).unwrap(); + for (path, contents) in [ + ("cgroup.procs", b"".as_slice()), + ("cgroup.subtree_control", b""), + ("nvx-exec/cgroup.procs", b""), + ("nvx-exec/cgroup.kill", b""), + ("nvx-exec/pids.current", b"0\n"), + ] { + std::fs::write(cgroup.join(path), contents).unwrap(); + } + let hostfs = temporary.path().join("hostfs"); + std::fs::create_dir_all(hostfs.join("root/0/source")).unwrap(); + let target = temporary.path().join("mapped"); + let paths = Paths { + cgroup_root: cgroup, + hostfs_dir: hostfs, + ..Paths::default() + }; + let system = LoopbackSystem { + inner: TestSystem::new(paths), + }; + let (broker_guest, agent) = UnixStream::pair().unwrap(); + let agent_descriptor = FileDescriptor::from_owned(agent.into()); + let agent_thread = thread::spawn(move || { + let mut system = system; + let config = AgentConfig { + rootfs: c"-".to_owned(), + hostname: c"loopback".to_owned(), + uid: c"1000".to_owned(), + gid: c"1000".to_owned(), + user: c"workload".to_owned(), + home: c"/".to_owned(), + direct: true, + }; + let mut session = Session::new(agent_descriptor); + let mut mappings = HostMappings { + expected: 1, + ..HostMappings::default() + }; + let outcome = dispatch::run(&mut system, &mut session, &config, &mut mappings).unwrap(); + (outcome, system) + }); + let core = Arc::new(Mutex::new(BrokerCore::new(broker_guest))); + let broker = BrokerHandle { core }; + let capability = [0x5a; CAPABILITY_LEN]; + let mut session = + ControlSession::attach(broker.transport(), &capability, deadline()).unwrap(); + + session.ping(deadline()).unwrap(); + assert_eq!( + session.features(deadline()).unwrap(), + GuestFeatures::DIRECT_AGENT + ); + session + .map_host_paths( + &[HostMap { + source: "0/source".to_owned(), + target: target.to_string_lossy().into_owned(), + read_only: true, + }], + deadline(), + ) + .unwrap(); + + let workload = |script: &str, timeout_ms| Workload { + argv: vec!["/bin/sh".to_owned(), "-c".to_owned(), script.to_owned()], + timeout_ms, + cwd: None, + environment: WorkloadEnvironment::Default, + }; + let (stdout, stderr, exit) = run_exec( + &mut session, + &workload("printf out; printf err >&2; exit 7", 0), + ); + assert_eq!((stdout, stderr), (b"out".to_vec(), b"err".to_vec())); + assert_eq!( + exit, + ExecEvent::Exit { + category: ExitCategory::Exit, + status: 7 + } + ); + + let (_, _, signal) = run_exec(&mut session, &workload("kill -TERM $$", 0)); + assert_eq!( + signal, + ExecEvent::Exit { + category: ExitCategory::Signal, + status: 128 + libc::SIGTERM + } + ); + let (_, _, realtime_signal) = run_exec(&mut session, &workload("kill -40 $$", 0)); + assert_eq!( + realtime_signal, + ExecEvent::Exit { + category: ExitCategory::Signal, + status: 128 + 40 + } + ); + let (_, _, timeout) = run_exec(&mut session, &workload("sleep 10", 25)); + assert_eq!( + timeout, + ExecEvent::Exit { + category: ExitCategory::Timeout, + status: 124 + } + ); + let (_, _, limited) = run_exec(&mut session, &workload("head -c 1048577 /dev/zero", 0)); + assert_eq!( + limited, + ExecEvent::Exit { + category: ExitCategory::OutputLimit, + status: 125 + } + ); + + let cancel_id = session + .start_exec(&workload("sleep 10", 0), deadline()) + .unwrap(); + session.cancel_exec(cancel_id, deadline()).unwrap(); + assert_eq!( + session + .next_exec_event(cancel_id, Some(deadline())) + .unwrap(), + ExecEvent::Exit { + category: ExitCategory::Cancelled, + status: 128 + libc::SIGKILL + } + ); + + let reset_id = session + .start_exec(&workload("sleep 10", 0), deadline()) + .unwrap(); + broker.reset(); + assert!(matches!( + session.next_exec_event(reset_id, Some(deadline())), + Err(SessionError::Reset) + )); + drop(session); + let mut session = + ControlSession::attach(broker.transport(), &capability, deadline()).unwrap(); + session.ping(deadline()).unwrap(); + session.stop(deadline()).unwrap(); + drop(session); + + let (_outcome, system) = agent_thread.join().unwrap(); + assert_eq!(system.inner.mounts.len(), 2); + assert!(system.inner.diagnostics.is_empty()); + } +} diff --git a/nvx-rs/src/guest/managed_agent/mappings.rs b/nvx-rs/src/guest/managed_agent/mappings.rs new file mode 100644 index 000000000..4574f3306 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/mappings.rs @@ -0,0 +1,333 @@ +use std::io::Read; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; + +use crate::guest::sys::{self, Errno, Mode, MountFlags, OpenFlags, c_string}; +use crate::protocol::{self, GuestHostMap}; + +use super::seam::{System, path_c_string}; + +/// Progress and latched failure state of the boot-announced host mapping table. +#[derive(Default)] +pub(crate) struct HostMappings { + /// Number of mappings announced on the kernel command line. + pub(crate) expected: u32, + /// Number of mappings mounted in order. + pub(crate) applied: u32, + /// Whether a prior mount failure permanently refused the remaining table. + pub(crate) failed: bool, +} + +/// Failure returned while validating or applying one MAPS request. +#[derive(Debug)] +pub(crate) enum MappingError { + /// The payload or sequence does not match the announced table. + Invalid, + /// A specific mapping failed while being mounted. + Mount { + /// Errno returned by mount preparation or the mount itself. + status: i32, + /// Zero-based mapping-table entry that failed. + entry: u32, + }, +} + +/// Reads the announced mapping count and validates the host export mount. +pub(crate) fn prepare(system: &mut impl System, mappings: &mut HostMappings) -> Result<(), Errno> { + let mut command_line = [0u8; 4096]; + let count = std::fs::File::open(&system.paths().proc_cmdline) + .and_then(|mut file| file.read(&mut command_line[..4095])) + .map_err(|error| sys::errno_of(&error))?; + mappings.expected = + protocol::parse_host_mapping_count(&command_line[..count]).map_err(|_| Errno::EINVAL)?; + let hostfs = system.paths().hostfs_dir.clone(); + let root = hostfs.join("root"); + let mounted = match (std::fs::metadata(&hostfs), std::fs::metadata(&root)) { + (Ok(parent), Ok(export)) => parent.dev() != export.dev(), + _ => false, + }; + if mounted { + let hostfs = path_c_string(&hostfs)?; + system.chown(&hostfs, 0, 0)?; + system.chmod(&hostfs, Mode::from_bits_retain(0o700))?; + } + if mappings.expected != 0 && !mounted { + return Err(Errno::ENOENT); + } + Ok(()) +} + +/// Validates and applies one ordered batch of host mappings. +pub(crate) fn apply_request( + system: &mut impl System, + mappings: &mut HostMappings, + payload: &[u8], +) -> Result<(), MappingError> { + if mappings.failed { + return Err(MappingError::Invalid); + } + let request = + protocol::decode_guest_maps_payload(payload).map_err(|_| MappingError::Invalid)?; + let count = u32::try_from(request.maps.len()).map_err(|_| MappingError::Invalid)?; + if request.first != mappings.applied + || count == 0 + || mappings.applied > mappings.expected + || count > mappings.expected - mappings.applied + { + return Err(MappingError::Invalid); + } + for (offset, mapping) in request.maps.iter().enumerate() { + if let Err(error) = apply(system, mapping) { + mappings.failed = true; + return Err(MappingError::Mount { + status: error as i32, + entry: request.first + offset as u32, + }); + } + mappings.applied += 1; + } + Ok(()) +} + +/// Creates one target and bind-mounts a validated mapping onto it. +fn apply(system: &mut impl System, mapping: &GuestHostMap) -> Result<(), Errno> { + let root = system.paths().hostfs_dir.join("root"); + let source = append_bytes(&root, &mapping.source); + let target = PathBuf::from(std::ffi::OsStr::from_bytes(&mapping.target)); + let metadata = std::fs::symlink_metadata(&source).map_err(|error| sys::errno_of(&error))?; + if metadata.file_type().is_dir() { + make_directories(&target, true)?; + } else if metadata.file_type().is_file() { + make_directories(&target, false)?; + match std::fs::symlink_metadata(&target) { + Ok(existing) if existing.file_type().is_file() => {} + Ok(_) => return Err(Errno::EINVAL), + Err(error) => match sys::errno_of(&error) { + Errno::ENOENT => create_file(&target)?, + error => return Err(error), + }, + } + } else { + return Err(Errno::EINVAL); + } + let source = path_c_string(&source)?; + let target = path_c_string(&target)?; + system.mount(Some(&source), &target, None, MountFlags::MS_BIND)?; + let mut flags = + MountFlags::MS_BIND | MountFlags::MS_REMOUNT | MountFlags::MS_NOSUID | MountFlags::MS_NODEV; + if mapping.read_only { + flags |= MountFlags::MS_RDONLY; + } + system.mount(None, &target, None, flags) +} + +/// Appends arbitrary Unix bytes to a trusted root path. +fn append_bytes(root: &Path, suffix: &[u8]) -> PathBuf { + let mut bytes = root.as_os_str().as_bytes().to_vec(); + bytes.push(b'/'); + bytes.extend_from_slice(suffix); + PathBuf::from(std::ffi::OsStr::from_bytes(&bytes)) +} + +/// Creates every missing directory component in `path`. +fn make_directories(path: &Path, include_last: bool) -> Result<(), Errno> { + let bytes = path.as_os_str().as_bytes(); + for index in 1..=bytes.len() { + if bytes.get(index) == Some(&b'/') || (index == bytes.len() && include_last) { + let directory = c_string(&bytes[..index])?; + match sys::create_directory(&directory, Mode::from_bits_retain(0o755)) { + Ok(()) | Err(Errno::EEXIST) => {} + Err(error) => return Err(error), + } + } + } + Ok(()) +} + +/// Atomically creates one regular mapping target without following a symlink. +/// +/// As in the C agent, the new file closes at once, and a close failure is ignored. +fn create_file(path: &Path) -> Result<(), Errno> { + let path = path_c_string(path)?; + sys::open( + &path, + OpenFlags::O_RDONLY + | OpenFlags::O_CREAT + | OpenFlags::O_EXCL + | OpenFlags::O_CLOEXEC + | OpenFlags::O_NOFOLLOW, + Mode::from_bits_retain(0o644), + ) + .map(|_created| ()) +} + +#[cfg(test)] +mod tests { + use std::os::unix::ffi::OsStrExt; + + use super::super::seam::{Paths, TestSystem}; + use super::*; + + fn setup(expected: u32) -> (tempfile::TempDir, TestSystem, HostMappings) { + let temporary = tempfile::tempdir().unwrap(); + let hostfs = temporary.path().join("hostfs"); + std::fs::create_dir_all(hostfs.join("root/0/src")).unwrap(); + std::fs::write(hostfs.join("root/0/notes.txt"), b"notes").unwrap(); + std::fs::create_dir(hostfs.join("root/1")).unwrap(); + let paths = Paths { + hostfs_dir: hostfs, + ..Paths::default() + }; + ( + temporary, + TestSystem::new(paths), + HostMappings { + expected, + ..HostMappings::default() + }, + ) + } + + fn payload(first: u32, entries: &[(&[u8], &[u8], u16)], count: Option) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(&first.to_le_bytes()); + payload.extend_from_slice(&count.unwrap_or(entries.len() as u32).to_le_bytes()); + for (source, target, flags) in entries { + payload.extend_from_slice(&flags.to_le_bytes()); + payload.extend_from_slice(&(source.len() as u16).to_le_bytes()); + payload.extend_from_slice(&(target.len() as u16).to_le_bytes()); + payload.extend_from_slice(source); + payload.extend_from_slice(target); + } + payload + } + + #[test] + fn mappings_mount_in_order_and_unblock_workloads() { + let (temporary, mut system, mut mappings) = setup(3); + let guest = temporary.path().join("guest"); + let source = guest.join("src"); + let notes = guest.join("src/notes.txt"); + let out = guest.join("out"); + let first = payload( + 0, + &[ + ( + b"0/src", + source.as_os_str().as_bytes(), + protocol::MAP_READ_ONLY, + ), + (b"0/notes.txt", notes.as_os_str().as_bytes(), 0), + ], + None, + ); + apply_request(&mut system, &mut mappings, &first).unwrap(); + assert_eq!((mappings.applied, mappings.expected), (2, 3)); + let second = payload(2, &[(b"1", out.as_os_str().as_bytes(), 0)], None); + apply_request(&mut system, &mut mappings, &second).unwrap(); + assert_eq!((mappings.applied, mappings.expected), (3, 3)); + assert_eq!(system.mounts.len(), 6); + assert_eq!( + system.mounts[1].2, + MountFlags::MS_BIND + | MountFlags::MS_REMOUNT + | MountFlags::MS_NOSUID + | MountFlags::MS_NODEV + | MountFlags::MS_RDONLY + ); + assert!(source.is_dir()); + assert!(notes.is_file()); + assert!(out.is_dir()); + } + + #[test] + fn entries_beyond_the_announcement_or_out_of_order_are_refused() { + let (temporary, mut system, mut mappings) = setup(1); + let target = temporary.path().join("guest/src"); + let entry = [(b"0/src".as_slice(), target.as_os_str().as_bytes(), 0)]; + for invalid in [payload(1, &entry, None), payload(0, &entry, Some(2))] { + assert!(matches!( + apply_request(&mut system, &mut mappings, &invalid), + Err(MappingError::Invalid) + )); + } + apply_request(&mut system, &mut mappings, &payload(0, &entry, None)).unwrap(); + assert!(matches!( + apply_request(&mut system, &mut mappings, &payload(1, &entry, None)), + Err(MappingError::Invalid) + )); + assert_eq!(system.mounts.len(), 2); + } + + #[test] + fn malformed_tables_mount_nothing() { + let (temporary, mut system, mut mappings) = setup(2); + let target = temporary.path().join("guest/x"); + let good = (b"0/src".as_slice(), target.as_os_str().as_bytes(), 0); + let malformed = [ + payload( + 0, + &[good, (b"0/../1", target.as_os_str().as_bytes(), 0)], + None, + ), + payload( + 0, + &[good, (b"/0/src", target.as_os_str().as_bytes(), 0)], + None, + ), + payload(0, &[good, (b"0/src", b"relative", 0)], None), + payload(0, &[good, (b".", target.as_os_str().as_bytes(), 0)], None), + payload( + 0, + &[good, (b"0//src", target.as_os_str().as_bytes(), 0)], + None, + ), + payload( + 0, + &[good, (b"0/src", target.as_os_str().as_bytes(), 2)], + None, + ), + payload(0, &[], Some(0)), + vec![0, 0, 0], + ]; + for payload in malformed { + assert!(matches!( + apply_request(&mut system, &mut mappings, &payload), + Err(MappingError::Invalid) + )); + } + assert!(system.mounts.is_empty()); + assert_eq!(mappings.applied, 0); + } + + #[test] + fn without_an_announcement_tables_are_refused() { + let (temporary, mut system, mut mappings) = setup(0); + let target = temporary.path().join("guest/src"); + let request = payload(0, &[(b"0/src", target.as_os_str().as_bytes(), 0)], None); + assert!(matches!( + apply_request(&mut system, &mut mappings, &request), + Err(MappingError::Invalid) + )); + assert!(system.mounts.is_empty()); + } + + #[test] + fn a_failed_mount_latches_and_keeps_refusing_tables() { + let (temporary, mut system, mut mappings) = setup(2); + system.fail_mount_call = Some(1); + let target = temporary.path().join("guest/src"); + let request = payload(0, &[(b"0/src", target.as_os_str().as_bytes(), 0)], None); + assert!(matches!( + apply_request(&mut system, &mut mappings, &request), + Err(MappingError::Mount { status, entry: 0 }) if status == Errno::ENOENT as i32 + )); + assert!(mappings.failed); + assert!(matches!( + apply_request(&mut system, &mut mappings, &request), + Err(MappingError::Invalid) + )); + assert_eq!(mappings.applied, 0); + } +} diff --git a/nvx-rs/src/guest/managed_agent/mod.rs b/nvx-rs/src/guest/managed_agent/mod.rs new file mode 100644 index 000000000..cf56cd11e --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/mod.rs @@ -0,0 +1,203 @@ +//! `nvx-managed-agent CONTROL_TTY ROOTFS HOSTNAME UID GID USER HOME`: the managed guest agent, +//! which serves NVX's authenticated control session on `CONTROL_TTY` and runs one workload at a +//! time as `UID` and `GID`. +//! +//! With `ROOTFS` `-`, the direct agent runs as PID 1: it applies the host mapping table, launches +//! each workload in its own cgroup and process group, reaps orphaned processes, and powers the VM +//! off after `STOP`. Otherwise the sandbox agent launches each workload in `ROOTFS` through the +//! container helpers, behind their FIFO barrier, and returns to the init agent after `STOP`. Both +//! keep the C agent's command lines, wire behavior, status and error categories, 1 MiB output and +//! receive windows, timeout and cancellation behavior, and sealed memfd execution configuration. +//! +//! `nvx-managed-agent --exec-config-fd FD -- WORKLOAD...` is the agent's internal execution +//! helper. The launch runs it once the workload's identity applies, and it applies the sealed +//! configuration in descriptor `FD`, then executes the workload. The agent and the helper exit +//! with 0, or with 125 when they fail. +//! +//! # Structure +//! +//! The submodules divide the agent by concern. Mounts, ownership and mode changes, the diagnostic +//! port, TTY operations, the helper's inherited descriptor and `exec`, the workload launch, and +//! the production paths are behind a system seam. Production implements it with the safe `sys` +//! wrappers; tests substitute temporary directories and recording operations, including failures +//! of mount, barrier write, environment export, and `exec`. +//! +//! The fork boundary receives only owned C strings, owned descriptors, and precomputed +//! descriptor actions. Before `fork` it constructs the complete `argv` and `envp`, and the child +//! performs no allocation or Rust cleanup before `execve` or `_exit`. The helper builds the +//! workload's environment in memory too, as C's environment functions changed its own, and +//! passes it to `execve`, so neither process changes its environment. +//! +//! Linux arguments, paths, and environment strings are bytes, not necessarily UTF-8, so the +//! protocol's guest `EXEC` and `MAPS` decoders accept what the C guest accepts. The boot contract +//! is one `nvx_maps=COUNT` token from 1 through 4096, and retired `nvx_map=` tokens are ignored. + +use std::ffi::CString; +use std::process::ExitCode; + +use super::status::{CommandError, ExitStatus}; +use super::sys::{self, Errno}; + +/// Direct-mode cgroup preparation and workload cleanup. +mod cgroup; +/// C-compatible diagnostics. +mod diagnostics; +/// Long-lived control request dispatcher. +mod dispatch; +/// Environments that the agent and its helper build for `execve`. +mod environment; +/// Workload execution, output, cancellation, and timeout engine. +mod exec; +/// Sealed private configuration shared with helper mode. +mod exec_config; +/// Short-lived post-privilege-drop helper mode. +mod helper; +/// Precomputed direct and sandbox launch plans. +mod launch; +#[cfg(all(test, feature = "host"))] +/// In-process host/broker/agent integration harness. +mod loopback; +/// Ordered host-filesystem mapping application. +mod mappings; +/// Replaceable privileged-system seam. +mod seam; +/// Outer control-session sequencing and credit. +mod session; +/// Control-terminal setup and drain behavior. +mod tty; + +use diagnostics::write_stderr; +use mappings::HostMappings; +use seam::{Paths, RealSystem, System, path_c_string}; +use session::Session; + +#[cfg(test)] +/// Serializes tests that temporarily alter process-global state. +pub(crate) static PROCESS_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Runs a process-global agent test in a dedicated test-harness subprocess. +/// +/// Returns `true` in the parent after the isolated run succeeds and `false` in the child that +/// should execute the test body. Isolation prevents PID-1-style `waitpid(-1)` behavior from +/// reaping children that unrelated parallel host tests own. +#[cfg(test)] +pub(crate) fn isolate_process_test(marker: &str, test: &str) -> bool { + if std::env::var_os(marker).is_some() { + return false; + } + let status = std::process::Command::new( + std::env::current_exe().expect("the test executable has a path"), + ) + .args(["--exact", test, "--nocapture"]) + .env(marker, "1") + .status() + .expect("the isolated test process starts"); + assert!(status.success(), "the isolated test process failed"); + true +} + +/// Immutable command-line configuration of a long-lived agent. +pub(crate) struct AgentConfig { + /// Sandbox root filesystem, or `-` in direct mode. + rootfs: CString, + /// Sandbox hostname. + hostname: CString, + /// Decimal workload user ID. + uid: CString, + /// Decimal workload group ID. + gid: CString, + /// Workload user name. + user: CString, + /// Workload home directory. + home: CString, + /// Whether this process is the direct PID-1 agent. + direct: bool, +} + +/// Runs `nvx-managed-agent` with the process's arguments. It exits with 0, or 125 when the agent +/// or its execution helper fails. +pub fn main() -> ExitCode { + sys::restore_sigpipe(); + let mut system = RealSystem::new(Paths::default()); + ExitStatus::from(run(&mut system, &sys::args())).into() +} + +/// Dispatches the executable's helper or long-lived agent mode. +fn run(system: &mut impl System, arguments: &[CString]) -> Result<(), CommandError> { + if arguments + .get(1) + .is_some_and(|argument| argument.as_bytes() == b"--exec-config-fd") + { + return helper::run(system, arguments); + } + if arguments.len() != 8 { + return Err(CommandError::Agent); + } + let config = AgentConfig { + rootfs: arguments[2].clone(), + hostname: arguments[3].clone(), + uid: arguments[4].clone(), + gid: arguments[5].clone(), + user: arguments[6].clone(), + home: arguments[7].clone(), + direct: arguments[2].as_bytes() == b"-", + }; + let result = serve(system, &arguments[1], &config); + finish(system, &config, result) +} + +/// Serves the control session on `control_tty` until the host stops the agent. +fn serve( + system: &mut impl System, + control_tty: &CString, + config: &AgentConfig, +) -> Result<(), CommandError> { + let fd = match tty::open(control_tty) { + Ok(fd) => fd, + Err(error) => return Err(fatal(system, "control-open", error)), + }; + let mut mappings = HostMappings::default(); + if config.direct + && let Err(error) = mappings::prepare(system, &mut mappings) + { + return Err(fatal(system, "host-mappings", error)); + } + if let Err(error) = tty::configure(system, &fd) { + return Err(fatal(system, "control-tty", error)); + } + let mut session = Session::new(fd); + match dispatch::run(system, &mut session, config, &mut mappings) { + Ok(dispatch::Outcome::Stopped) => Ok(()), + Err(error) => Err(fatal(system, "control-session", error)), + } +} + +/// Emits a fatal diagnostic on both the port and standard error, and returns the failure. +fn fatal(system: &mut impl System, stage: &str, error: Errno) -> CommandError { + let status = error as i32; + diagnostics::error(system, stage, status); + let message = format!("NVX-MANAGED-ERROR: stage={stage} status={status}\n"); + write_stderr(message.as_bytes()); + CommandError::Agent +} + +/// Powers off a direct-mode VM with the agent's exit status, and returns the agent's result. +fn finish( + system: &mut impl System, + config: &AgentConfig, + result: Result<(), CommandError>, +) -> Result<(), CommandError> { + if !config.direct { + return result; + } + let Ok(path) = path_c_string(&system.paths().exit) else { + return result; + }; + let code = (ExitStatus::from(result) as u8).to_string(); + let arguments = [ + c"nvx-exit".to_owned(), + CString::new(code).expect("a decimal status has no NUL"), + ]; + let _ = system.exec(&path, &arguments, None); + result +} diff --git a/nvx-rs/src/guest/managed_agent/seam.rs b/nvx-rs/src/guest/managed_agent/seam.rs new file mode 100644 index 000000000..f149d16b2 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/seam.rs @@ -0,0 +1,331 @@ +use std::ffi::{CStr, CString}; +use std::path::{Path, PathBuf}; + +use crate::guest::sys::{self, Errno, FileDescriptor, Mode, MountFlags, OpenFlags, ProcessId}; + +use super::launch::{self, SpawnRequest}; + +/// Production paths used by the managed agent and replaceable paths used by tests. +#[derive(Clone, Debug)] +pub(crate) struct Paths { + /// Unified cgroup-v2 root. + pub(crate) cgroup_root: PathBuf, + /// Sandbox container cgroup membership file. + pub(crate) container_cgroup_procs: PathBuf, + /// Parent of OpenVMM's aggregate host-filesystem exports. + pub(crate) hostfs_dir: PathBuf, + /// Kernel command line containing the announced mapping count. + pub(crate) proc_cmdline: PathBuf, + /// Port device used for fatal diagnostics. + pub(crate) diagnostic_port: PathBuf, + /// Null device used for child standard input. + pub(crate) null_device: PathBuf, + /// FIFO that releases a sandbox launcher. + pub(crate) barrier: PathBuf, + /// Managed-agent helper executable. + pub(crate) helper: PathBuf, + /// Sandbox container launcher. + pub(crate) launcher: PathBuf, + /// VM exit helper. + pub(crate) exit: PathBuf, + /// Privilege-dropping launcher used in direct mode. + pub(crate) setpriv: PathBuf, +} + +impl Default for Paths { + /// Returns the paths installed in production guest images. + fn default() -> Self { + Self { + cgroup_root: "/sys/fs/cgroup".into(), + container_cgroup_procs: "/sys/fs/cgroup/container/cgroup.procs".into(), + hostfs_dir: "/run/nvx/hostfs".into(), + proc_cmdline: "/proc/cmdline".into(), + diagnostic_port: "/dev/port".into(), + null_device: "/dev/null".into(), + barrier: "/run/nvx/managed-container-start".into(), + helper: "/sbin/nvx-managed-agent".into(), + launcher: "/sbin/nvx-container-launch".into(), + exit: "/sbin/nvx-exit".into(), + setpriv: "/bin/setpriv".into(), + } + } +} + +/// Replaceable boundary for privileged and process-global operations. +pub(crate) trait System { + /// Returns the production or test path set. + fn paths(&self) -> &Paths; + + /// Performs one mount or bind-remount operation. + fn mount( + &mut self, + source: Option<&CStr>, + target: &CStr, + filesystem: Option<&CStr>, + flags: MountFlags, + ) -> Result<(), Errno>; + + /// Changes path ownership. + fn chown(&mut self, path: &CStr, uid: u32, gid: u32) -> Result<(), Errno>; + /// Changes path mode bits. + fn chmod(&mut self, path: &CStr, mode: Mode) -> Result<(), Errno>; + /// Configures the control terminal. + fn configure_tty(&mut self, descriptor: &FileDescriptor) -> Result<(), Errno>; + /// Drains pending control-terminal output. + fn drain_tty(&mut self, descriptor: &FileDescriptor) -> Result<(), Errno>; + /// Writes one diagnostic line through port 0xE9. + fn write_diagnostic(&mut self, message: &[u8]) -> Result<(), Errno>; + /// Writes the sandbox launch barrier. + fn write_barrier(&mut self, descriptor: &FileDescriptor, message: &[u8]) -> Result<(), Errno>; + /// Returns an owned duplicate of the helper's inherited configuration descriptor, which then + /// closes on `exec`. + fn take_inherited_descriptor(&mut self, text: &CStr) -> Result; + /// Validates one variable that an environment receives, without setting it. + /// + /// Production accepts every variable: building an environment in memory cannot fail where C's + /// environment functions could. Tests reject one to cover the C failure paths. + fn validate_environment(&mut self, name: &CStr, value: &CStr) -> Result<(), Errno>; + /// Replaces the current process image, with `environment` or the current one. + fn exec( + &mut self, + path: &CStr, + arguments: &[CString], + environment: Option<&[CString]>, + ) -> Result<(), Errno>; + /// Spawns one managed workload. + fn spawn(&mut self, request: SpawnRequest<'_>) -> Result; +} + +/// Production implementation backed exclusively by safe `guest::sys` APIs. +pub(crate) struct RealSystem { + /// Production path set. + paths: Paths, +} + +impl RealSystem { + /// Creates a production system seam with `paths`. + pub(crate) fn new(paths: Paths) -> Self { + Self { paths } + } +} + +impl System for RealSystem { + fn paths(&self) -> &Paths { + &self.paths + } + + fn mount( + &mut self, + source: Option<&CStr>, + target: &CStr, + filesystem: Option<&CStr>, + flags: MountFlags, + ) -> Result<(), Errno> { + sys::mount(source, target, filesystem, flags) + } + + fn chown(&mut self, path: &CStr, uid: u32, gid: u32) -> Result<(), Errno> { + sys::chown(path, uid, gid) + } + + fn chmod(&mut self, path: &CStr, mode: Mode) -> Result<(), Errno> { + sys::chmod(path, mode) + } + + fn configure_tty(&mut self, descriptor: &FileDescriptor) -> Result<(), Errno> { + sys::configure_raw(descriptor) + } + + fn drain_tty(&mut self, descriptor: &FileDescriptor) -> Result<(), Errno> { + sys::drain(descriptor) + } + + fn write_diagnostic(&mut self, message: &[u8]) -> Result<(), Errno> { + let path = path_c_string(&self.paths.diagnostic_port)?; + let descriptor = sys::open( + &path, + OpenFlags::O_WRONLY | OpenFlags::O_CLOEXEC, + Mode::empty(), + )?; + sys::write_port_diagnostic(&descriptor, message) + } + + fn write_barrier(&mut self, descriptor: &FileDescriptor, message: &[u8]) -> Result<(), Errno> { + descriptor.write_all(message) + } + + fn take_inherited_descriptor(&mut self, text: &CStr) -> Result { + sys::take_inherited_descriptor(text) + } + + fn validate_environment(&mut self, _name: &CStr, _value: &CStr) -> Result<(), Errno> { + Ok(()) + } + + fn exec( + &mut self, + path: &CStr, + arguments: &[CString], + environment: Option<&[CString]>, + ) -> Result<(), Errno> { + Err(sys::exec(path, arguments, environment)) + } + + fn spawn(&mut self, request: SpawnRequest<'_>) -> Result { + launch::spawn(&self.paths, request) + } +} + +/// Converts a path through the system layer for use by the seam and callers. +pub(crate) fn path_c_string(path: &Path) -> Result { + sys::path_c_string(path) +} + +/// One recorded `exec`: the path, the arguments, and the environment if it had one. +#[cfg(test)] +pub(crate) type RecordedExec = (Vec, Vec>, Option>>); + +#[cfg(test)] +pub(crate) struct TestSystem { + pub(crate) paths: Paths, + pub(crate) mounts: Vec<(Option>, Vec, MountFlags)>, + pub(crate) fail_mount_call: Option, + pub(crate) diagnostics: Vec, + pub(crate) fail_environment: Option>, + pub(crate) execs: Vec, + pub(crate) tty_configured: usize, + pub(crate) tty_drained: usize, + pub(crate) close_barrier_reader: Option, + pub(crate) fail_barrier_write: Option, + /// Owned descriptors awaiting a simulated helper-entry transfer. + pub(crate) inherited_descriptors: Vec<(Vec, FileDescriptor)>, +} + +#[cfg(test)] +impl TestSystem { + pub(crate) fn new(paths: Paths) -> Self { + Self { + paths, + mounts: Vec::new(), + fail_mount_call: None, + diagnostics: Vec::new(), + fail_environment: None, + execs: Vec::new(), + tty_configured: 0, + tty_drained: 0, + close_barrier_reader: None, + fail_barrier_write: None, + inherited_descriptors: Vec::new(), + } + } + + /// Registers one owned descriptor and returns its simulated inherited argument. + pub(crate) fn transfer_inherited_descriptor(&mut self, descriptor: FileDescriptor) -> CString { + let argument = descriptor.argument(); + self.inherited_descriptors + .push((argument.as_bytes().to_vec(), descriptor)); + argument + } +} + +#[cfg(test)] +impl System for TestSystem { + fn paths(&self) -> &Paths { + &self.paths + } + + fn mount( + &mut self, + source: Option<&CStr>, + target: &CStr, + _filesystem: Option<&CStr>, + flags: MountFlags, + ) -> Result<(), Errno> { + self.mounts.push(( + source.map(|source| source.to_bytes().to_vec()), + target.to_bytes().to_vec(), + flags, + )); + if self.fail_mount_call == Some(self.mounts.len()) { + Err(Errno::ENOENT) + } else { + Ok(()) + } + } + + fn chown(&mut self, _path: &CStr, _uid: u32, _gid: u32) -> Result<(), Errno> { + Ok(()) + } + + fn chmod(&mut self, _path: &CStr, _mode: Mode) -> Result<(), Errno> { + Ok(()) + } + + fn configure_tty(&mut self, _descriptor: &FileDescriptor) -> Result<(), Errno> { + self.tty_configured += 1; + Ok(()) + } + + fn drain_tty(&mut self, _descriptor: &FileDescriptor) -> Result<(), Errno> { + self.tty_drained += 1; + Ok(()) + } + + fn write_diagnostic(&mut self, message: &[u8]) -> Result<(), Errno> { + self.diagnostics.extend_from_slice(message); + Ok(()) + } + + fn write_barrier(&mut self, descriptor: &FileDescriptor, message: &[u8]) -> Result<(), Errno> { + self.close_barrier_reader = None; + if let Some(error) = self.fail_barrier_write { + return Err(error); + } + descriptor.write_all(message) + } + + fn take_inherited_descriptor(&mut self, text: &CStr) -> Result { + let index = self + .inherited_descriptors + .iter() + .position(|(argument, _)| argument == text.to_bytes()) + .ok_or(Errno::EBADF)?; + Ok(self.inherited_descriptors.swap_remove(index).1) + } + + fn validate_environment(&mut self, name: &CStr, _value: &CStr) -> Result<(), Errno> { + if self + .fail_environment + .as_deref() + .is_some_and(|failure| failure == name.to_bytes()) + { + Err(Errno::ENOMEM) + } else { + Ok(()) + } + } + + fn exec( + &mut self, + path: &CStr, + arguments: &[CString], + environment: Option<&[CString]>, + ) -> Result<(), Errno> { + let bytes = |strings: &[CString]| { + strings + .iter() + .map(|string| string.as_bytes().to_vec()) + .collect::>() + }; + self.execs.push(( + path.to_bytes().to_vec(), + bytes(arguments), + environment.map(bytes), + )); + Ok(()) + } + + fn spawn(&mut self, _request: SpawnRequest<'_>) -> Result { + Err(Errno::ENOSYS) + } +} diff --git a/nvx-rs/src/guest/managed_agent/session.rs b/nvx-rs/src/guest/managed_agent/session.rs new file mode 100644 index 000000000..aa014e11a --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/session.rs @@ -0,0 +1,240 @@ +use crate::guest::sys::{Errno, FileDescriptor}; +use crate::protocol::{ + self, APP_ERROR, OUTER_ACK, OUTER_CREDIT, OUTER_DATA, OUTER_GUEST_ATTACH, OUTER_HEADER_LEN, + OUTER_MAX_PAYLOAD, OUTER_RECEIVE_WINDOW, OUTER_RESET, OuterHeader, +}; + +/// One decoded outer control record. +pub(crate) struct Record { + /// Validated outer header. + pub(crate) header: OuterHeader, + /// Record payload bytes. + pub(crate) payload: Vec, +} + +/// Sequence, identity, and credit state of one control-console attachment. +pub(crate) struct Session { + /// Owned control-console descriptor. + descriptor: FileDescriptor, + /// Attached instance capability identity. + pub(crate) instance_id: [u8; 16], + /// Current nonzero reset epoch. + pub(crate) epoch: u64, + /// Next sequence sent by the guest. + pub(crate) guest_sequence: u64, + /// Next sequence expected from the host. + pub(crate) host_sequence: u64, +} + +/// Protocol stage that rejected an outer DATA record. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DataFailureKind { + /// Record type or host sequence mismatch. + Sequence, + /// Instance identity or epoch mismatch. + Identity, + /// Malformed application frame or request metadata. + Application, + /// Failure to replenish host receive credit. + Credit, +} + +impl DataFailureKind { + /// Returns the diagnostic stage that reports this failure, as the C agent names it. + pub(crate) fn stage(self) -> &'static str { + match self { + Self::Sequence => "data-sequence", + Self::Identity => "data-identity", + Self::Application => "data-application", + Self::Credit => "data-credit", + } + } +} + +/// Typed failed-DATA validation with diagnostic metadata. +pub(crate) struct DataFailure { + /// Validation stage. + pub(crate) kind: DataFailureKind, + /// C-compatible stage status. + pub(crate) status: i32, + /// Underlying protocol or I/O error. + error: Errno, +} + +impl DataFailure { + /// Consumes the metadata and returns the underlying error. + pub(crate) fn into_error(self) -> Errno { + self.error + } +} + +impl Session { + /// Starts unattached session state on an owned control descriptor. + pub(crate) fn new(descriptor: FileDescriptor) -> Self { + Self { + descriptor, + instance_id: [0; 16], + epoch: 0, + guest_sequence: 0, + host_sequence: 0, + } + } + + /// Borrows the control descriptor for polling and TTY operations. + pub(crate) fn descriptor(&self) -> &FileDescriptor { + &self.descriptor + } + + /// Sends the initial guest-attach record. + pub(crate) fn send_attach(&mut self) -> Result<(), Errno> { + self.write_outer(OUTER_GUEST_ATTACH, &[0; 16], 0, 0, &[]) + } + + /// Validates and acknowledges a host RESET record. + pub(crate) fn acknowledge_reset(&mut self, record: &Record) -> Result<(), Errno> { + if record.header.record_type != OUTER_RESET + || !record.payload.is_empty() + || record.header.epoch == 0 + { + return Err(Errno::EPROTO); + } + self.instance_id = record.header.instance_id; + self.epoch = record.header.epoch; + self.guest_sequence = 0; + self.host_sequence = record.header.sequence.wrapping_add(1); + let payload = protocol::encode_ack_payload(OUTER_RECEIVE_WINDOW).map_err(protocol_error)?; + self.write_outer( + OUTER_ACK, + &self.instance_id, + self.epoch, + self.guest_sequence, + &payload, + )?; + self.guest_sequence = 1; + Ok(()) + } + + /// Sends one outer record in the attached epoch. + pub(crate) fn send_record(&mut self, record_type: u8, payload: &[u8]) -> Result<(), Errno> { + self.write_outer( + record_type, + &self.instance_id, + self.epoch, + self.guest_sequence, + payload, + )?; + self.guest_sequence = self.guest_sequence.wrapping_add(1); + Ok(()) + } + + /// Replenishes host receive credit after consuming bytes. + pub(crate) fn send_credit(&mut self, bytes: usize) -> Result<(), Errno> { + if bytes == 0 { + return Ok(()); + } + let bytes = u32::try_from(bytes).map_err(|_| Errno::EOVERFLOW)?; + let payload = protocol::encode_credit_payload(bytes).map_err(protocol_error)?; + self.send_record(OUTER_CREDIT, &payload) + } + + /// Encodes and sends one application frame. + pub(crate) fn send_app( + &mut self, + kind: u8, + request_id: u64, + status: i32, + payload: &[u8], + ) -> Result<(), Errno> { + if protocol::APP_HEADER_LEN + .checked_add(payload.len()) + .is_none_or(|length| length > OUTER_MAX_PAYLOAD) + { + return Err(Errno::EMSGSIZE); + } + let frame = protocol::encode_app(kind, request_id, status, payload); + self.send_record(OUTER_DATA, &frame) + } + + /// Sends one application ERROR frame with an ASCII category. + pub(crate) fn send_error( + &mut self, + request_id: u64, + status: i32, + category: &str, + ) -> Result<(), Errno> { + self.send_app(APP_ERROR, request_id, status, category.as_bytes()) + } + + /// Reads and decodes one complete outer record. + pub(crate) fn read_record(&mut self) -> Result { + let mut header = [0u8; OUTER_HEADER_LEN]; + self.descriptor.read_exact(&mut header)?; + let header = protocol::decode_outer_header(&header).map_err(protocol_error)?; + let mut payload = vec![0; header.length]; + self.descriptor.read_exact(&mut payload)?; + Ok(Record { header, payload }) + } + + /// Validates a DATA record and advances host sequence and credit. + pub(crate) fn validate_data<'a>( + &mut self, + record: &'a Record, + ) -> Result, DataFailure> { + if record.header.record_type != OUTER_DATA || record.header.sequence != self.host_sequence { + return Err(data_failure( + DataFailureKind::Sequence, + record.header.sequence as i32, + )); + } + if record.header.epoch != self.epoch || record.header.instance_id != self.instance_id { + return Err(data_failure( + DataFailureKind::Identity, + record.header.epoch as i32, + )); + } + let frame = protocol::decode_app(&record.payload) + .map_err(|_| data_failure(DataFailureKind::Application, record.payload.len() as i32))?; + if frame.request_id == 0 || frame.status != 0 { + return Err(data_failure( + DataFailureKind::Application, + record.payload.len() as i32, + )); + } + self.host_sequence = self.host_sequence.wrapping_add(1); + self.send_credit(record.payload.len()) + .map_err(|error| DataFailure { + kind: DataFailureKind::Credit, + status: record.payload.len() as i32, + error, + })?; + Ok(frame) + } + + /// Encodes and writes one outer record. + fn write_outer( + &self, + record_type: u8, + instance_id: &[u8; 16], + epoch: u64, + sequence: u64, + payload: &[u8], + ) -> Result<(), Errno> { + let record = protocol::encode_outer(record_type, instance_id, epoch, sequence, payload) + .map_err(protocol_error)?; + self.descriptor.write_all(&record) + } +} + +/// Maps an in-memory codec error to the control protocol's `EPROTO`. +fn protocol_error(_: protocol::ProtocolError) -> Errno { + Errno::EPROTO +} + +/// Constructs one typed DATA validation failure. +fn data_failure(kind: DataFailureKind, status: i32) -> DataFailure { + DataFailure { + kind, + status, + error: Errno::EPROTO, + } +} diff --git a/nvx-rs/src/guest/managed_agent/tty.rs b/nvx-rs/src/guest/managed_agent/tty.rs new file mode 100644 index 000000000..5a570f106 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/tty.rs @@ -0,0 +1,48 @@ +use std::ffi::CStr; +use std::time::Duration; + +use crate::guest::sys::{self, Errno, FileDescriptor, Mode, OpenFlags}; + +use super::seam::System; + +/// Number of attempts allowed while the control terminal is still appearing. +const OPEN_ATTEMPTS: usize = 600; +/// Delay between retryable control-terminal open failures. +const OPEN_DELAY: Duration = Duration::from_millis(50); + +/// Opens the control terminal, retrying transient boot-time failures. +pub(crate) fn open(path: &CStr) -> Result { + for attempt in 0..OPEN_ATTEMPTS { + match sys::open( + path, + OpenFlags::O_RDWR | OpenFlags::O_CLOEXEC, + Mode::empty(), + ) { + Ok(descriptor) => return Ok(descriptor), + Err(Errno::ENOENT | Errno::ENXIO | Errno::ENODEV | Errno::EIO) => {} + Err(error) => return Err(error), + } + if attempt + 1 != OPEN_ATTEMPTS { + std::thread::sleep(OPEN_DELAY); + } + } + Err(Errno::ETIMEDOUT) +} + +/// Applies raw local-terminal settings through the replaceable seam. +pub(crate) fn configure( + system: &mut impl System, + descriptor: &FileDescriptor, +) -> Result<(), Errno> { + system.configure_tty(descriptor) +} + +/// Best-effort drains queued control-terminal output. +pub(crate) fn drain(system: &mut impl System, descriptor: &FileDescriptor) { + let _ = system.drain_tty(descriptor); +} + +/// Makes a workload-output descriptor nonblocking. +pub(crate) fn make_nonblocking(descriptor: &FileDescriptor) -> Result<(), Errno> { + descriptor.set_nonblocking() +} diff --git a/nvx-rs/src/guest/mod.rs b/nvx-rs/src/guest/mod.rs index 121b76407..68acd1932 100644 --- a/nvx-rs/src/guest/mod.rs +++ b/nvx-rs/src/guest/mod.rs @@ -2,14 +2,17 @@ //! //! Each module holds one program, and the crate's binary of the same name runs its `main`. The //! programs keep the command lines, exit statuses, and output of the C programs that they -//! replace, except that numeric arguments are decimal or `0x`-prefixed hexadecimal and error -//! messages describe `errno` values with `nix`'s text. +//! replace, except that numeric arguments are plain decimal, or `0x`-prefixed hexadecimal where +//! the C program also read hexadecimal, and error messages describe `errno` values with `nix`'s +//! text. //! //! # Conventions //! //! - Program modules deny unsafe code. Only the private `sys` module may use it, behind //! documented safe operations. It makes every system call through `nix`, with the enabled, -//! default-free feature set, and the programs take random bytes from the `getrandom` crate. +//! default-free feature set, except the few managed-agent operations that `nix` cannot +//! provide, which `sys` documents, and the programs take random bytes from the `getrandom` +//! crate. //! - Descriptors and mappings have owners that release them when dropped. Where a C program //! reported a cleanup failure, a consuming `close` or `unmap` returns it instead. Raw //! descriptor integers never leave `sys`, and no resource is dropped by hand: where a C @@ -32,6 +35,7 @@ pub mod console_pending; pub mod device_io; #[cfg(target_arch = "x86_64")] pub mod exit; +pub mod managed_agent; #[cfg(target_arch = "x86_64")] pub mod mmio_write; pub mod reseed; diff --git a/nvx-rs/src/guest/parse.rs b/nvx-rs/src/guest/parse.rs index aeabdf1a4..1dbfa65e2 100644 --- a/nvx-rs/src/guest/parse.rs +++ b/nvx-rs/src/guest/parse.rs @@ -2,7 +2,8 @@ //! //! The C programs parsed these with `strtoull(text, &end, 0)`, which also accepts leading //! whitespace, a sign, and octal after a leading zero. No caller passes those forms, so the -//! programs reject them as usage errors rather than read them differently. +//! programs reject them as usage errors rather than read them differently. The managed agent's +//! C program read its identities and descriptor in base 10 instead, so they are plain decimal. use std::ffi::CStr; @@ -24,6 +25,19 @@ pub(crate) fn parse_u64(text: &CStr) -> Option { u64::from_str_radix(digits, radix).ok() } +/// Parses a whole, nonempty decimal number, whose leading zeros are decimal, as C's `strtoull` +/// and `strtol` read one in base 10. +/// +/// Returns `None` for an empty argument, any other character, including the leading whitespace +/// and sign that C also skipped, or a value above `u64::MAX`. +pub(crate) fn parse_decimal(text: &[u8]) -> Option { + // `from_str` alone would also accept a leading `+`. + if text.is_empty() || !text.iter().all(u8::is_ascii_digit) { + return None; + } + std::str::from_utf8(text).ok()?.parse().ok() +} + #[cfg(test)] mod tests { use std::ffi::CString; @@ -79,4 +93,36 @@ mod tests { assert_eq!(parse(text), None, "{}", text.escape_ascii()); } } + + #[test] + fn parses_plain_decimal_numbers_with_leading_zeros() { + let cases: &[(&[u8], u64)] = &[ + (b"0", 0), + (b"7", 7), + (b"007", 7), + (b"2147483647", 2_147_483_647), + (b"18446744073709551615", u64::MAX), + ]; + for &(text, expected) in cases { + assert_eq!( + parse_decimal(text), + Some(expected), + "{}", + text.escape_ascii() + ); + } + for text in [ + &b""[..], + b"+7", + b"-1", + b" 42", + b"42 ", + b"1x", + b"0x10", + b"18446744073709551616", + "\u{661}".as_bytes(), + ] { + assert_eq!(parse_decimal(text), None, "{}", text.escape_ascii()); + } + } } diff --git a/nvx-rs/src/guest/status.rs b/nvx-rs/src/guest/status.rs index 997336041..fc2d1de6a 100644 --- a/nvx-rs/src/guest/status.rs +++ b/nvx-rs/src/guest/status.rs @@ -6,7 +6,7 @@ use std::process::ExitCode; /// /// A program exits with [`Success`](Self::Success), or with the status that its /// [`CommandError`] selects. Only `nvx-device-io` exits with the benchmark statuses 3, 4, and -/// 255. +/// 255, and only `nvx-managed-agent` exits with 125. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub(crate) enum ExitStatus { @@ -20,6 +20,8 @@ pub(crate) enum ExitStatus { SetupFailure = 3, /// A benchmark transfer or send was incomplete. TransferFailure = 4, + /// The managed agent, or its execution helper before it executed the workload, failed. + AgentFailure = 125, /// A benchmark could not write its result. OutputFailure = 255, } @@ -37,6 +39,9 @@ pub(crate) enum CommandError { Transfer, /// A benchmark could not write its result. Output, + /// The managed agent or its execution helper failed for any reason, including an invalid + /// command line. It has reported why where its C program did. + Agent, } impl From> for ExitStatus { @@ -48,6 +53,7 @@ impl From> for ExitStatus { Err(CommandError::Setup) => Self::SetupFailure, Err(CommandError::Transfer) => Self::TransferFailure, Err(CommandError::Output) => Self::OutputFailure, + Err(CommandError::Agent) => Self::AgentFailure, } } } @@ -70,6 +76,7 @@ mod tests { (Err(CommandError::Usage), 2), (Err(CommandError::Setup), 3), (Err(CommandError::Transfer), 4), + (Err(CommandError::Agent), 125), (Err(CommandError::Output), 255), ]; for (result, code) in cases { diff --git a/nvx-rs/src/guest/sys/fd.rs b/nvx-rs/src/guest/sys/fd.rs index 656836280..425bd6020 100644 --- a/nvx-rs/src/guest/sys/fd.rs +++ b/nvx-rs/src/guest/sys/fd.rs @@ -1,14 +1,23 @@ //! Owned file descriptors and safe file-oriented system operations. -use std::ffi::CStr; -use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd}; +use std::ffi::{CStr, CString}; +use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd}; pub(crate) use nix::fcntl::OFlag as OpenFlags; +use nix::fcntl::{FcntlArg, SealFlag, fcntl}; use nix::poll::{PollFd, PollFlags}; +use nix::sys::memfd::{MFdFlags, memfd_create}; pub(crate) use nix::sys::stat::Mode; +use nix::unistd::Whence; use super::Errno; +/// The seals that make an execution configuration immutable. +const IMMUTABLE_SEALS: SealFlag = SealFlag::F_SEAL_WRITE + .union(SealFlag::F_SEAL_GROW) + .union(SealFlag::F_SEAL_SHRINK) + .union(SealFlag::F_SEAL_SEAL); + /// An owned file descriptor. /// /// The inner [`OwnedFd`] closes the descriptor on every early-return path. [`close`](Self::close) @@ -18,6 +27,21 @@ use super::Errno; pub(crate) struct FileDescriptor(OwnedFd); impl FileDescriptor { + /// Takes ownership of a descriptor number that the caller validated. + /// + /// # Safety + /// + /// `descriptor` must be open and have no other owner, because the result closes it. + pub(super) unsafe fn from_raw(descriptor: RawFd) -> Self { + // SAFETY: the caller guarantees that the descriptor is open and has no other owner. + Self(unsafe { OwnedFd::from_raw_fd(descriptor) }) + } + + /// Formats the descriptor number as a NUL-terminated decimal argument. + pub(crate) fn argument(&self) -> CString { + CString::new(self.0.as_raw_fd().to_string()).expect("a decimal descriptor has no NUL") + } + /// Reads bytes once, without retrying an interrupted call. pub(crate) fn read(&self, bytes: &mut [u8]) -> Result { nix::unistd::read(&self.0, bytes) @@ -28,6 +52,36 @@ impl FileDescriptor { nix::unistd::write(&self.0, bytes) } + /// Reads exactly `bytes.len()` bytes, retrying interruptions. + /// + /// End of file before the last byte is `EIO`, the status that the agent reports for it. + pub(crate) fn read_exact(&self, mut bytes: &mut [u8]) -> Result<(), Errno> { + while !bytes.is_empty() { + match self.read(bytes) { + Ok(0) => return Err(Errno::EIO), + Ok(count) => bytes = &mut bytes[count..], + Err(Errno::EINTR) => {} + Err(error) => return Err(error), + } + } + Ok(()) + } + + /// Writes every byte, retrying interruptions. + /// + /// A write that makes no progress is `EIO`, the status that the agent reports for it. + pub(crate) fn write_all(&self, mut bytes: &[u8]) -> Result<(), Errno> { + while !bytes.is_empty() { + match self.write(bytes) { + Ok(0) => return Err(Errno::EIO), + Ok(count) => bytes = &bytes[count..], + Err(Errno::EINTR) => {} + Err(error) => return Err(error), + } + } + Ok(()) + } + /// Reads at `offset` once, preserving the C cast from `u64` to `off_t`. pub(crate) fn pread(&self, bytes: &mut [u8], offset: u64) -> Result { nix::sys::uio::pread(&self.0, bytes, offset as _) @@ -60,11 +114,35 @@ impl FileDescriptor { .is_some_and(|events| events.contains(PollFlags::POLLIN))) } - /// Takes ownership of a descriptor that another wrapper in this module tree created. - pub(super) fn from_owned(descriptor: OwnedFd) -> Self { + /// Takes ownership of a descriptor that another wrapper in this module tree, or a test, + /// created. + pub(crate) fn from_owned(descriptor: OwnedFd) -> Self { Self(descriptor) } + /// Makes future reads and writes nonblocking while preserving the other status flags. + pub(crate) fn set_nonblocking(&self) -> Result<(), Errno> { + let flags = fcntl(&self.0, FcntlArg::F_GETFL).map(OpenFlags::from_bits_retain)?; + fcntl(&self.0, FcntlArg::F_SETFL(flags | OpenFlags::O_NONBLOCK)).map(|_| ()) + } + + /// Rewinds this descriptor to byte zero. + pub(crate) fn rewind(&self) -> Result<(), Errno> { + nix::unistd::lseek(&self.0, 0, Whence::SeekSet).map(|_| ()) + } + + /// Adds the complete immutable-file seal set used by execution configurations. + pub(crate) fn seal_immutable(&self) -> Result<(), Errno> { + fcntl(&self.0, FcntlArg::F_ADD_SEALS(IMMUTABLE_SEALS)).map(|_| ()) + } + + /// Returns whether this descriptor carries every seal that `seal_immutable` adds. + #[cfg(test)] + pub(crate) fn is_sealed_immutable(&self) -> Result { + let seals = fcntl(&self.0, FcntlArg::F_GET_SEALS).map(SealFlag::from_bits_retain)?; + Ok(seals.contains(IMMUTABLE_SEALS)) + } + /// Closes this descriptor and returns an error if the kernel reports one. pub(crate) fn close(self) -> Result<(), Errno> { nix::unistd::close(self.0) @@ -75,7 +153,8 @@ impl FileDescriptor { self.0.as_fd() } - /// Returns the raw descriptor only to the ioctl wrappers in this module tree. + /// Returns the raw descriptor only to the ioctl wrappers and the forked child in this module + /// tree. pub(super) fn raw(&self) -> RawFd { self.0.as_raw_fd() } @@ -86,14 +165,35 @@ pub(crate) fn open(path: &CStr, flags: OpenFlags, mode: Mode) -> Result Result { + memfd_create(name, MFdFlags::MFD_CLOEXEC | MFdFlags::MFD_ALLOW_SEALING).map(FileDescriptor) +} + +/// Creates a close-on-exec pipe and returns its owned read and write ends. +pub(crate) fn create_pipe() -> Result<(FileDescriptor, FileDescriptor), Errno> { + nix::unistd::pipe2(OpenFlags::O_CLOEXEC) + .map(|(reader, writer)| (FileDescriptor(reader), FileDescriptor(writer))) +} + +/// Creates `path` as a directory with `mode`. +pub(crate) fn create_directory(path: &CStr, mode: Mode) -> Result<(), Errno> { + nix::unistd::mkdir(path, mode) +} + /// Creates `path` as a FIFO with `mode`. /// -/// Tests use it to stand for a console, which keeps their system calls in this module. -#[cfg(test)] +/// The managed agent creates its sandbox launch barrier with it, and tests use it to stand for a +/// console, which keeps their system calls in this module. pub(crate) fn create_fifo(path: &CStr, mode: Mode) -> Result<(), Errno> { nix::unistd::mkfifo(path, mode) } +/// Removes a filesystem entry without following it. +pub(crate) fn unlink(path: &CStr) -> Result<(), Errno> { + nix::unistd::unlink(path) +} + /// Returns the host page size when it is a positive power of two. pub(crate) fn page_size() -> Option { nix::unistd::sysconf(nix::unistd::SysconfVar::PAGE_SIZE) diff --git a/nvx-rs/src/guest/sys/filesystem.rs b/nvx-rs/src/guest/sys/filesystem.rs new file mode 100644 index 000000000..ce4769c33 --- /dev/null +++ b/nvx-rs/src/guest/sys/filesystem.rs @@ -0,0 +1,69 @@ +//! Safe path, mount, ownership, mode, and working-directory operations. + +use std::ffi::{CStr, CString}; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +pub(crate) use nix::mount::MsFlags as MountFlags; +use nix::sys::stat::{FchmodatFlags, Mode}; +use nix::unistd::{Gid, Uid}; + +use super::Errno; + +/// Converts a Unix path to a NUL-terminated string, or `EINVAL` if it contains a NUL. +pub(crate) fn path_c_string(path: &Path) -> Result { + super::c_string(path.as_os_str().as_bytes()) +} + +/// Mounts `source` on `target` with no data argument. +pub(crate) fn mount( + source: Option<&CStr>, + target: &CStr, + filesystem: Option<&CStr>, + flags: MountFlags, +) -> Result<(), Errno> { + nix::mount::mount(source, target, filesystem, flags, None::<&CStr>) +} + +/// Changes the owner and group of `path`, following the final symlink like C `chown`. +pub(crate) fn chown(path: &CStr, uid: u32, gid: u32) -> Result<(), Errno> { + nix::unistd::chown(path, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid))) +} + +/// Changes the permission bits of `path`, following the final symlink like C `chmod`. +/// +/// `fchmodat` relative to the working directory, without `AT_SYMLINK_NOFOLLOW`, is the system +/// call that `chmod` makes. +pub(crate) fn chmod(path: &CStr, mode: Mode) -> Result<(), Errno> { + nix::sys::stat::fchmodat( + nix::fcntl::AT_FDCWD, + path, + mode, + FchmodatFlags::FollowSymlink, + ) +} + +/// Changes the process working directory to `path`. +pub(crate) fn change_directory(path: &CStr) -> Result<(), Errno> { + nix::unistd::chdir(path) +} + +/// Returns the physical working directory, or `ERANGE` if it needs `maximum` bytes or more. +/// +/// `libc`'s `getcwd` writes into a fixed buffer, which preserves the C agent's `ERANGE`, whereas +/// `nix` and the standard library grow their storage until the path fits. +pub(crate) fn current_directory(maximum: usize) -> Result, Errno> { + let mut bytes = vec![0u8; maximum]; + // SAFETY: the vector supplies `maximum` writable bytes, and libc NUL-terminates the path on + // success. + let result = unsafe { nix::libc::getcwd(bytes.as_mut_ptr().cast(), bytes.len()) }; + if result.is_null() { + return Err(Errno::last()); + } + let length = bytes + .iter() + .position(|&byte| byte == 0) + .ok_or(Errno::ERANGE)?; + bytes.truncate(length); + Ok(bytes) +} diff --git a/nvx-rs/src/guest/sys/mod.rs b/nvx-rs/src/guest/sys/mod.rs index fdbe31ea7..e9258aac7 100644 --- a/nvx-rs/src/guest/sys/mod.rs +++ b/nvx-rs/src/guest/sys/mod.rs @@ -1,34 +1,59 @@ -//! Safe operating-system interface shared by the guest tools. +//! Safe operating-system interface shared by the guest programs. //! //! Guest program modules deny unsafe code. This module is their only boundary to the operating //! system, through `nix`: it owns descriptors and mappings, and its submodules confine the few -//! unsafe `nix` operations, such as ioctls, memory mappings, and signal dispositions, behind -//! wrappers that establish their pointer, lifetime, and ownership invariants. Errors are `nix`'s -//! [`Errno`], whose descriptions the tools print. +//! unsafe `nix` operations, such as ioctls, memory mappings, signal dispositions, and `fork`, +//! behind wrappers that establish their pointer, lifetime, and ownership invariants. Errors are +//! `nix`'s [`Errno`], whose descriptions the programs print. +//! +//! The managed agent needs a few operations that `nix` cannot provide, which reach libc through +//! `nix`'s re-export: its forked child, which may neither allocate nor drop an owner, closes, +//! executes, and exits through libc; its wait-status decoding keeps the real-time signals that +//! `nix`'s `Signal` cannot represent; and its execution helper duplicates the descriptor that it +//! inherits by number, which it cannot borrow before it knows that the descriptor is open. Each +//! such call says why. #[cfg(target_arch = "x86_64")] mod clock; mod fd; +mod filesystem; mod ioctl; mod mapping; +mod poll; mod process; mod socket; +mod spawn; +mod terminal; use std::ffi::CString; use std::io::{self, Write}; use std::os::unix::ffi::OsStringExt; pub(crate) use nix::errno::Errno; +pub(crate) use nix::sys::signal::Signal; #[cfg(target_arch = "x86_64")] pub(crate) use clock::monotonic_ns; -#[cfg(test)] -pub(crate) use fd::create_fifo; -pub(crate) use fd::{FileDescriptor, Mode, OpenFlags, open, page_size}; +pub(crate) use fd::{ + FileDescriptor, Mode, OpenFlags, create_directory, create_fifo, create_memfd, create_pipe, + open, page_size, unlink, +}; +pub(crate) use filesystem::{ + MountFlags, change_directory, chmod, chown, current_directory, mount, path_c_string, +}; pub(crate) use ioctl::{console_pending, reseed_random}; pub(crate) use mapping::MemoryMapping; -pub(crate) use process::{DirectStdout, Stdout, restore_sigpipe}; +pub(crate) use poll::readable_or_hung_up; +#[cfg(test)] +pub(crate) use process::sigpipe_disposition; +pub(crate) use process::{ + ChildStatus, DirectStdout, ProcessId, Stdout, exec, has_exited, kill_process_group, + reap_any_nonblocking, restore_sigpipe, take_inherited_descriptor, wait, with_sigpipe_ignored, + write_port_diagnostic, write_standard_error, +}; pub(crate) use socket::connect_datagram; +pub(crate) use spawn::{CHILD_STAGE_CWD, PhysicalPwd, SpawnDescriptors, SpawnPlan, spawn}; +pub(crate) use terminal::{configure_raw, drain}; /// Returns the process arguments, program name first, as the C strings that `main` received. /// @@ -41,6 +66,11 @@ pub(crate) fn args() -> Vec { .collect() } +/// Converts arbitrary bytes into a C string, or `EINVAL` if they contain a NUL. +pub(crate) fn c_string(bytes: &[u8]) -> Result { + CString::new(bytes).map_err(|_| Errno::EINVAL) +} + /// Writes the concatenation of `parts` to `stream` in one write. /// /// The C tools wrote diagnostics through unbuffered `stderr` and ignored a diagnostic write @@ -61,7 +91,7 @@ pub(crate) fn print(stream: &mut dyn Write, bytes: &[u8]) -> Result<(), Errno> { /// /// A write that made no progress has no OS error, so it maps to `EIO`, matching the guest tools' /// generic I/O-failure treatment. -fn errno_of(error: &io::Error) -> Errno { +pub(crate) fn errno_of(error: &io::Error) -> Errno { error.raw_os_error().map_or(Errno::EIO, Errno::from_raw) } diff --git a/nvx-rs/src/guest/sys/poll.rs b/nvx-rs/src/guest/sys/poll.rs new file mode 100644 index 000000000..a31f83042 --- /dev/null +++ b/nvx-rs/src/guest/sys/poll.rs @@ -0,0 +1,34 @@ +//! Safe readiness polling for owned descriptors. + +use nix::poll::{PollFd, PollFlags, poll}; + +use super::{Errno, FileDescriptor}; + +/// Polls up to three descriptors for readable data or hangup. +/// +/// `None` entries are omitted, and the returned booleans preserve the caller's three positions. +/// Any reported event wakes that position, matching the C agent's `revents != 0` test. +pub(crate) fn readable_or_hung_up( + descriptors: [Option<&FileDescriptor>; 3], + timeout_milliseconds: u16, +) -> Result<[bool; 3], Errno> { + let mut positions = Vec::with_capacity(descriptors.len()); + let mut polls = Vec::with_capacity(descriptors.len()); + for (position, descriptor) in descriptors.into_iter().enumerate() { + if let Some(descriptor) = descriptor { + positions.push(position); + polls.push(PollFd::new( + descriptor.borrowed(), + PollFlags::POLLIN | PollFlags::POLLHUP, + )); + } + } + poll(&mut polls, timeout_milliseconds)?; + let mut ready = [false; 3]; + for (position, descriptor) in positions.into_iter().zip(polls) { + ready[position] = descriptor + .revents() + .is_some_and(|events| !events.is_empty()); + } + Ok(ready) +} diff --git a/nvx-rs/src/guest/sys/process.rs b/nvx-rs/src/guest/sys/process.rs index 09fd002af..364e93195 100644 --- a/nvx-rs/src/guest/sys/process.rs +++ b/nvx-rs/src/guest/sys/process.rs @@ -1,9 +1,15 @@ -//! Process-runtime behavior inherited from the C guest tools. +//! Safe process, signal, inherited-descriptor, and standard-stream operations. +use std::ffi::{CStr, CString}; use std::io::{self, Write}; +use std::os::fd::RawFd; use std::sync::atomic::{AtomicBool, Ordering}; -use nix::sys::signal::{SigHandler, Signal}; +use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, kill}; +use nix::unistd::Pid; + +use super::{Errno, FileDescriptor}; +use crate::guest::parse::parse_decimal; /// Whether this process inherited an ignored SIGPIPE across `exec`. static SIGPIPE_IGNORED: AtomicBool = AtomicBool::new(false); @@ -42,6 +48,265 @@ pub(crate) fn restore_sigpipe() { let _ = unsafe { nix::sys::signal::signal(Signal::SIGPIPE, handler) }; } +/// Returns an owned, close-on-exec duplicate of the descriptor whose decimal number `text` +/// names, which this process inherited across `exec`, and makes that descriptor close-on-exec +/// too, so that the next `exec` closes it, as the C helper closed it before its own. +/// +/// Duplicating the inherited descriptor, rather than adopting it, needs no proof that nothing +/// else in this process owns it: the duplicate is new, and only its flags change on the +/// original. A malformed number is `EINVAL`, and a closed descriptor `EBADF`. +pub(crate) fn take_inherited_descriptor(text: &CStr) -> Result { + let inherited = parse_decimal(text.to_bytes()) + .and_then(|value| RawFd::try_from(value).ok()) + .ok_or(Errno::EINVAL)?; + // `nix`'s `fcntl` takes a borrowed descriptor, which must stay open while it is borrowed, so + // these calls on an untrusted number go through libc. + // SAFETY: `F_DUPFD_CLOEXEC` reads only the descriptor number, and fails with `EBADF` if it is + // closed. + let duplicate = + Errno::result(unsafe { nix::libc::fcntl(inherited, nix::libc::F_DUPFD_CLOEXEC, 0) })?; + // SAFETY: the kernel has just created `duplicate`, so it is open and nothing else owns it. + let descriptor = unsafe { FileDescriptor::from_raw(duplicate) }; + // SAFETY: `F_SETFD` changes only the flags of the descriptor number. + Errno::result(unsafe { + nix::libc::fcntl(inherited, nix::libc::F_SETFD, nix::libc::FD_CLOEXEC) + })?; + Ok(descriptor) +} + +/// One child process identifier returned by the safe fork/exec boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ProcessId(Pid); + +impl ProcessId { + /// Wraps a positive process identifier returned by `fork` or `waitpid`. + pub(super) fn from_pid(process: Pid) -> Self { + Self(process) + } + + /// Borrows the identifier of a child created by the standard test harness. + #[cfg(test)] + pub(crate) fn from_child(child: &std::process::Child) -> Self { + Self(Pid::from_raw(child.id() as i32)) + } + + /// Returns the decimal process identifier followed by a newline. + pub(crate) fn line(self) -> Vec { + format!("{}\n", self.0.as_raw()).into_bytes() + } +} + +/// A terminal state observed while reaping a child. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ChildStatus { + /// The child called `_exit` or returned from its program. + Exited(i32), + /// A signal terminated the child. + Signaled(i32), + /// A wait state that is neither a normal exit nor signal termination. + Other, +} + +/// A child collected by a nonblocking reap. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ReapedChild { + /// Identifier of the collected child. + pub(crate) process: ProcessId, + /// Terminal state reported by the kernel. + pub(crate) status: ChildStatus, +} + +/// Decodes Linux's raw wait status without narrowing real-time signal numbers. +fn child_status(process: nix::libc::pid_t, status: nix::libc::c_int) -> ReapedChild { + let status = if nix::libc::WIFEXITED(status) { + ChildStatus::Exited(nix::libc::WEXITSTATUS(status)) + } else if nix::libc::WIFSIGNALED(status) { + ChildStatus::Signaled(nix::libc::WTERMSIG(status)) + } else { + ChildStatus::Other + }; + ReapedChild { + process: ProcessId::from_pid(Pid::from_raw(process)), + status, + } +} + +/// Reaps one available child without blocking. +/// +/// libc is used instead of `nix` because `nix` converts a terminating signal to its finite +/// `Signal` enum and rejects Linux real-time signal numbers, after the child is reaped. +/// Interruptions are retried. `ECHILD`, a running child, and other errors all mean that no +/// reapable child is available, matching the C agent's best-effort orphan-reaping loop. +pub(crate) fn reap_any_nonblocking() -> Option { + loop { + let mut status = 0; + // SAFETY: `status` is writable, `WNOHANG` never blocks, and a positive result names a + // child that this call atomically reaped. + let process = unsafe { nix::libc::waitpid(-1, &mut status, nix::libc::WNOHANG) }; + if process > 0 { + return Some(child_status(process, status)); + } + if process == 0 { + return None; + } + if Errno::last() != Errno::EINTR { + return None; + } + } +} + +/// Waits for `process`, retrying interruptions. +/// +/// libc is used for the same real-time signals as [`reap_any_nonblocking`]. +pub(crate) fn wait(process: ProcessId) -> Option { + loop { + let mut status = 0; + // SAFETY: `status` is writable and `process` came from this module's successful fork + // boundary or a test-owned `Child`. + let observed = unsafe { nix::libc::waitpid(process.0.as_raw(), &mut status, 0) }; + if observed == process.0.as_raw() { + return Some(child_status(observed, status).status); + } + if observed < 0 && Errno::last() == Errno::EINTR { + continue; + } + return None; + } +} + +/// Sends `SIGKILL` to the process group led by `process`. +pub(crate) fn kill_process_group(process: ProcessId) { + let _ = kill(Pid::from_raw(-process.0.as_raw()), Signal::SIGKILL); +} + +/// Returns whether `process` has reached a waitable terminal state without reaping it. +/// +/// libc is used because `nix`'s `waitid` decodes the state into a `WaitStatus` too, which fails +/// for a real-time signal, whereas only the child that `si_pid` names matters here. +pub(crate) fn has_exited(process: ProcessId) -> Result { + // SAFETY: zero is a valid initial representation for `siginfo_t`. + let mut information = unsafe { std::mem::zeroed::() }; + // SAFETY: `information` is writable; `WNOWAIT` leaves the child available to the owner. + Errno::result(unsafe { + nix::libc::waitid( + nix::libc::P_PID, + process.0.as_raw() as nix::libc::id_t, + &mut information, + nix::libc::WEXITED | nix::libc::WNOHANG | nix::libc::WNOWAIT, + ) + })?; + // SAFETY: successful `waitid` initialized the Linux `siginfo_t` child-event union. + Ok(unsafe { information.si_pid() } == process.0.as_raw()) +} + +/// Replaces the process image with `path` and `arguments`, and with `environment`, or without +/// one, the current environment. It returns only the error of a failed `exec`. +pub(crate) fn exec(path: &CStr, arguments: &[CString], environment: Option<&[CString]>) -> Errno { + let result = match environment { + Some(environment) => nix::unistd::execve(path, arguments, environment), + None => nix::unistd::execv(path, arguments), + }; + match result { + Ok(never) => match never {}, + Err(error) => error, + } +} + +/// Writes all bytes to standard error and ignores failures. +pub(crate) fn write_standard_error(bytes: &[u8]) { + let mut remaining = bytes; + while !remaining.is_empty() { + match nix::unistd::write(io::stderr(), remaining) { + Ok(0) => return, + Ok(count) => remaining = &remaining[count..], + Err(Errno::EINTR) => {} + Err(_) => return, + } + } +} + +/// RAII owner of a temporarily replaced SIGPIPE disposition. +struct SigpipeGuard { + /// Exact action that must be restored. + previous: SigAction, + /// Whether Drop still owns the restoration. + active: bool, +} + +impl SigpipeGuard { + /// Installs `SIG_IGN` and records the prior action. + fn ignore() -> Result { + let ignored = SigAction::new(SigHandler::SigIgn, SaFlags::empty(), SigSet::empty()); + // SAFETY: `SIG_IGN` installs no Rust callback. + let previous = unsafe { nix::sys::signal::sigaction(Signal::SIGPIPE, &ignored) }?; + Ok(Self { + previous, + active: true, + }) + } + + /// Explicitly restores the prior action and reports a failure. + fn restore(mut self) -> Result<(), Errno> { + self.active = false; + // SAFETY: `previous` is the exact action that the installation replaced. + unsafe { nix::sys::signal::sigaction(Signal::SIGPIPE, &self.previous) }.map(|_| ()) + } +} + +impl Drop for SigpipeGuard { + /// Best-effort restoration for unwinding test paths. + fn drop(&mut self) { + if self.active { + // SAFETY: this guard owns the exact action that the installation replaced. + let _ = unsafe { nix::sys::signal::sigaction(Signal::SIGPIPE, &self.previous) }; + } + } +} + +/// Runs `operation` while `SIGPIPE` is ignored, then restores the exact prior disposition. +/// +/// The managed agent is single-threaded at this call site, so the process-wide disposition +/// cannot race another thread. Explicit restoration reports errors; RAII also restores it if a +/// host test unwinds through the operation. +pub(crate) fn with_sigpipe_ignored( + operation: impl FnOnce() -> Result<(), Errno>, +) -> Result<(), Errno> { + let guard = SigpipeGuard::ignore()?; + let result = operation(); + guard.restore()?; + result +} + +/// Returns the current SIGPIPE handler token, flags, and mask for regression tests. +/// +/// A whole `SigAction` does not compare reliably: libc copies only the kernel's part of the mask. +#[cfg(test)] +pub(crate) fn sigpipe_disposition() -> Result<(usize, SaFlags, SigSet), Errno> { + let probe = SigAction::new(SigHandler::SigIgn, SaFlags::empty(), SigSet::empty()); + // SAFETY: the probe installs no Rust callback, and the second call reinstalls the action + // that the first one replaced. + let current = unsafe { nix::sys::signal::sigaction(Signal::SIGPIPE, &probe) }?; + // SAFETY: `current` is the exact action that the probe replaced. + unsafe { nix::sys::signal::sigaction(Signal::SIGPIPE, ¤t) }?; + let handler = nix::libc::sigaction::from(current).sa_sigaction; + Ok((handler, current.flags(), current.mask())) +} + +/// Writes `message` to a diagnostic I/O port through a descriptor. +/// +/// A write that stores no byte is `EIO`, the status that the agent reports for it. +pub(crate) fn write_port_diagnostic( + descriptor: &FileDescriptor, + message: &[u8], +) -> Result<(), Errno> { + for byte in message { + if descriptor.pwrite(std::slice::from_ref(byte), 0xe9)? != 1 { + return Err(Errno::EIO); + } + } + Ok(()) +} + /// Standard output with the error behavior of C stdio. /// /// C line-buffers a terminal, so a failed terminal write fails `printf`. It fully buffers other @@ -95,3 +360,51 @@ impl Write for DirectStdout { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::io::{Seek, Write}; + use std::os::fd::{AsFd, AsRawFd}; + + use nix::fcntl::{FcntlArg, FdFlag, fcntl}; + + use super::*; + + /// Returns whether `descriptor` is close-on-exec. + fn closes_on_exec(descriptor: impl AsFd) -> bool { + let flags = fcntl(descriptor, FcntlArg::F_GETFD).unwrap(); + FdFlag::from_bits_retain(flags).contains(FdFlag::FD_CLOEXEC) + } + + #[test] + fn an_inherited_descriptor_is_duplicated_and_closes_on_exec() { + let mut file = tempfile::tempfile().unwrap(); + file.write_all(b"config").unwrap(); + file.rewind().unwrap(); + fcntl(file.as_fd(), FcntlArg::F_SETFD(FdFlag::empty())).unwrap(); + let argument = CString::new(file.as_raw_fd().to_string()).unwrap(); + let duplicate = take_inherited_descriptor(&argument).unwrap(); + assert_ne!(duplicate.argument(), argument); + assert!(closes_on_exec(duplicate.borrowed())); + assert!(closes_on_exec(&file)); + let mut bytes = [0u8; 8]; + assert_eq!(duplicate.read(&mut bytes), Ok(6)); + assert_eq!(&bytes[..6], b"config"); + assert_eq!(duplicate.close(), Ok(())); + } + + #[test] + fn an_inherited_descriptor_number_is_decimal_and_open() { + for text in [c"", c"+7", c" 42", c"-1", c"1x", c"2147483648"] { + assert_eq!( + take_inherited_descriptor(text).unwrap_err(), + Errno::EINVAL, + "{text:?}" + ); + } + assert_eq!( + take_inherited_descriptor(c"2147483647").unwrap_err(), + Errno::EBADF + ); + } +} diff --git a/nvx-rs/src/guest/sys/spawn.rs b/nvx-rs/src/guest/sys/spawn.rs new file mode 100644 index 000000000..24b0c51d7 --- /dev/null +++ b/nvx-rs/src/guest/sys/spawn.rs @@ -0,0 +1,623 @@ +//! Prepared fork/exec boundary for the managed guest agent. +//! +//! The safe API owns or borrows every string and descriptor before `fork`. The child branch +//! performs no allocation, locking, formatting, or Rust cleanup: it makes direct system calls +//! over prevalidated storage and ends in `execve`, `execv`, or `_exit`. It makes them through +//! `nix` where its wrappers neither allocate nor consume an owner, and through libc to close a +//! borrowed descriptor, fill the preallocated `PWD` entry, execute, and exit. + +use std::ffi::CString; + +use nix::fcntl::{FcntlArg, FdFlag, OFlag}; +use nix::sys::stat::Mode; +use nix::unistd::{ForkResult, Gid, Pid, Uid}; + +use super::{Errno, FileDescriptor, ProcessId}; + +/// Prefix of the mutable physical-working-directory environment entry. +const PWD_PREFIX: &[u8] = b"PWD="; + +/// Child report stage for a working-directory failure. +pub(crate) const CHILD_STAGE_CWD: i32 = 1; +/// Child report stage for identity, descriptor, or cgroup setup failure. +pub(crate) const CHILD_STAGE_SETUP: i32 = 2; + +/// Privileged setup performed between `fork` and `exec`. +enum ChildSetup { + /// Direct-agent identity transition, directory entry, and cgroup enrollment. + Direct { + /// Workload effective user. + uid: Uid, + /// Workload effective group. + gid: Gid, + /// Writable direct-workload cgroup membership file. + cgroup_procs: FileDescriptor, + }, + /// Sandbox launcher, which performs its own setup. + Sandbox, + /// Unprivileged test workload that only enters a directory. + #[cfg(all(test, feature = "host"))] + Plain, +} + +/// Mutable physical-working-directory entry prepared before `fork`. +/// +/// The type is opaque outside the system layer so safe callers cannot choose its storage, +/// environment position, prefix, or terminator independently. +pub(crate) struct PhysicalPwd { + /// Writable `PWD=` entry, including capacity for the path and its trailing NUL. + entry: Box<[u8]>, + /// Position of the entry in the child environment. + index: usize, +} + +impl PhysicalPwd { + /// Allocates a physical `PWD` entry and removes the entry it will replace. + pub(crate) fn new(environment: &mut Vec, length: usize) -> Result { + if length <= PWD_PREFIX.len() { + return Err(Errno::EINVAL); + } + let index = environment + .iter() + .position(|entry| entry.as_bytes().starts_with(PWD_PREFIX)) + .map_or(environment.len(), |index| { + environment.remove(index); + index + }); + let mut entry = vec![0u8; length].into_boxed_slice(); + entry[..PWD_PREFIX.len()].copy_from_slice(PWD_PREFIX); + Ok(Self { entry, index }) + } +} + +/// All allocated inputs to one fork/exec operation. +pub(crate) struct SpawnPlan { + /// Executable path. + program: CString, + /// Argument strings, including argument zero. + arguments: Vec, + /// Explicit environment, or `None` to inherit it with `execv`. + environment: Option>, + /// Preallocated `PWD=` entry filled after the child enters a noncanonical path. + physical_pwd: Option, + /// Working directory used by direct and plain children. + directory: CString, + /// Child setup variant. + setup: ChildSetup, + /// Read-only null device opened for standard input. + null_device: CString, +} + +impl SpawnPlan { + /// Creates a plan for the direct or sandbox managed launcher. + #[allow(clippy::too_many_arguments)] + pub(crate) fn managed( + program: CString, + arguments: Vec, + environment: Vec, + physical_pwd: Option, + directory: CString, + uid: u32, + gid: u32, + cgroup_procs: Option, + null_device: CString, + direct: bool, + ) -> Result { + let setup = if direct { + ChildSetup::Direct { + uid: Uid::from_raw(uid), + gid: Gid::from_raw(gid), + cgroup_procs: cgroup_procs.ok_or(Errno::EBADF)?, + } + } else { + ChildSetup::Sandbox + }; + let plan = Self { + program, + arguments, + environment: Some(environment), + physical_pwd, + directory, + setup, + null_device, + }; + plan.validate()?; + Ok(plan) + } + + /// Creates a plan for an unprivileged process with inherited environment. + #[cfg(all(test, feature = "host"))] + pub(crate) fn plain( + program: CString, + arguments: Vec, + directory: CString, + null_device: CString, + ) -> Self { + Self { + program, + arguments, + environment: None, + physical_pwd: None, + directory, + setup: ChildSetup::Plain, + null_device, + } + } + + /// Returns the prepared arguments for focused launch-plan tests. + #[cfg(test)] + pub(crate) fn arguments(&self) -> &[CString] { + &self.arguments + } + + /// Returns the prepared executable path for focused launch-plan tests. + #[cfg(test)] + pub(crate) fn program(&self) -> &CString { + &self.program + } + + /// Returns the prepared explicit environment for focused launch-plan tests. + #[cfg(test)] + pub(crate) fn environment(&self) -> &[CString] { + self.environment.as_deref().unwrap_or_default() + } + + /// Returns the preallocated physical-PWD entry for focused launch-plan tests. + #[cfg(test)] + pub(crate) fn physical_pwd(&self) -> Option<&[u8]> { + self.physical_pwd + .as_ref() + .map(|physical_pwd| physical_pwd.entry.as_ref()) + } + + /// Returns the insertion position of the physical-PWD entry. + #[cfg(test)] + pub(crate) fn physical_pwd_index(&self) -> Option { + self.physical_pwd + .as_ref() + .map(|physical_pwd| physical_pwd.index) + } + + /// Returns whether this plan owns a direct cgroup membership descriptor. + #[cfg(test)] + pub(crate) fn has_cgroup(&self) -> bool { + matches!(&self.setup, ChildSetup::Direct { .. }) + } + + /// Verifies all invariants consumed by the post-fork child. + fn validate(&self) -> Result<(), Errno> { + let Some(physical_pwd) = &self.physical_pwd else { + return Ok(()); + }; + let Some(environment) = &self.environment else { + return Err(Errno::EINVAL); + }; + if !matches!(&self.setup, ChildSetup::Direct { .. }) + || physical_pwd.entry.len() <= PWD_PREFIX.len() + || !physical_pwd.entry.starts_with(PWD_PREFIX) + || physical_pwd.entry.last() != Some(&0) + || physical_pwd.index > environment.len() + { + return Err(Errno::EINVAL); + } + Ok(()) + } +} + +/// Descriptor actions prepared for one child. +pub(crate) struct SpawnDescriptors<'a> { + /// Sealed execution configuration. + config: &'a FileDescriptor, + /// Parent's stdout read end, closed in the child. + stdout_reader: &'a FileDescriptor, + /// Child's stdout write end. + stdout_writer: &'a FileDescriptor, + /// Parent's stderr read end, closed in the child. + stderr_reader: &'a FileDescriptor, + /// Child's stderr write end. + stderr_writer: &'a FileDescriptor, + /// Optional parent's launch-report read end. + launch_reader: Option<&'a FileDescriptor>, + /// Optional child's launch-report write end. + launch_writer: Option<&'a FileDescriptor>, + /// Whether the configuration descriptor must survive `exec`. + inherit_config: bool, +} + +impl<'a> SpawnDescriptors<'a> { + /// Creates a managed-child descriptor action set. + pub(crate) fn managed( + config: &'a FileDescriptor, + stdout: (&'a FileDescriptor, &'a FileDescriptor), + stderr: (&'a FileDescriptor, &'a FileDescriptor), + launch: Option<(&'a FileDescriptor, &'a FileDescriptor)>, + ) -> Self { + Self { + config, + stdout_reader: stdout.0, + stdout_writer: stdout.1, + stderr_reader: stderr.0, + stderr_writer: stderr.1, + launch_reader: launch.map(|pair| pair.0), + launch_writer: launch.map(|pair| pair.1), + inherit_config: true, + } + } + + /// Creates an unprivileged-child descriptor action set. + #[cfg(all(test, feature = "host"))] + pub(crate) fn plain( + config: &'a FileDescriptor, + stdout: (&'a FileDescriptor, &'a FileDescriptor), + stderr: (&'a FileDescriptor, &'a FileDescriptor), + launch: Option<(&'a FileDescriptor, &'a FileDescriptor)>, + ) -> Self { + Self { + config, + stdout_reader: stdout.0, + stdout_writer: stdout.1, + stderr_reader: stderr.0, + stderr_writer: stderr.1, + launch_reader: launch.map(|pair| pair.0), + launch_writer: launch.map(|pair| pair.1), + inherit_config: false, + } + } +} + +/// Forks and executes one fully prepared plan. +/// +/// This function is safe because every pointer and descriptor action is derived from live +/// owners borrowed for the call, and the child path never returns into Rust. `fork` may occur in +/// a multithreaded test process: the child makes only direct async-signal-safe system calls over +/// storage allocated before the fork. Production invokes it from the single-threaded managed +/// agent. +pub(crate) fn spawn( + plan: &mut SpawnPlan, + descriptors: SpawnDescriptors<'_>, +) -> Result { + plan.validate()?; + let mut argument_pointers: Vec<_> = plan + .arguments + .iter() + .map(|argument| argument.as_ptr()) + .collect(); + argument_pointers.push(std::ptr::null()); + let physical = plan.physical_pwd.as_mut().map(|physical_pwd| { + physical_pwd + .entry + .as_mut_ptr() + .cast::() + .cast_const() + }); + let environment_pointers = plan.environment.as_ref().map(|environment| { + let mut pointers = + Vec::with_capacity(environment.len() + usize::from(plan.physical_pwd.is_some()) + 1); + for index in 0..=environment.len() { + if plan + .physical_pwd + .as_ref() + .is_some_and(|physical_pwd| physical_pwd.index == index) + && let Some(pointer) = physical + { + pointers.push(pointer); + } + if let Some(entry) = environment.get(index) { + pointers.push(entry.as_ptr()); + } + } + pointers.push(std::ptr::null()); + pointers + }); + + // SAFETY: all child inputs and pointer vectors were allocated above. The child invokes only + // the non-returning routine below; the parent returns a typed process identifier. + match unsafe { nix::unistd::fork() }? { + ForkResult::Child => { + // SAFETY: this is the post-fork child and `child_exec` never returns. + unsafe { + child_exec( + plan, + &argument_pointers, + environment_pointers.as_deref(), + &descriptors, + ) + } + } + ForkResult::Parent { child } => { + // A racing exec or exit makes a failed setpgid harmless, exactly as in the C agent. + let _ = nix::unistd::setpgid(child, child); + Ok(ProcessId::from_pid(child)) + } + } +} + +/// Executes the child half of [`spawn`]. +/// +/// # Safety +/// +/// This must run only in the just-forked child. Every pointer and descriptor must refer to +/// storage retained by the copied parent stack until the function ends in `exec` or `_exit`. +unsafe fn child_exec( + plan: &mut SpawnPlan, + arguments: &[*const nix::libc::c_char], + environment: Option<&[*const nix::libc::c_char]>, + descriptors: &SpawnDescriptors<'_>, +) -> ! { + let _ = nix::unistd::setpgid(Pid::from_raw(0), Pid::from_raw(0)); + if descriptors.inherit_config + && nix::fcntl::fcntl( + descriptors.config.borrowed(), + FcntlArg::F_SETFD(FdFlag::empty()), + ) + .is_err() + { + // SAFETY: the caller establishes that this is the post-fork child. + unsafe { exit_child() } + } + // SAFETY: the caller establishes that this is the post-fork child, whose copies of these + // descriptors no Rust owner closes. + unsafe { + close_copy(descriptors.stdout_reader); + close_copy(descriptors.stderr_reader); + if !descriptors.inherit_config { + close_copy(descriptors.config); + } + } + if let Ok(null) = nix::fcntl::open(plan.null_device.as_c_str(), OFlag::O_RDONLY, Mode::empty()) + { + let _ = nix::unistd::dup2_stdin(&null); + let _ = nix::unistd::close(null); + } + let _ = nix::unistd::dup2_stdout(descriptors.stdout_writer.borrowed()); + let _ = nix::unistd::dup2_stderr(descriptors.stderr_writer.borrowed()); + // SAFETY: as above. + unsafe { + close_copy(descriptors.stdout_writer); + close_copy(descriptors.stderr_writer); + } + + match &mut plan.setup { + ChildSetup::Direct { + uid, + gid, + cgroup_procs, + } => { + let (Some(launch_reader), Some(launch_writer)) = + (descriptors.launch_reader, descriptors.launch_writer) + else { + // SAFETY: as above. + unsafe { exit_child() } + }; + // SAFETY: as above, and every failure report below runs in this child too. + unsafe { + close_copy(launch_reader); + if nix::unistd::setgroups(&[]).is_err() + || nix::unistd::setegid(*gid).is_err() + || nix::unistd::seteuid(*uid).is_err() + { + report_failure(launch_writer, CHILD_STAGE_SETUP); + } + if nix::unistd::chdir(plan.directory.as_c_str()).is_err() { + report_failure(launch_writer, CHILD_STAGE_CWD); + } + if nix::unistd::seteuid(Uid::from_raw(0)).is_err() + || nix::unistd::setegid(Gid::from_raw(0)).is_err() + { + report_failure(launch_writer, CHILD_STAGE_SETUP); + } + if let Some(physical_pwd) = plan.physical_pwd.as_mut() { + let output = &mut physical_pwd.entry[PWD_PREFIX.len()..]; + // `nix`'s `getcwd` allocates its result, so libc fills the entry in place. + if nix::libc::getcwd(output.as_mut_ptr().cast(), output.len()).is_null() { + report_failure(launch_writer, CHILD_STAGE_SETUP); + } + } + if !write_decimal(cgroup_procs, nix::unistd::getpid().as_raw()) { + report_failure(launch_writer, CHILD_STAGE_SETUP); + } + close_copy(cgroup_procs); + } + } + ChildSetup::Sandbox => {} + #[cfg(all(test, feature = "host"))] + ChildSetup::Plain => { + // SAFETY: as above. + unsafe { + if let Some(reader) = descriptors.launch_reader { + close_copy(reader); + } + if let Some(writer) = descriptors.launch_writer { + close_copy(writer); + } + if nix::unistd::chdir(plan.directory.as_c_str()).is_err() { + exit_child(); + } + } + } + } + + // `nix`'s `exec` wrappers allocate their pointer vectors, so libc takes the ones that the + // parent prepared. + // SAFETY: the vectors are NUL-terminated, and every pointer names storage retained by the + // copied parent stack. + unsafe { + if let Some(environment) = environment { + nix::libc::execve( + plan.program.as_ptr(), + arguments.as_ptr(), + environment.as_ptr(), + ); + } else { + nix::libc::execv(plan.program.as_ptr(), arguments.as_ptr()); + } + exit_child() + } +} + +/// Closes the child's copy of a descriptor that a parent-side owner borrowed into the child. +/// +/// `nix`'s `close` consumes an owner, which the child has only borrowed. +/// +/// # Safety +/// +/// This must run only in a post-fork child that never returns into Rust, so that no owner +/// closes the descriptor again. +unsafe fn close_copy(descriptor: &FileDescriptor) { + // SAFETY: the caller guarantees that no Rust owner in this process uses the copy again. + unsafe { nix::libc::close(descriptor.raw()) }; +} + +/// Leaves the post-fork child with status 125 without running Rust or C cleanup. +/// +/// `nix` has no `_exit`, and `std::process::exit` runs cleanup. +/// +/// # Safety +/// +/// This must run only in a post-fork child. +unsafe fn exit_child() -> ! { + // SAFETY: the caller guarantees that this is the post-fork child. + unsafe { nix::libc::_exit(125) } +} + +/// Reports a direct-child setup failure and exits immediately. +/// +/// # Safety +/// +/// `descriptor` must be the live launch-report writer in a post-fork child. +unsafe fn report_failure(descriptor: &FileDescriptor, stage: i32) -> ! { + let status = Errno::last_raw(); + let mut bytes = [0u8; 8]; + bytes[..4].copy_from_slice(&stage.to_ne_bytes()); + bytes[4..].copy_from_slice(&status.to_ne_bytes()); + let _ = write_all(descriptor, &bytes); + // SAFETY: the caller guarantees that this is the post-fork child. + unsafe { exit_child() } +} + +/// Writes a decimal process ID and newline without allocation. +fn write_decimal(descriptor: &FileDescriptor, value: nix::libc::pid_t) -> bool { + let mut bytes = [0u8; 32]; + let mut position = bytes.len() - 1; + bytes[position] = b'\n'; + let mut value = value as u32; + loop { + position -= 1; + bytes[position] = b'0' + (value % 10) as u8; + value /= 10; + if value == 0 { + break; + } + } + write_all(descriptor, &bytes[position..]) +} + +/// Writes all bytes with direct async-signal-safe calls, retrying interruptions. +fn write_all(descriptor: &FileDescriptor, mut bytes: &[u8]) -> bool { + while !bytes.is_empty() { + match nix::unistd::write(descriptor.borrowed(), bytes) { + Ok(count) if count > 0 => bytes = &bytes[count..], + Err(Errno::EINTR) => {} + Ok(_) | Err(_) => return false, + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Returns one ordinary owned descriptor for plan and action tests. + fn descriptor() -> FileDescriptor { + FileDescriptor::from_owned(tempfile::tempfile().unwrap().into()) + } + + /// Builds a minimal managed plan with `physical_pwd`. + fn managed_plan( + environment: Vec, + physical_pwd: Option, + direct: bool, + ) -> Result { + SpawnPlan::managed( + c"/bin/true".to_owned(), + vec![c"/bin/true".to_owned()], + environment, + physical_pwd, + c"/".to_owned(), + 1, + 1, + direct.then(descriptor), + c"/dev/null".to_owned(), + direct, + ) + } + + #[test] + fn physical_pwd_is_built_entirely_in_the_system_layer() { + let mut environment = vec![ + c"FIRST=1".to_owned(), + c"PWD=/old".to_owned(), + c"LAST=1".to_owned(), + ]; + let physical_pwd = PhysicalPwd::new(&mut environment, 32).unwrap(); + assert_eq!(physical_pwd.index, 1); + assert_eq!(environment, [c"FIRST=1".to_owned(), c"LAST=1".to_owned()]); + assert!(physical_pwd.entry.starts_with(PWD_PREFIX)); + assert_eq!(physical_pwd.entry.last(), Some(&0)); + assert_eq!( + PhysicalPwd::new(&mut environment, PWD_PREFIX.len()).err(), + Some(Errno::EINVAL) + ); + } + + #[test] + fn managed_plan_rejects_every_invalid_physical_pwd_invariant() { + let mut environment = vec![c"VALUE=1".to_owned()]; + let physical_pwd = PhysicalPwd::new(&mut environment, 32).unwrap(); + assert_eq!( + managed_plan(environment, Some(physical_pwd), false).err(), + Some(Errno::EINVAL) + ); + + let mut environment = vec![c"VALUE=1".to_owned()]; + let mut physical_pwd = PhysicalPwd::new(&mut environment, 32).unwrap(); + physical_pwd.entry[0] = b'X'; + assert_eq!( + managed_plan(environment, Some(physical_pwd), true).err(), + Some(Errno::EINVAL) + ); + + let mut environment = vec![c"VALUE=1".to_owned()]; + let mut physical_pwd = PhysicalPwd::new(&mut environment, 32).unwrap(); + *physical_pwd.entry.last_mut().unwrap() = b'X'; + assert_eq!( + managed_plan(environment, Some(physical_pwd), true).err(), + Some(Errno::EINVAL) + ); + + let mut environment = vec![c"VALUE=1".to_owned()]; + let mut physical_pwd = PhysicalPwd::new(&mut environment, 32).unwrap(); + physical_pwd.index = environment.len() + 1; + assert_eq!( + managed_plan(environment, Some(physical_pwd), true).err(), + Some(Errno::EINVAL) + ); + } + + #[test] + fn spawn_rejects_a_corrupted_plan_before_forking() { + let mut environment = vec![c"VALUE=1".to_owned()]; + let physical_pwd = PhysicalPwd::new(&mut environment, 32).unwrap(); + let mut plan = managed_plan(environment, Some(physical_pwd), true).unwrap(); + plan.physical_pwd.as_mut().unwrap().entry[0] = b'X'; + let action = descriptor(); + let descriptors = SpawnDescriptors::managed( + &action, + (&action, &action), + (&action, &action), + Some((&action, &action)), + ); + assert_eq!(spawn(&mut plan, descriptors).err(), Some(Errno::EINVAL)); + } +} diff --git a/nvx-rs/src/guest/sys/terminal.rs b/nvx-rs/src/guest/sys/terminal.rs new file mode 100644 index 000000000..8610acb96 --- /dev/null +++ b/nvx-rs/src/guest/sys/terminal.rs @@ -0,0 +1,18 @@ +//! Safe terminal configuration and output-drain operations. + +use nix::sys::termios::{ControlFlags, SetArg, cfmakeraw, tcdrain, tcgetattr, tcsetattr}; + +use super::{Errno, FileDescriptor}; + +/// Configures a terminal for raw local operation. +pub(crate) fn configure_raw(descriptor: &FileDescriptor) -> Result<(), Errno> { + let mut settings = tcgetattr(descriptor.borrowed())?; + cfmakeraw(&mut settings); + settings.control_flags |= ControlFlags::CLOCAL; + tcsetattr(descriptor.borrowed(), SetArg::TCSANOW, &settings) +} + +/// Waits until all terminal output queued on `descriptor` has been transmitted. +pub(crate) fn drain(descriptor: &FileDescriptor) -> Result<(), Errno> { + tcdrain(descriptor.borrowed()) +} From 39439d1800e2ee911c3a3be07d33540bb5fcd2df Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Wed, 14 Oct 2026 16:10:56 -0300 Subject: [PATCH 3/5] nvx-rs: exercise the managed agent helper binary Exercise the managed agent helper as a real process, including exact and layered environments, cwd handling, malformed configuration, the internal descriptor lifetime, and command-line failures. The tests can use either the Cargo test binary or the prebuilt guest-profile binary. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/tests/guest_tools.rs | 293 +++++++++++++++++++++++++++++++++++- 1 file changed, 292 insertions(+), 1 deletion(-) diff --git a/nvx-rs/tests/guest_tools.rs b/nvx-rs/tests/guest_tools.rs index 08ac9e5b7..7dba4a0d4 100644 --- a/nvx-rs/tests/guest_tools.rs +++ b/nvx-rs/tests/guest_tools.rs @@ -6,7 +6,8 @@ use std::ffi::OsStr; use std::fs; -use std::io; +use std::io::{self, Seek, Write}; +use std::os::fd::AsRawFd; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; @@ -14,6 +15,7 @@ use std::process::{Child, Command, Output, Stdio}; use std::sync::RwLock; use nix::errno::Errno; +use nix::fcntl::{FcntlArg, FdFlag, fcntl}; /// Returns the path of the guest binary `name`. fn binary(name: &str) -> PathBuf { @@ -24,6 +26,7 @@ fn binary(name: &str) -> PathBuf { "nvx-console-pending" => env!("CARGO_BIN_EXE_nvx-console-pending"), "nvx-mmio-write" => env!("CARGO_BIN_EXE_nvx-mmio-write"), "nvx-reseed" => env!("CARGO_BIN_EXE_nvx-reseed"), + "nvx-managed-agent" => env!("CARGO_BIN_EXE_nvx-managed-agent"), "nvx-exit" => env!("CARGO_BIN_EXE_nvx-exit"), "nvx-device-io" => env!("CARGO_BIN_EXE_nvx-device-io"), _ => panic!("no guest binary {name}"), @@ -520,3 +523,291 @@ fn device_io_exits_255_when_it_cannot_print() { let (status, stderr) = run_to_ignoring_sigpipe("nvx-device-io", arguments, broken_pipe()); assert_eq!((status.code(), stderr), (Some(255), Vec::new())); } + +fn helper_config(flags: u16, cwd: &[u8], environment: &[&[u8]]) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&flags.to_le_bytes()); + bytes.extend_from_slice(&(environment.len() as u16).to_le_bytes()); + bytes.extend_from_slice(&(cwd.len() as u32).to_le_bytes()); + bytes.extend_from_slice(cwd); + for entry in environment { + bytes.extend_from_slice(&(entry.len() as u32).to_le_bytes()); + bytes.extend_from_slice(entry); + } + bytes +} + +fn run_agent_helper( + config: &[u8], + workload: &[&OsStr], + directory: Option<&Path>, + environment: &[(&str, &str)], +) -> Output { + let mut file = tempfile::tempfile().unwrap(); + file.write_all(config).unwrap(); + file.rewind().unwrap(); + let fd = file.as_raw_fd(); + // The helper inherits the descriptor, and closes it before it executes the workload. + fcntl(&file, FcntlArg::F_SETFD(FdFlag::empty())).unwrap(); + let mut command = Command::new(binary("nvx-managed-agent")); + command + .arg("--exec-config-fd") + .arg(fd.to_string()) + .arg("--") + .args(workload) + .env_clear(); + for (name, value) in environment { + command.env(name, value); + } + if let Some(directory) = directory { + command.current_dir(directory); + } + output_of(command.stdout(Stdio::piped())) +} + +#[test] +fn managed_agent_helper_round_trips_environment_and_cwd() { + const CWD_PRESENT: u16 = 1; + const ENVIRONMENT_PRESENT: u16 = 2; + const INHERIT_DEFAULT_ENV: u16 = 4; + let env = OsStr::new("/usr/bin/env"); + let pwd = OsStr::new("/bin/pwd"); + + let output = run_agent_helper( + &helper_config(0, &[], &[]), + &[env], + None, + &[("BASE", "inherited"), ("NVX_EXEC_CONFIG_FD", "synthetic")], + ); + assert_eq!((output.status.code(), output.stderr), (Some(0), Vec::new())); + let lines: std::collections::BTreeSet<_> = output.stdout.split(|&byte| byte == b'\n').collect(); + assert!(lines.contains(b"BASE=inherited".as_slice())); + assert!(lines.contains(b"PWD=/".as_slice())); + assert!( + !output + .stdout + .windows(b"NVX_EXEC_CONFIG_FD".len()) + .any(|window| window == b"NVX_EXEC_CONFIG_FD") + ); + + let output = run_agent_helper( + &helper_config(ENVIRONMENT_PRESENT, &[], &[]), + &[env], + None, + &[("BASE", "inherited"), ("NVX_EXEC_CONFIG_FD", "synthetic")], + ); + assert_eq!( + (output.status.code(), output.stdout, output.stderr), + (Some(0), Vec::new(), Vec::new()) + ); + + let output = run_agent_helper( + &helper_config(CWD_PRESENT | ENVIRONMENT_PRESENT, b"/tmp", &[]), + &[pwd], + None, + &[], + ); + assert_eq!( + (output.status.code(), output.stdout, output.stderr), + (Some(0), b"/tmp\n".to_vec(), Vec::new()) + ); + + let output = run_agent_helper( + &helper_config( + ENVIRONMENT_PRESENT | INHERIT_DEFAULT_ENV, + &[], + &[b"VALUE=layered"], + ), + &[env], + None, + &[("BASE", "inherited"), ("NVX_EXEC_CONFIG_FD", "synthetic")], + ); + assert_eq!(output.status.code(), Some(0)); + let lines: std::collections::BTreeSet<_> = output.stdout.split(|&byte| byte == b'\n').collect(); + assert!(lines.contains(b"BASE=inherited".as_slice())); + assert!(lines.contains(b"VALUE=layered".as_slice())); + assert!( + !output + .stdout + .windows(b"NVX_EXEC_CONFIG_FD".len()) + .any(|window| window == b"NVX_EXEC_CONFIG_FD") + ); + + let output = run_agent_helper( + &helper_config( + CWD_PRESENT | ENVIRONMENT_PRESENT, + b"/tmp", + &[b"PWD=/caller"], + ), + &[env], + None, + &[], + ); + assert_eq!( + (output.status.code(), output.stdout, output.stderr), + (Some(0), b"PWD=/caller\n".to_vec(), Vec::new()) + ); +} + +#[test] +fn managed_agent_helper_keeps_requested_pwd_and_reports_unusable_cwd() { + const CWD_PRESENT: u16 = 1; + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("a/b"); + let second = directory.path().join("a/c"); + fs::create_dir_all(&first).unwrap(); + fs::create_dir(&second).unwrap(); + std::os::unix::fs::symlink(directory.path().join("a"), directory.path().join("link")).unwrap(); + let cases = [ + (directory.path().join("a/b/."), first.clone()), + (directory.path().join("a/b/../c"), second), + (directory.path().join("a//b"), first.clone()), + (PathBuf::from(format!("{}/", first.display())), first), + ( + directory.path().join("link/b"), + directory.path().join("link/b"), + ), + ]; + for (requested, expected) in cases { + let output = run_agent_helper( + &helper_config(CWD_PRESENT, requested.as_os_str().as_bytes(), &[]), + &[OsStr::new("/usr/bin/env")], + None, + &[], + ); + assert_eq!(output.status.code(), Some(0), "{requested:?}"); + let expected = [b"PWD=".as_slice(), expected.as_os_str().as_bytes()].concat(); + assert!( + output + .stdout + .split(|&byte| byte == b'\n') + .any(|line| line == expected), + "{requested:?}: {:?}", + output.stdout + ); + } + + let missing = directory.path().join("missing"); + let output = run_agent_helper( + &helper_config(CWD_PRESENT, missing.as_os_str().as_bytes(), &[]), + &[OsStr::new("/bin/true")], + None, + &[], + ); + assert_eq!(output.status.code(), Some(125)); + assert!(output.stdout.is_empty()); + assert!( + output + .stderr + .windows(b"cannot use working directory".len()) + .any(|window| window == b"cannot use working directory") + ); +} + +#[test] +fn managed_agent_helper_accepts_large_exact_environment() { + const ENVIRONMENT_PRESENT: u16 = 2; + let entries: Vec> = (0..12) + .map(|index| { + let mut entry = format!("KEY{index}=").into_bytes(); + entry.extend(std::iter::repeat_n(b'x', 4000)); + entry + }) + .collect(); + let references: Vec<&[u8]> = entries.iter().map(Vec::as_slice).collect(); + let output = run_agent_helper( + &helper_config(ENVIRONMENT_PRESENT, &[], &references), + &[OsStr::new("/usr/bin/env")], + None, + &[], + ); + assert_eq!((output.status.code(), output.stderr), (Some(0), Vec::new())); + let expected: Vec = entries + .iter() + .flat_map(|entry| entry.iter().copied().chain(std::iter::once(b'\n'))) + .collect(); + assert_eq!(output.stdout, expected); +} + +#[test] +fn managed_agent_helper_defensively_rejects_bad_configuration() { + const CWD_PRESENT: u16 = 1; + const ENVIRONMENT_PRESENT: u16 = 2; + const CWD_ENTERED: u16 = 0x8000; + let workload = [OsStr::new("/bin/pwd")]; + for config in [ + helper_config(CWD_PRESENT, b".", &[]), + helper_config(CWD_PRESENT | CWD_ENTERED, b"/", &[]), + helper_config(ENVIRONMENT_PRESENT, &[], &[b"NO_EQUALS"]), + helper_config(ENVIRONMENT_PRESENT, &[], &[b"=value"]), + helper_config(ENVIRONMENT_PRESENT, &[], &[b"KEY=embedded\0nul"]), + ] { + let output = run_agent_helper(&config, &workload, None, &[]); + assert_eq!(output.status.code(), Some(125), "{config:?}"); + assert!(output.stdout.is_empty()); + } +} + +#[test] +fn managed_agent_helper_keeps_an_entered_directory() { + const CWD_ENTERED: u16 = 0x8000; + let directory = tempfile::tempdir().unwrap(); + let output = run_agent_helper( + &helper_config(CWD_ENTERED, &[], &[]), + &[OsStr::new("/bin/pwd")], + Some(directory.path()), + &[("BASE", "inherited")], + ); + let expected = [directory.path().as_os_str().as_bytes(), b"\n"].concat(); + assert_eq!( + (output.status.code(), output.stdout, output.stderr), + (Some(0), expected, Vec::new()) + ); +} + +#[test] +fn managed_agent_helper_closes_its_internal_descriptor() { + const CWD_ENTERED: u16 = 0x8000; + let script = OsStr::new(r#"test ! -e "/proc/self/fd/$1""#); + let mut file = tempfile::tempfile().unwrap(); + file.write_all(&helper_config(CWD_ENTERED, &[], &[])) + .unwrap(); + file.rewind().unwrap(); + let fd = file.as_raw_fd(); + // The helper deliberately inherits this descriptor. + fcntl(&file, FcntlArg::F_SETFD(FdFlag::empty())).unwrap(); + let output = output_of( + Command::new(binary("nvx-managed-agent")) + .args([ + OsStr::new("--exec-config-fd"), + OsStr::new(&fd.to_string()), + OsStr::new("--"), + OsStr::new("/bin/sh"), + OsStr::new("-c"), + script, + OsStr::new("sh"), + OsStr::new(&fd.to_string()), + ]) + .stdout(Stdio::piped()), + ); + assert_eq!( + (output.status.code(), output.stdout, output.stderr), + (Some(0), Vec::new(), Vec::new()) + ); +} + +#[test] +fn managed_agent_bad_helper_syntax_exits_125() { + for arguments in [ + &[][..], + &["--exec-config-fd"], + &["--exec-config-fd", "-1", "--", "/bin/true"], + &["--exec-config-fd", "0", "--", "bin/true"], + ] { + assert_eq!( + run("nvx-managed-agent", arguments).0, + Some(125), + "{arguments:?}" + ); + } +} From b9cc41dfaaaff90c250380ef47fa5511b5b10c36 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Wed, 14 Oct 2026 16:10:56 -0300 Subject: [PATCH 4/5] ci: build and test nvx-managed-agent through nvx.py Add nvx-managed-agent to the Rust guest-binary inventory. The existing nvx.py build-guest-binaries and test-guest-binaries commands now build its static musl executable in the same fresh, reproducible Cargo build as the other guest programs, check its ELF form, include its digest in the manifest, and run its prebuilt helper integration tests. A build removes the retired nvx-port-io from an earlier output whose manifest records it, and in the same way, it adds nvx-managed-agent to an earlier output whose manifest does not record it. It still refuses any other existing output, such as one that lacks an executable that its manifest records. The command documentation now counts six binaries. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/usage.md | 7 ++-- scripts/nvx_tools/build_constants.py | 4 +++ scripts/nvx_tools/guest_binaries.py | 25 ++++++++++---- scripts/test_guest_binaries.py | 51 ++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/doc/usage.md b/doc/usage.md index accc2351f..1cc8bbac2 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -161,12 +161,13 @@ python3 scripts/nvx.py build-guest-binaries --output DIRECTORY ``` The Linux-only build command checks the exact `rust-toolchain.toml` pin and -builds the five PR 1 guest executables for `x86_64-unknown-linux-musl` with one +builds the six Rust guest executables for `x86_64-unknown-linux-musl` with one `cargo build` of the `guest` profile, a fresh target directory, Cargo's locked mode, incremental compilation disabled, inherited Cargo profile overrides removed, remapped source paths, and no linker build IDs. It runs the static-ELF check and writes `manifest.json` with the source and executable SHA-256 digests. -It updates the output of an earlier build in place, removing the retired +It updates the output of an earlier build in place, adding `nvx-managed-agent` +when that build's manifest does not record it and removing the retired `nvx-port-io` executable when that build's manifest records it, and refuses any other existing `DIRECTORY`. @@ -176,7 +177,7 @@ other existing `DIRECTORY`. python3 scripts/nvx.py test-guest-binaries --binaries DIRECTORY ``` -The Linux-only test command validates the five binaries and build manifest, +The Linux-only test command validates the six binaries and build manifest, sets `NVX_GUEST_BINARIES`, and runs the musl `guest_tools` integration test against that exact directory. diff --git a/scripts/nvx_tools/build_constants.py b/scripts/nvx_tools/build_constants.py index 7b37d26d5..9fa3d60d7 100644 --- a/scripts/nvx_tools/build_constants.py +++ b/scripts/nvx_tools/build_constants.py @@ -182,10 +182,14 @@ class GuestRustBuildConstants: "nvx-reseed", "nvx-exit", "nvx-device-io", + "nvx-managed-agent", ) # Executables that earlier builds produced. A build removes them from the output # of an earlier build whose manifest records them. RETIRED_BINARIES: Final = ("nvx-port-io",) + # Executables that earlier builds lacked. A build adds them to the output of an + # earlier build whose manifest does not record them. + ADDED_BINARIES: Final = ("nvx-managed-agent",) class AlpineBuildConstants: diff --git a/scripts/nvx_tools/guest_binaries.py b/scripts/nvx_tools/guest_binaries.py index ae3692fa1..b06c4919d 100644 --- a/scripts/nvx_tools/guest_binaries.py +++ b/scripts/nvx_tools/guest_binaries.py @@ -164,7 +164,8 @@ def _validate_owned_output(output: Path) -> tuple[str, ...]: """Require an existing output to be a complete build owned by this tool. Return the retired executables that the output holds, which its build manifest - must record. + must record. The output may lack added executables, which its build manifest + must not record. """ if not output.is_dir(): raise ScriptError( @@ -176,9 +177,17 @@ def _validate_owned_output(output: Path) -> tuple[str, ...]: for binary in GuestRustBuildConstants.RETIRED_BINARIES if binary in actual ) + missing = tuple( + binary + for binary in GuestRustBuildConstants.ADDED_BINARIES + if binary not in actual + ) + present = tuple( + binary for binary in GuestRustBuildConstants.BINARIES if binary not in missing + ) expected = { GuestRustBuildConstants.BUILD_MANIFEST_NAME, - *GuestRustBuildConstants.BINARIES, + *present, *retired, } if actual != expected: @@ -202,15 +211,19 @@ def _validate_owned_output(output: Path) -> tuple[str, ...]: raise ScriptError( f"guest binary output has an unrecognized build manifest: {manifest_path}" ) - if retired: + if retired or missing: recorded = manifest.get("binaries") - if not isinstance(recorded, dict) or not all( - binary in cast(dict[str, object], recorded) for binary in retired + if ( + not isinstance(recorded, dict) + or not all( + binary in cast(dict[str, object], recorded) for binary in retired + ) + or any(binary in cast(dict[str, object], recorded) for binary in missing) ): raise ScriptError( f"guest binary output is not owned by this builder: {output}" ) - for binary in (*GuestRustBuildConstants.BINARIES, *retired): + for binary in (*present, *retired): path = output / binary if not path.is_file() or path.is_symlink(): raise ScriptError(f"guest binary output has an invalid executable: {path}") diff --git a/scripts/test_guest_binaries.py b/scripts/test_guest_binaries.py index 118e3230f..b13e1b5ee 100644 --- a/scripts/test_guest_binaries.py +++ b/scripts/test_guest_binaries.py @@ -349,13 +349,64 @@ def test_rebuild_removes_a_retired_executable_that_the_manifest_records(self): self.assertEqual(manifest["binaries"], result.binaries) self.assertFalse(any(root.glob(".output.work-*"))) + def test_rebuild_adds_an_executable_that_an_earlier_build_lacked(self): + # Otherwise, an output could lack an executable that no build installs. + self.assertTrue( + set(GuestRustBuildConstants.ADDED_BINARIES).issubset( + GuestRustBuildConstants.BINARIES + ) + ) + earlier = tuple( + binary + for binary in GuestRustBuildConstants.BINARIES + if binary not in GuestRustBuildConstants.ADDED_BINARIES + ) + for case, previous in ( + ("build without the added executables", earlier), + ("build from before nvx-port-io retired", (*earlier, "nvx-port-io")), + ): + with ( + self.subTest(case), + tempfile.TemporaryDirectory( + prefix=".test-guest-binary-added-", + dir=BuildConstants.REPO_ROOT, + ) as temporary, + ): + root = Path(temporary) + output = root / "output" + _write_build_output(output, previous, recorded=previous) + with _fake_guest_build(): + result = guest_binaries.build_guest_binaries(output) + self.assertEqual( + {path.name for path in output.iterdir()}, + { + GuestRustBuildConstants.BUILD_MANIFEST_NAME, + *GuestRustBuildConstants.BINARIES, + }, + ) + for binary in GuestRustBuildConstants.BINARIES: + self.assertEqual((output / binary).read_bytes(), binary.encode()) + manifest = json.loads( + (output / GuestRustBuildConstants.BUILD_MANIFEST_NAME).read_text( + encoding="utf-8" + ) + ) + self.assertEqual(manifest["binaries"], result.binaries) + self.assertFalse(any(root.glob(".output.work-*"))) + def test_rebuild_refuses_unrecorded_or_unrelated_files(self): current = GuestRustBuildConstants.BINARIES previous = (*current, "nvx-port-io") unrelated = (*previous, "uncommitted.txt") + earlier = tuple( + binary + for binary in current + if binary not in GuestRustBuildConstants.ADDED_BINARIES + ) for case, binaries, recorded in ( ("unrecorded retired executable", previous, current), ("recorded unrelated file", unrelated, unrelated), + ("recorded missing added executable", earlier, current), ): with ( self.subTest(case), From 887020073429cb2bd48d73e66b98067b9119bcff Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 15 Oct 2026 17:10:41 -0300 Subject: [PATCH 5/5] nvx-rs: confine the host's Unix system calls to host::sys Move the host's remaining Unix FFI behind documented safe functions in a new private host::sys module, which makes its system calls through nix, as guest::sys does for the guest programs. The host module now denies unsafe code, except in sys and the unchanged Windows platform module. - The control socket's connect and SO_PEERCRED use nix's socket API, and the effective IDs, setsid, open, and fcntl use nix's wrappers. - nix has no wrapper for pidfd_open, pidfd_send_signal, close_range, or getdents64, so sys makes them through nix's re-export of libc. The pre_exec hook still allocates nothing, so it reads /proc/self/fd with getdents64 rather than through nix's Dir. - The fake OpenVMM polls through nix, and the managed agent's loopback tests take errno and signal values from guest::sys. nvx-rs no longer depends on libc directly. The host and testing features enable nix, which becomes a Unix rather than a Linux dependency because the host and the fake OpenVMM also build on macOS, and aci_edge_sandboxes/Cargo.lock gains it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- aci_edge_sandboxes/Cargo.lock | 36 +++- nvx-rs/Cargo.lock | 1 - nvx-rs/Cargo.toml | 10 +- nvx-rs/src/guest/managed_agent/loopback.rs | 8 +- nvx-rs/src/host/launch.rs | 3 +- nvx-rs/src/host/mod.rs | 8 + nvx-rs/src/host/platform/linux.rs | 236 ++------------------- nvx-rs/src/host/platform/mod.rs | 1 + nvx-rs/src/host/process.rs | 11 +- nvx-rs/src/host/sys/mod.rs | 29 +++ nvx-rs/src/host/sys/process.rs | 180 ++++++++++++++++ nvx-rs/src/host/sys/socket.rs | 41 ++++ nvx-rs/src/testing/fake_openvmm.rs | 16 +- 13 files changed, 330 insertions(+), 250 deletions(-) create mode 100644 nvx-rs/src/host/sys/mod.rs create mode 100644 nvx-rs/src/host/sys/process.rs create mode 100644 nvx-rs/src/host/sys/socket.rs diff --git a/aci_edge_sandboxes/Cargo.lock b/aci_edge_sandboxes/Cargo.lock index 8b3f28b92..bfae31544 100644 --- a/aci_edge_sandboxes/Cargo.lock +++ b/aci_edge_sandboxes/Cargo.lock @@ -30,6 +30,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "2.13.2" @@ -57,6 +63,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -157,12 +169,34 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nvx-rs" version = "0.1.0" dependencies = [ "getrandom", - "libc", + "nix", "serde", "serde_json", "sha2", diff --git a/nvx-rs/Cargo.lock b/nvx-rs/Cargo.lock index 46a1f11c5..0e45aca95 100644 --- a/nvx-rs/Cargo.lock +++ b/nvx-rs/Cargo.lock @@ -152,7 +152,6 @@ name = "nvx-rs" version = "0.1.0" dependencies = [ "getrandom", - "libc", "nix", "serde", "serde_json", diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index f95ed0e08..eecc5a70b 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -20,13 +20,13 @@ name = "nvx" default = ["host"] # The host side: artifact discovery, launch, network and filesystem lowering, control sessions, # process and platform primitives, and the supervision of VM instances. -host = ["serde", "dep:getrandom", "dep:serde_json", "dep:libc", "dep:windows-sys"] +host = ["serde", "dep:getrandom", "dep:serde_json", "dep:nix", "dep:windows-sys"] # Stages the OpenVMM executable, guest kernel, and control initramfs at build time; see # `Artifacts::bundled`. The build script downloads the release pinned in `artifacts.json`, or # copies the release directory named by `NVX_BUNDLE_DIR`. bundled = ["host", "dep:sha2"] # Test doubles: the fake OpenVMM of `testing::fake_openvmm` and its `nvx-fake-openvmm` binary. -testing = ["dep:getrandom", "dep:serde", "dep:serde_json", "dep:libc", "dep:windows-sys"] +testing = ["dep:getrandom", "dep:serde", "dep:serde_json", "dep:nix", "dep:windows-sys"] # The serde form of `Hypervisor`. The `host` feature enables it. serde = ["dep:serde"] # The programs that run in Linux guests: the `guest` module and the `nvx-*` guest binaries. Build @@ -38,9 +38,6 @@ getrandom = { version = "0.4", optional = true } serde = { version = "1.0.200", features = ["derive"], optional = true } serde_json = { version = "1.0.120", optional = true } -[target.'cfg(unix)'.dependencies] -libc = { version = "0.2.160", optional = true } - [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", optional = true, features = [ "Win32_Foundation", @@ -52,7 +49,8 @@ windows-sys = { version = "0.61", optional = true, features = [ "Win32_System_Threading", ] } -[target.'cfg(target_os = "linux")'.dependencies] +# The host and the fake OpenVMM also build on Unix hosts other than Linux, such as macOS. +[target.'cfg(unix)'.dependencies] nix = { version = "0.31.3", default-features = false, optional = true, features = [ "fs", "ioctl", diff --git a/nvx-rs/src/guest/managed_agent/loopback.rs b/nvx-rs/src/guest/managed_agent/loopback.rs index db7504575..b1f0f01bc 100644 --- a/nvx-rs/src/guest/managed_agent/loopback.rs +++ b/nvx-rs/src/guest/managed_agent/loopback.rs @@ -142,7 +142,7 @@ impl BrokerCore { self.epoch = self .epoch .checked_add(1) - .ok_or_else(|| io::Error::from_raw_os_error(libc::EOVERFLOW))?; + .ok_or_else(|| io::Error::from(Errno::EOVERFLOW))?; self.host_output.clear(); self.receive_credit = 0; self.reset_guest()?; @@ -347,7 +347,7 @@ fn spawn_unprivileged(request: SpawnRequest<'_>) -> Result { } fn protocol_error() -> io::Error { - io::Error::from_raw_os_error(libc::EPROTO) + io::Error::from(Errno::EPROTO) } fn timeout_error(error: io::Error) -> io::Error { @@ -498,7 +498,7 @@ mod tests { signal, ExecEvent::Exit { category: ExitCategory::Signal, - status: 128 + libc::SIGTERM + status: 128 + sys::Signal::SIGTERM as i32 } ); let (_, _, realtime_signal) = run_exec(&mut session, &workload("kill -40 $$", 0)); @@ -536,7 +536,7 @@ mod tests { .unwrap(), ExecEvent::Exit { category: ExitCategory::Cancelled, - status: 128 + libc::SIGKILL + status: 128 + sys::Signal::SIGKILL as i32 } ); diff --git a/nvx-rs/src/host/launch.rs b/nvx-rs/src/host/launch.rs index 22e3a317e..ce8927d3c 100644 --- a/nvx-rs/src/host/launch.rs +++ b/nvx-rs/src/host/launch.rs @@ -93,8 +93,7 @@ fn host_identity(wanted: bool) -> Option<(u32, u32)> { } #[cfg(unix)] { - // SAFETY: geteuid and getegid have no preconditions. - let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + let (uid, gid) = super::sys::effective_ids(); (uid != 0 && gid != 0).then_some((uid, gid)) } #[cfg(not(unix))] diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index ccac915b9..129d27afd 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -12,6 +12,11 @@ //! - [`session`] speaks the authenticated control protocol, [`process`] launches and terminates //! OpenVMM processes, and [`platform`] holds the primitives of Linux and Windows hosts. Other //! hosts compile but report NVX as unavailable. +//! +//! The host modules deny unsafe code. On Unix, the private `sys` module confines the system calls +//! that the standard library lacks behind documented safe functions, which call `nix`. The +//! Windows platform module still calls `windows-sys` directly. +#![deny(unsafe_code)] pub mod artifacts; pub mod filesystem; @@ -22,3 +27,6 @@ pub mod platform; pub mod process; pub mod report; pub mod session; +#[cfg(unix)] +#[allow(unsafe_code)] +mod sys; diff --git a/nvx-rs/src/host/platform/linux.rs b/nvx-rs/src/host/platform/linux.rs index 1f2063cf4..83bd68ca0 100644 --- a/nvx-rs/src/host/platform/linux.rs +++ b/nvx-rs/src/host/platform/linux.rs @@ -1,16 +1,14 @@ use std::fs::{self, DirBuilder, OpenOptions, Permissions}; use std::io::{self, Read, Write}; -use std::mem; -use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; use std::os::unix::net::UnixStream; -use std::os::unix::process::CommandExt; use std::path::Path; use std::process::Command; use std::thread; use std::time::{Duration, Instant}; use super::Transport; +use crate::host::sys::{self, Errno}; use crate::hypervisor::Hypervisor; /// Whether this host can run NVX microVMs. @@ -28,7 +26,7 @@ pub fn process_start_time(pid: u32) -> io::Result> { Ok(stat) => stat, Err(error) if error.kind() == io::ErrorKind::NotFound - || error.raw_os_error() == Some(libc::ESRCH) => + || error.raw_os_error() == Some(Errno::ESRCH as i32) => { return Ok(None); } @@ -68,7 +66,7 @@ fn other_threads_remain(pid: u32) -> io::Result { Ok(threads) => Ok(threads.count() > 1), Err(error) if error.kind() == io::ErrorKind::NotFound - || error.raw_os_error() == Some(libc::ESRCH) => + || error.raw_os_error() == Some(Errno::ESRCH as i32) => { Ok(false) } @@ -82,160 +80,29 @@ fn other_threads_remain(pid: u32) -> io::Result { /// without pidfds (Linux before 5.3, or seccomp profiles that reject them) get an error instead /// of a racy kill. pub(crate) fn kill_process(pid: u32, start_time: u64) -> io::Result<()> { - let raw_pid = libc::pid_t::try_from(pid).map_err(io::Error::other)?; - // SAFETY: pidfd_open takes a process ID and flags and returns a new descriptor or -1. - let descriptor = unsafe { libc::syscall(libc::SYS_pidfd_open, raw_pid, 0) }; - if descriptor < 0 { - let error = io::Error::last_os_error(); - return if error.raw_os_error() == Some(libc::ESRCH) { - Ok(()) - } else { - Err(io::Error::new( + let process = match sys::PidFd::open(pid) { + Ok(process) => process, + Err(error) if error.raw_os_error() == Some(Errno::ESRCH as i32) => return Ok(()), + Err(error) => { + return Err(io::Error::new( error.kind(), format!("cannot pin OpenVMM process {pid} for termination: {error}"), - )) - }; - } - // SAFETY: the kernel returned a fresh descriptor that nothing else owns. - let pidfd = unsafe { OwnedFd::from_raw_fd(descriptor as RawFd) }; - if process_start_time(pid)? != Some(start_time) { - return Ok(()); - } - // SAFETY: the descriptor is a valid pidfd, and a null siginfo with no flags is allowed. - let result = unsafe { - libc::syscall( - libc::SYS_pidfd_send_signal, - pidfd.as_raw_fd(), - libc::SIGKILL, - std::ptr::null::(), - 0, - ) + )); + } }; - if result == 0 { + if process_start_time(pid)? != Some(start_time) { return Ok(()); } - let error = io::Error::last_os_error(); - if error.raw_os_error() == Some(libc::ESRCH) { - Ok(()) - } else { - Err(error) + match process.kill() { + Err(error) if error.raw_os_error() == Some(Errno::ESRCH as i32) => Ok(()), + result => result, } } /// Starts the command in a new session so it outlives the caller and its terminal, and with no /// descriptor of the caller beyond the standard streams that the command sets up. pub(crate) fn detach(command: &mut Command, _breakaway_from_job: bool) { - // SAFETY: the closure makes only async-signal-safe system calls and allocates nothing, as - // code that runs between fork and exec must. - unsafe { - command.pre_exec(|| { - if libc::setsid() == -1 { - return Err(io::Error::last_os_error()); - } - mark_descriptors_close_on_exec() - }); - } -} - -/// Marks every descriptor above standard error close-on-exec. -/// -/// Rust opens its own descriptors close-on-exec, but a descriptor that the caller inherited or -/// that native code opened may lack the flag, and a detached VM would hold it until it stops: a -/// pipe writer it holds, for example, keeps the reader from seeing end-of-file. Marking rather -/// than closing keeps the descriptor through which `Command::spawn` reports a failed exec. -fn mark_descriptors_close_on_exec() -> io::Result<()> { - let first: libc::c_uint = 3; - // SAFETY: close_range takes two descriptor bounds and flags and changes no memory. - let marked = unsafe { - libc::syscall( - libc::SYS_close_range, - first, - libc::c_uint::MAX, - libc::CLOSE_RANGE_CLOEXEC, - ) - }; - if marked == 0 { - return Ok(()); - } - // Linux before 5.11 lacks CLOSE_RANGE_CLOEXEC, and seccomp policies may reject close_range. - mark_listed_descriptors_close_on_exec() -} - -/// Marks every descriptor above standard error that `/proc/self/fd` lists close-on-exec. -/// -/// It reads the directory with getdents64 into a stack buffer, so it allocates nothing. -pub(crate) fn mark_listed_descriptors_close_on_exec() -> io::Result<()> { - // SAFETY: the path is NUL-terminated, and the call returns a new descriptor or -1. - let directory = unsafe { - libc::open( - c"/proc/self/fd".as_ptr(), - libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC, - ) - }; - if directory < 0 { - return Err(io::Error::last_os_error()); - } - let result = mark_directory_entries(directory); - // SAFETY: this function opened the descriptor, and nothing else uses it. - unsafe { libc::close(directory) }; - result -} - -/// Marks the descriptors enumerated by the open `/proc/self/fd` `directory` close-on-exec. -fn mark_directory_entries(directory: RawFd) -> io::Result<()> { - #[repr(C, align(8))] - struct Records([u8; 4096]); - - let malformed = || io::Error::from_raw_os_error(libc::EIO); - let mut buffer = Records([0; 4096]); - loop { - // SAFETY: the kernel writes at most the buffer's length into it. - let read = unsafe { - libc::syscall( - libc::SYS_getdents64, - directory, - buffer.0.as_mut_ptr(), - buffer.0.len(), - ) - }; - let Ok(read) = usize::try_from(read) else { - return Err(io::Error::last_os_error()); - }; - if read == 0 { - return Ok(()); - } - let mut records = buffer.0.get(..read).ok_or_else(malformed)?; - while !records.is_empty() { - // A linux_dirent64 holds d_ino (8 bytes), d_off (8), d_reclen (2), d_type (1), and - // the NUL-terminated name. - let length = match records.get(16..18) { - Some(&[low, high]) => usize::from(u16::from_ne_bytes([low, high])), - _ => return Err(malformed()), - }; - let (record, rest) = records.split_at_checked(length).ok_or_else(malformed)?; - let name = record.get(19..).ok_or_else(malformed)?; - if let Some(descriptor) = descriptor_number(name) - && descriptor > 2 - && descriptor != directory - { - // SAFETY: F_SETFD changes only the flags of the descriptor; a stale number fails. - unsafe { libc::fcntl(descriptor, libc::F_SETFD, libc::FD_CLOEXEC) }; - } - records = rest; - } - } -} - -/// Parses a `/proc/self/fd` entry name, which ends at its NUL terminator. -fn descriptor_number(name: &[u8]) -> Option { - let digits = name.split(|&byte| byte == 0).next()?; - if digits.is_empty() { - return None; - } - digits.iter().try_fold(0, |value: RawFd, &byte| { - let digit = byte.checked_sub(b'0').filter(|digit| *digit < 10)?; - value.checked_mul(10)?.checked_add(RawFd::from(digit)) - }) + sys::detach(command); } /// Checks that the hypervisor device is present and accessible. @@ -292,8 +159,8 @@ pub(crate) fn connect_endpoint( expected_pid: u32, _timeout: Duration, ) -> io::Result> { - let stream = connect_nonblocking(endpoint)?; - let peer = peer_pid(&stream)?; + let stream = sys::connect_nonblocking(endpoint)?; + let peer = sys::peer_pid(&stream)?; if peer != expected_pid { return Err(io::Error::new( io::ErrorKind::PermissionDenied, @@ -309,8 +176,8 @@ pub(crate) fn connect_endpoint( pub(crate) fn endpoint_server_pid(endpoint: &str) -> io::Result> { let deadline = Instant::now() + Duration::from_secs(5); loop { - match connect_nonblocking(endpoint) { - Ok(stream) => return peer_pid(&stream).map(Some), + match sys::connect_nonblocking(endpoint) { + Ok(stream) => return sys::peer_pid(&stream).map(Some), Err(error) if matches!( error.kind(), @@ -330,71 +197,6 @@ pub(crate) fn endpoint_server_pid(endpoint: &str) -> io::Result> { } } -/// Connects to the Unix socket at `path` without waiting in a full listen backlog. -fn connect_nonblocking(path: &str) -> io::Result { - // SAFETY: socket has no memory-safety preconditions. - let descriptor = unsafe { - libc::socket( - libc::AF_UNIX, - libc::SOCK_STREAM | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK, - 0, - ) - }; - if descriptor < 0 { - return Err(io::Error::last_os_error()); - } - // SAFETY: the kernel returned a fresh descriptor that nothing else owns. - let socket = unsafe { OwnedFd::from_raw_fd(descriptor) }; - // SAFETY: sockaddr_un is plain data for which all-zero bytes are valid. - let mut address: libc::sockaddr_un = unsafe { mem::zeroed() }; - address.sun_family = libc::AF_UNIX as libc::sa_family_t; - let bytes = path.as_bytes(); - if bytes.len() >= address.sun_path.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "control socket path is too long", - )); - } - for (target, byte) in address.sun_path.iter_mut().zip(bytes) { - *target = *byte as libc::c_char; - } - let length = - libc::socklen_t::try_from(mem::offset_of!(libc::sockaddr_un, sun_path) + bytes.len() + 1) - .map_err(io::Error::other)?; - // SAFETY: the address is a NUL-terminated sockaddr_un of `length` bytes. - if unsafe { libc::connect(socket.as_raw_fd(), (&raw const address).cast(), length) } != 0 { - return Err(io::Error::last_os_error()); - } - let stream = UnixStream::from(socket); - stream.set_nonblocking(false)?; - Ok(stream) -} - -/// Returns the Linux peer credential's process ID for a connected Unix stream. -fn peer_pid(stream: &UnixStream) -> io::Result { - let mut credentials = libc::ucred { - pid: 0, - uid: 0, - gid: 0, - }; - let mut length = - libc::socklen_t::try_from(size_of::()).map_err(io::Error::other)?; - // SAFETY: the descriptor is a connected socket and the buffer matches the reported length. - let result = unsafe { - libc::getsockopt( - stream.as_raw_fd(), - libc::SOL_SOCKET, - libc::SO_PEERCRED, - (&raw mut credentials).cast(), - &mut length, - ) - }; - if result != 0 { - return Err(io::Error::last_os_error()); - } - u32::try_from(credentials.pid).map_err(io::Error::other) -} - /// Blocking Unix-domain stream adapted to the control transport's deadline API. struct UnixTransport { /// Connected stream to OpenVMM's control socket. diff --git a/nvx-rs/src/host/platform/mod.rs b/nvx-rs/src/host/platform/mod.rs index c5fae8ec0..5a0c9ae74 100644 --- a/nvx-rs/src/host/platform/mod.rs +++ b/nvx-rs/src/host/platform/mod.rs @@ -16,6 +16,7 @@ mod linux; mod other; #[cfg(windows)] /// Windows implementations of host process, pipe, and hypervisor primitives. +#[allow(unsafe_code)] mod windows; #[cfg(target_os = "linux")] diff --git a/nvx-rs/src/host/process.rs b/nvx-rs/src/host/process.rs index 5984280a2..23d2752ce 100644 --- a/nvx-rs/src/host/process.rs +++ b/nvx-rs/src/host/process.rs @@ -161,9 +161,9 @@ pub fn kill(pid: u32, start_time: u64) -> io::Result { mod tests { use std::fs; use std::os::fd::AsRawFd; - use std::os::unix::process::CommandExt; use super::*; + use crate::host::sys; /// Spawns a long-running child while this process holds an inheritable pipe writer, as a /// descriptor that the caller's parent or native code left without close-on-exec would be. @@ -173,11 +173,7 @@ mod tests { fn child_inherits_writer(spawn: impl FnOnce() -> Child) -> bool { let (pipe, child) = { let (_, writer) = io::pipe().unwrap(); - // SAFETY: clearing FD_CLOEXEC only changes the flags of a descriptor this test owns. - assert_eq!( - unsafe { libc::fcntl(writer.as_raw_fd(), libc::F_SETFD, 0) }, - 0 - ); + sys::clear_close_on_exec(&writer).unwrap(); let pipe = fs::read_link(format!("/proc/self/fd/{}", writer.as_raw_fd())).unwrap(); (pipe, Reaped(spawn())) }; @@ -220,8 +216,7 @@ mod tests { // Kernels without CLOSE_RANGE_CLOEXEC take the /proc/self/fd fallback. let fallback = || { let mut command = sleeper(); - // SAFETY: the fallback allocates nothing and makes only async-signal-safe calls. - unsafe { command.pre_exec(platform::mark_listed_descriptors_close_on_exec) }; + sys::mark_listed_descriptors_on_spawn(&mut command); command.spawn().unwrap() }; assert!(!child_inherits_writer(fallback)); diff --git a/nvx-rs/src/host/sys/mod.rs b/nvx-rs/src/host/sys/mod.rs new file mode 100644 index 000000000..d0757e44e --- /dev/null +++ b/nvx-rs/src/host/sys/mod.rs @@ -0,0 +1,29 @@ +//! Safe operating-system interface of Unix hosts. +//! +//! The other host modules deny unsafe code. On Unix, they reach the operating system through the +//! standard library or this module, which makes its system calls through `nix` and confines the +//! unsafe ones behind wrappers that establish their ownership and async-signal-safety invariants. +//! `nix` has no wrapper for `pidfd_open`, `pidfd_send_signal`, `close_range`, or `getdents64`, so +//! those go through `nix`'s re-export of libc. The Windows platform module still calls +//! `windows-sys` directly. + +#[cfg(target_os = "linux")] +mod process; +#[cfg(target_os = "linux")] +mod socket; + +#[cfg(target_os = "linux")] +pub(crate) use nix::errno::Errno; +use nix::unistd::{getegid, geteuid}; + +#[cfg(target_os = "linux")] +pub(crate) use process::{PidFd, detach}; +#[cfg(all(test, target_os = "linux"))] +pub(crate) use process::{clear_close_on_exec, mark_listed_descriptors_on_spawn}; +#[cfg(target_os = "linux")] +pub(crate) use socket::{connect_nonblocking, peer_pid}; + +/// Returns the effective user and group IDs of this process. +pub(crate) fn effective_ids() -> (u32, u32) { + (geteuid().as_raw(), getegid().as_raw()) +} diff --git a/nvx-rs/src/host/sys/process.rs b/nvx-rs/src/host/sys/process.rs new file mode 100644 index 000000000..d8cf5ad1a --- /dev/null +++ b/nvx-rs/src/host/sys/process.rs @@ -0,0 +1,180 @@ +//! Process descriptors, and the hook that detaches a spawned process from its caller. + +use std::io; +#[cfg(test)] +use std::os::fd::AsFd; +use std::os::fd::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd}; +use std::os::unix::process::CommandExt; +use std::process::Command; +use std::ptr; + +use nix::fcntl::{FcntlArg, FdFlag, OFlag, fcntl, open}; +use nix::libc; +use nix::sys::signal::Signal; +use nix::sys::stat::Mode; +use nix::unistd::setsid; + +use super::Errno; + +/// A pidfd: an owned descriptor that refers to one process, whose ID the kernel cannot reuse +/// while the descriptor is open. +pub(crate) struct PidFd(OwnedFd); + +impl PidFd { + /// Opens a pidfd for the process `pid`. + /// + /// Fails with `ESRCH` if no such process exists. Hosts without pidfds, Linux before 5.3 or + /// seccomp profiles that reject them, fail with another error. + pub(crate) fn open(pid: u32) -> io::Result { + let pid = libc::pid_t::try_from(pid).map_err(io::Error::other)?; + // SAFETY: pidfd_open takes a process ID and flags and returns a new descriptor or -1. + let descriptor = Errno::result(unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) })?; + // SAFETY: the kernel returned a fresh descriptor that nothing else owns. + Ok(Self(unsafe { OwnedFd::from_raw_fd(descriptor as RawFd) })) + } + + /// Sends `SIGKILL` to the process. + /// + /// Fails with `ESRCH` if the process has exited. + pub(crate) fn kill(&self) -> io::Result<()> { + // SAFETY: the descriptor is a valid pidfd, and a null siginfo with no flags is allowed. + Errno::result(unsafe { + libc::syscall( + libc::SYS_pidfd_send_signal, + self.0.as_raw_fd(), + Signal::SIGKILL as libc::c_int, + ptr::null::(), + 0, + ) + })?; + Ok(()) + } +} + +/// Makes `command` start its process in a new session, with every descriptor of the caller +/// above standard error close-on-exec. +pub(crate) fn detach(command: &mut Command) { + // SAFETY: the hook makes only async-signal-safe system calls and allocates nothing, as code + // that runs between fork and exec must. + unsafe { + command.pre_exec(|| { + setsid()?; + mark_descriptors_close_on_exec() + }); + } +} + +/// Makes `command` mark only the descriptors that `/proc/self/fd` lists close-on-exec before it +/// executes, as [`detach`] does on kernels without `CLOSE_RANGE_CLOEXEC`. +#[cfg(test)] +pub(crate) fn mark_listed_descriptors_on_spawn(command: &mut Command) { + // SAFETY: the hook makes only async-signal-safe system calls and allocates nothing, as code + // that runs between fork and exec must. + unsafe { command.pre_exec(mark_listed_descriptors_close_on_exec) }; +} + +/// Clears the close-on-exec flag of `descriptor`, as a descriptor that the caller's parent or +/// native code left inheritable lacks it. +#[cfg(test)] +pub(crate) fn clear_close_on_exec(descriptor: impl AsFd) -> io::Result<()> { + fcntl(descriptor, FcntlArg::F_SETFD(FdFlag::empty()))?; + Ok(()) +} + +/// Marks every descriptor above standard error close-on-exec. +/// +/// Rust opens its own descriptors close-on-exec, but a descriptor that the caller inherited or +/// that native code opened may lack the flag, and a detached VM would hold it until it stops: a +/// pipe writer it holds, for example, keeps the reader from seeing end-of-file. Marking rather +/// than closing keeps the descriptor through which `Command::spawn` reports a failed exec. +fn mark_descriptors_close_on_exec() -> io::Result<()> { + let first: libc::c_uint = 3; + // SAFETY: close_range takes two descriptor bounds and flags and changes no memory. + let marked = unsafe { + libc::syscall( + libc::SYS_close_range, + first, + libc::c_uint::MAX, + libc::CLOSE_RANGE_CLOEXEC, + ) + }; + if marked == 0 { + return Ok(()); + } + // Linux before 5.11 lacks CLOSE_RANGE_CLOEXEC, and seccomp policies may reject close_range. + mark_listed_descriptors_close_on_exec() +} + +/// Marks every descriptor above standard error that `/proc/self/fd` lists close-on-exec. +/// +/// It runs only in a `pre_exec` hook, between fork and exec, where nothing may allocate, so it +/// reads the directory with getdents64 into a stack buffer rather than through `nix`'s `Dir`, +/// whose `fdopendir` allocates. +fn mark_listed_descriptors_close_on_exec() -> io::Result<()> { + let directory = open( + c"/proc/self/fd", + OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC, + Mode::empty(), + )?; + mark_directory_entries(&directory) +} + +/// Marks the descriptors enumerated by the open `/proc/self/fd` `directory` close-on-exec. +fn mark_directory_entries(directory: &OwnedFd) -> io::Result<()> { + #[repr(C, align(8))] + struct Records([u8; 4096]); + + let malformed = || io::Error::from(Errno::EIO); + let mut buffer = Records([0; 4096]); + loop { + // SAFETY: the kernel writes at most the buffer's length into it. + let read = unsafe { + libc::syscall( + libc::SYS_getdents64, + directory.as_raw_fd(), + buffer.0.as_mut_ptr(), + buffer.0.len(), + ) + }; + let Ok(read) = usize::try_from(read) else { + return Err(io::Error::last_os_error()); + }; + if read == 0 { + return Ok(()); + } + let mut records = buffer.0.get(..read).ok_or_else(malformed)?; + while !records.is_empty() { + // A linux_dirent64 holds d_ino (8 bytes), d_off (8), d_reclen (2), d_type (1), and + // the NUL-terminated name. + let length = match records.get(16..18) { + Some(&[low, high]) => usize::from(u16::from_ne_bytes([low, high])), + _ => return Err(malformed()), + }; + let (record, rest) = records.split_at_checked(length).ok_or_else(malformed)?; + let name = record.get(19..).ok_or_else(malformed)?; + if let Some(descriptor) = descriptor_number(name) + && descriptor > 2 + && descriptor != directory.as_raw_fd() + { + // SAFETY: the listing shows the descriptor open, and between fork and exec only + // this thread runs, which closes no descriptor while the borrow lasts. + let descriptor = unsafe { BorrowedFd::borrow_raw(descriptor) }; + // F_SETFD changes only the flags of the descriptor, so a failure leaves it as is. + let _ = fcntl(descriptor, FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC)); + } + records = rest; + } + } +} + +/// Parses a `/proc/self/fd` entry name, which ends at its NUL terminator. +fn descriptor_number(name: &[u8]) -> Option { + let digits = name.split(|&byte| byte == 0).next()?; + if digits.is_empty() { + return None; + } + digits.iter().try_fold(0, |value: RawFd, &byte| { + let digit = byte.checked_sub(b'0').filter(|digit| *digit < 10)?; + value.checked_mul(10)?.checked_add(RawFd::from(digit)) + }) +} diff --git a/nvx-rs/src/host/sys/socket.rs b/nvx-rs/src/host/sys/socket.rs new file mode 100644 index 000000000..5d34b510a --- /dev/null +++ b/nvx-rs/src/host/sys/socket.rs @@ -0,0 +1,41 @@ +//! Unix stream connections to OpenVMM's control socket. + +use std::io; +use std::os::fd::AsRawFd; +use std::os::unix::net::UnixStream; + +use nix::sys::socket::sockopt::PeerCredentials; +use nix::sys::socket::{AddressFamily, SockFlag, SockType, UnixAddr, connect, getsockopt, socket}; + +use super::Errno; + +/// Connects to the Unix socket at `path` without waiting in a full listen backlog, and returns +/// the connected stream in blocking mode. +/// +/// A full backlog fails with [`io::ErrorKind::WouldBlock`], and a path that does not fit a Unix +/// socket address with [`io::ErrorKind::InvalidInput`]. +pub(crate) fn connect_nonblocking(path: &str) -> io::Result { + let address = UnixAddr::new(path).map_err(|error| match error { + Errno::ENAMETOOLONG => io::Error::new( + io::ErrorKind::InvalidInput, + "control socket path is too long", + ), + error => error.into(), + })?; + let socket = socket( + AddressFamily::Unix, + SockType::Stream, + SockFlag::SOCK_CLOEXEC | SockFlag::SOCK_NONBLOCK, + None, + )?; + connect(socket.as_raw_fd(), &address)?; + let stream = UnixStream::from(socket); + stream.set_nonblocking(false)?; + Ok(stream) +} + +/// Returns the process ID of the peer credentials of a connected Unix stream. +pub(crate) fn peer_pid(stream: &UnixStream) -> io::Result { + let credentials = getsockopt(stream, PeerCredentials)?; + u32::try_from(credentials.pid()).map_err(io::Error::other) +} diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index d4180de83..58affaf43 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -614,18 +614,12 @@ trait Pending { #[cfg(unix)] impl Pending for std::os::unix::net::UnixStream { fn pending(&mut self) -> io::Result { - use std::os::fd::AsRawFd; + use std::os::fd::AsFd; - let mut descriptor = libc::pollfd { - fd: self.as_raw_fd(), - events: libc::POLLIN, - revents: 0, - }; - // SAFETY: the descriptor array has one valid entry, and a zero timeout only polls. - match unsafe { libc::poll(&mut descriptor, 1, 0) } { - -1 => Err(io::Error::last_os_error()), - ready => Ok(ready > 0), - } + use nix::poll::{PollFd, PollFlags, PollTimeout, poll}; + + let mut descriptors = [PollFd::new(self.as_fd(), PollFlags::POLLIN)]; + Ok(poll(&mut descriptors, PollTimeout::ZERO)? > 0) } }