From 6184d5c4534c195a3c7cd1e85227b311d84adc2b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Wed, 14 Oct 2026 12:33:11 -0300 Subject: [PATCH 1/4] nvx-rs: document every private item Document the private fields, constants, helpers, and platform modules that predate the guest ports. The rustdoc records ownership, error behavior, wire contracts, platform invariants, and the reasons for compatibility helpers instead of merely restating item names. Enable Clippy's missing_docs_in_private_items lint for the whole crate so future public and private code must provide the same detail on every target. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/Cargo.toml | 3 +++ nvx-rs/build.rs | 13 +++++++++ nvx-rs/src/error.rs | 3 +++ nvx-rs/src/host/artifacts.rs | 2 ++ nvx-rs/src/host/filesystem.rs | 17 ++++++++++++ nvx-rs/src/host/instance.rs | 11 ++++++++ nvx-rs/src/host/platform/linux.rs | 7 +++++ nvx-rs/src/host/platform/mod.rs | 2 ++ nvx-rs/src/host/platform/windows.rs | 19 ++++++++++++- nvx-rs/src/host/process.rs | 4 +++ nvx-rs/src/host/session.rs | 10 +++++++ nvx-rs/src/net/cidr.rs | 6 +++++ nvx-rs/src/protocol/features.rs | 1 + nvx-rs/src/protocol/mod.rs | 2 ++ nvx-rs/src/testing/fake_openvmm.rs | 41 +++++++++++++++++++++++++++++ 15 files changed, 140 insertions(+), 1 deletion(-) diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index ad9f47527..384752bde 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -68,3 +68,6 @@ doc = false missing_docs = "warn" unsafe_op_in_unsafe_fn = "deny" unreachable_pub = "warn" + +[lints.clippy] +missing_docs_in_private_items = "warn" diff --git a/nvx-rs/build.rs b/nvx-rs/build.rs index 9345ca71e..15036cf03 100644 --- a/nvx-rs/build.rs +++ b/nvx-rs/build.rs @@ -27,6 +27,7 @@ fn main() { mod contract; #[cfg(feature = "bundled")] +/// Stages and verifies the release bundle selected for the build. mod bundle { use std::collections::HashMap; use std::env; @@ -38,13 +39,20 @@ mod bundle { use serde_json::Value; use sha2::{Digest, Sha256}; + /// Environment variable whose presence suppresses bundle staging. const SKIP: &str = "NVX_BUNDLE"; + /// Environment variable that selects an existing extracted release. const SOURCE_DIR: &str = "NVX_BUNDLE_DIR"; + /// Environment variable that selects the KVM or MSHV Linux package. const HYPERVISOR: &str = "NVX_BUNDLE_HYPERVISOR"; + /// Name of the release's source and compatibility manifest. const MANIFEST: &str = "SOURCE-MANIFEST.json"; + /// Name of the release's SHA-256 checksum list. const CHECKSUMS: &str = "SHA256SUMS"; + /// Guest artifacts copied into every bundle. const GUEST: [&str; 2] = ["guest/vmlinux", "guest/initramfs.cpio.gz"]; + /// Resolves the requested release, verifies it, and exposes the staged path to Cargo. pub(crate) fn run() { println!("cargo::rerun-if-changed=artifacts.json"); for name in [SKIP, SOURCE_DIR, HYPERVISOR] { @@ -282,10 +290,12 @@ mod bundle { .is_ok_and(|output| output.status.success() && output.stdout.starts_with(b"bsdtar")) } + /// Runs `command` and reports whether it exited successfully. fn succeeds(command: &mut Command) -> bool { command.output().is_ok_and(|output| output.status.success()) } + /// Runs `command`, or terminates the build with its stderr on failure. fn execute(command: &mut Command) { let output = command .output() @@ -299,6 +309,7 @@ mod bundle { } } + /// Parses the checksum file at `path` into release path-to-digest entries. fn read_checksums(path: &Path) -> HashMap { let text = fs::read_to_string(path) .unwrap_or_else(|error| fail(format!("cannot read {}: {error}", path.display()))); @@ -314,6 +325,7 @@ mod bundle { .collect() } + /// Returns the lowercase SHA-256 digest of the file at `path`. fn sha256(path: &Path) -> String { let file = File::open(path) .unwrap_or_else(|error| fail(format!("cannot open {}: {error}", path.display()))); @@ -336,6 +348,7 @@ mod bundle { .collect() } + /// Stops the build with a bundle-specific `message`. fn fail(message: String) -> ! { panic!("nvx bundled artifacts: {message}"); } diff --git a/nvx-rs/src/error.rs b/nvx-rs/src/error.rs index 166e78bba..db12019ff 100644 --- a/nvx-rs/src/error.rs +++ b/nvx-rs/src/error.rs @@ -57,8 +57,11 @@ impl fmt::Display for ErrorKind { /// errors onto their own classifications by [`kind`](Self::kind) alone. #[derive(Debug)] pub struct Error { + /// Stable classification that callers use when mapping the failure. kind: ErrorKind, + /// Complete, user-facing description without a trailing period. message: String, + /// Underlying implementation error, when retaining it aids diagnostics. source: Option>, } diff --git a/nvx-rs/src/host/artifacts.rs b/nvx-rs/src/host/artifacts.rs index 384d42258..20a9fe1a0 100644 --- a/nvx-rs/src/host/artifacts.rs +++ b/nvx-rs/src/host/artifacts.rs @@ -186,6 +186,7 @@ impl Artifacts { pub const BESIDE_EXE_DIR: &'static str = "nvx"; } +/// Implements artifact discovery with injectable environment and bundle sources for tests. fn discover_with( variable: impl Fn(&str) -> Option, exe_dir: Option<&Path>, @@ -251,6 +252,7 @@ pub fn absolute(path: &Path) -> Result { }) } +/// Reads and parses the JSON file at `path`, naming it with `description` in errors. fn read_json(path: &Path, description: &str) -> Result { let text = fs::read_to_string(path).map_err(|error| { Error::unavailable(format!("cannot read {description} {}", path.display())) diff --git a/nvx-rs/src/host/filesystem.rs b/nvx-rs/src/host/filesystem.rs index 553c33016..a0e59cefa 100644 --- a/nvx-rs/src/host/filesystem.rs +++ b/nvx-rs/src/host/filesystem.rs @@ -39,9 +39,11 @@ pub const MAX_CHILDREN: usize = 256; /// Largest number of hidden, allowed, or writable paths that OpenVMM accepts in one exported /// directory, and their largest combined length. const MAX_POLICY_PATHS: usize = 128; +/// Largest combined UTF-8 byte length of one exported directory's policy paths. const MAX_POLICY_BYTES: usize = 16 * 1024; /// Largest length of one such path, and of all of them together. const MAX_POLICY_PATH_BYTES: usize = 4096; +/// Largest combined UTF-8 byte length of all policies in one aggregate export. const MAX_AGGREGATE_POLICY_BYTES: usize = 128 * 1024; /// Longest guest path, without the terminating NUL. const MAX_GUEST_PATH: usize = 4095; @@ -127,6 +129,7 @@ impl Bind { } } +/// Joins an aggregate child index and child-relative source into a guest export path. fn guest_source(child: usize, source: &str) -> String { if source.is_empty() { child.to_string() @@ -758,16 +761,19 @@ fn check_policy_paths(root: &Path, paths: &[PathBuf], action: &str) -> Result bool { use std::os::windows::fs::MetadataExt; metadata.file_attributes() & 0x400 != 0 } #[cfg(not(windows))] +/// Reports no reparse points on hosts whose metadata has no such Windows attribute. fn is_reparse_point(_metadata: &fs::Metadata) -> bool { false } +/// Returns the stable device-and-file identity of `path` for rename detection. fn identity(path: &Path) -> Result { platform::file_identity(path) .map(|(device, index)| FileIdentity { device, index }) @@ -786,14 +792,21 @@ fn relative_text(root: &Path, path: &Path) -> Option { Some(parts.join("/")) } +/// Canonicalized, validated form of one requested host mapping. struct Mapped { + /// Host path exactly as the caller supplied it, for diagnostics. given: PathBuf, + /// Link-resolved host path used to construct the export. canonical: PathBuf, + /// Absolute guest destination of the bind mount. target: String, + /// Whether the guest must mount this path read-only. read_only: bool, + /// Whether the mapped host object is a directory rather than a regular file. directory: bool, } +/// Returns whether `path` is `directory` or one of its descendants. fn within(path: &str, directory: &str) -> bool { path == directory || path @@ -801,6 +814,7 @@ fn within(path: &str, directory: &str) -> bool { .is_some_and(|rest| rest.starts_with('/')) } +/// Counts the nonempty components of an absolute guest path. fn depth(path: &str) -> usize { path.split('/').filter(|part| !part.is_empty()).count() } @@ -847,6 +861,7 @@ fn canonicalize_lenient(path: &Path) -> std::io::Result<(PathBuf, bool)> { } #[cfg(windows)] +/// Removes a local Windows verbatim prefix and rejects UNC paths. fn strip_verbatim(path: &Path) -> Option { let text = path.to_str()?; match text.strip_prefix(r"\\?\") { @@ -858,10 +873,12 @@ fn strip_verbatim(path: &Path) -> Option { } #[cfg(not(windows))] +/// Returns the unchanged path on hosts without Windows verbatim prefixes. fn strip_verbatim(path: &Path) -> Option { Some(path.to_path_buf()) } +/// Returns a lossily decoded path for user-facing diagnostics. fn display_path(path: &Path) -> String { path.to_string_lossy().into_owned() } diff --git a/nvx-rs/src/host/instance.rs b/nvx-rs/src/host/instance.rs index 65fc8318b..115350e2b 100644 --- a/nvx-rs/src/host/instance.rs +++ b/nvx-rs/src/host/instance.rs @@ -127,6 +127,7 @@ pub struct LaunchRecord { /// Held advisory lock. Dropping it releases the lock. #[derive(Debug)] pub struct LockGuard { + /// Open locked file whose lifetime holds the operating-system lock. _file: File, } @@ -150,10 +151,12 @@ pub fn create_private_dir(path: &Path) -> io::Result<()> { platform::create_private_dir(path) } +/// Builds an error mapper that preserves an I/O source under `context`. fn io_error(context: String) -> impl FnOnce(io::Error) -> Error { move |error| Error::io(context).with_source(error) } +/// Returns create-and-write options that request owner-only permissions on Unix. fn private_options() -> OpenOptions { let mut options = OpenOptions::new(); options.write(true).create(true); @@ -278,7 +281,9 @@ pub struct Stopped { /// One VM instance in a directory of its own. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Instance { + /// Private directory containing runtime state, logs, and the control endpoint. dir: PathBuf, + /// Sandbox name used in diagnostics. name: String, } @@ -495,6 +500,7 @@ impl Instance { } } + /// Describes why this instance did not start and points the caller to its log. fn start_failure(&self, kind: ErrorKind, reason: &str) -> Error { Error::new( kind, @@ -679,6 +685,7 @@ impl Instance { }) } + /// Maps a control-session failure onto an instance-facing error and message. fn session_error(&self, error: SessionError, control_timeout: Duration) -> Error { match error { SessionError::ProcessExited => { @@ -830,9 +837,13 @@ pub enum WorkloadOutcome { /// A workload that [`Instance::exec`] started. pub struct Execution { + /// Exclusive control session that carries this workload's traffic. session: ControlSession, + /// Identifier correlating requests, output, cancellation, and the final outcome. request_id: u64, + /// Workload response deadline, absent for a workload without a timeout. deadline: Option, + /// Maximum time allowed for control traffic after cancellation or failure. control_timeout: Duration, } diff --git a/nvx-rs/src/host/platform/linux.rs b/nvx-rs/src/host/platform/linux.rs index c8842c306..1f2063cf4 100644 --- a/nvx-rs/src/host/platform/linux.rs +++ b/nvx-rs/src/host/platform/linux.rs @@ -181,6 +181,7 @@ pub(crate) fn mark_listed_descriptors_close_on_exec() -> io::Result<()> { 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]); @@ -329,6 +330,7 @@ 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 { @@ -368,6 +370,7 @@ fn connect_nonblocking(path: &str) -> io::Result { 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, @@ -392,14 +395,18 @@ fn peer_pid(stream: &UnixStream) -> io::Result { 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. stream: UnixStream, } +/// Clamps a present socket timeout to the nonzero duration accepted by `std`. fn socket_timeout(timeout: Option) -> Option { timeout.map(|timeout| timeout.max(Duration::from_millis(1))) } +/// Normalizes socket would-block and timeout errors to [`io::ErrorKind::TimedOut`]. fn map_timeout(error: io::Error) -> io::Error { if matches!( error.kind(), diff --git a/nvx-rs/src/host/platform/mod.rs b/nvx-rs/src/host/platform/mod.rs index fd4813215..c5fae8ec0 100644 --- a/nvx-rs/src/host/platform/mod.rs +++ b/nvx-rs/src/host/platform/mod.rs @@ -10,10 +10,12 @@ use crate::error::{Error, Result}; use crate::hypervisor::Hypervisor; #[cfg(target_os = "linux")] +/// Linux implementations of host process, socket, and hypervisor primitives. mod linux; #[cfg(not(any(target_os = "linux", windows)))] mod other; #[cfg(windows)] +/// Windows implementations of host process, pipe, and hypervisor primitives. mod windows; #[cfg(target_os = "linux")] diff --git a/nvx-rs/src/host/platform/windows.rs b/nvx-rs/src/host/platform/windows.rs index e528c6b9d..0c9596c42 100644 --- a/nvx-rs/src/host/platform/windows.rs +++ b/nvx-rs/src/host/platform/windows.rs @@ -41,15 +41,18 @@ use crate::hypervisor::Hypervisor; /// Whether this host can run NVX microVMs. pub const SUPPORTED: bool = true; +/// Encodes `value` as a NUL-terminated UTF-16 string for Win32. fn wide(value: impl AsRef) -> Vec { value.as_ref().encode_wide().chain([0]).collect() } +/// Returns the calling thread's Win32 last-error code. fn last_error() -> u32 { // SAFETY: GetLastError has no preconditions. unsafe { GetLastError() } } +/// Takes ownership of a newly returned valid Win32 `handle`. fn owned(handle: HANDLE) -> Option { if handle.is_null() || handle == INVALID_HANDLE_VALUE { None @@ -59,15 +62,18 @@ fn owned(handle: HANDLE) -> Option { } } +/// Borrows an owned handle in the form expected by `windows-sys`. fn raw(handle: &OwnedHandle) -> HANDLE { handle.as_raw_handle() as HANDLE } +/// Opens process `pid` with `access`, returning `None` on any Win32 failure. fn open_process(pid: u32, access: u32) -> Option { // SAFETY: OpenProcess has no memory-safety preconditions. owned(unsafe { OpenProcess(access, 0, pid) }) } +/// Returns the process creation `FILETIME`, used with the PID as a stable identity. fn creation_time(process: &OwnedHandle) -> io::Result { let mut created = FILETIME { dwLowDateTime: 0, @@ -93,6 +99,7 @@ fn creation_time(process: &OwnedHandle) -> io::Result { } } +/// Polls `process` without waiting and reports whether it has exited. fn has_exited(process: &OwnedHandle) -> io::Result { // SAFETY: the handle has synchronize access; a zero timeout only polls. match unsafe { WaitForSingleObject(raw(process), 0) } { @@ -160,11 +167,14 @@ pub(crate) fn kill_process(pid: u32, start_time: u64) -> io::Result<()> { /// An OpenVMM process launched by this caller, held through its process handle. pub(crate) struct LaunchedProcess { + /// Process handle retained for termination and waiting. process: OwnedHandle, + /// Numeric process identifier reported to instance state. pid: u32, } impl LaunchedProcess { + /// Returns the numeric process identifier. pub(crate) fn id(&self) -> u32 { self.pid } @@ -181,11 +191,12 @@ impl LaunchedProcess { /// Owns an initialized process-thread attribute list. struct AttributeList { - // Backing storage, kept `usize`-aligned for the opaque list structure. + /// Backing storage, kept `usize`-aligned for the opaque list structure. buffer: Vec, } impl AttributeList { + /// Allocates and initializes a list with room for `count` attributes. fn new(count: u32) -> io::Result { let mut size = 0; // SAFETY: a null list with a size pointer only queries the required size. @@ -201,6 +212,7 @@ impl AttributeList { Ok(Self { buffer }) } + /// Returns the mutable opaque-list pointer used by Win32 process APIs. fn as_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { self.buffer.as_mut_ptr().cast() } @@ -320,6 +332,7 @@ fn command_line(program: &OsStr, arguments: &[OsString]) -> io::Result> Ok(line) } +/// Appends one argument using the Microsoft C runtime's backslash-and-quote rules. fn append_argument(line: &mut Vec, argument: &OsStr, always_quote: bool) -> io::Result<()> { let units: Vec = argument.encode_wide().collect(); if units.contains(&0) { @@ -550,8 +563,11 @@ pub(crate) fn endpoint_server_pid(endpoint: &str) -> io::Result> { } } +/// Overlapped byte transport over OpenVMM's named-pipe control endpoint. struct PipeTransport { + /// Connected named-pipe client handle. pipe: OwnedHandle, + /// Manual event reused to wait for each overlapped operation. event: OwnedHandle, } @@ -597,6 +613,7 @@ impl PipeTransport { } } +/// Maps a completed pipe operation's Win32 status to Rust I/O semantics. fn completion_error(error: u32, transferred: usize, timed_out: bool) -> io::Result { match error { ERROR_OPERATION_ABORTED if timed_out => Err(io::ErrorKind::TimedOut.into()), diff --git a/nvx-rs/src/host/process.rs b/nvx-rs/src/host/process.rs index 1b8cc5086..d553a473e 100644 --- a/nvx-rs/src/host/process.rs +++ b/nvx-rs/src/host/process.rs @@ -12,7 +12,9 @@ use std::time::{Duration, Instant}; use super::platform; use crate::protocol::CAPABILITY_LEN; +/// Longest wait for a terminated OpenVMM process to exit. const KILL_TIMEOUT: Duration = Duration::from_secs(10); +/// Interval at which process shutdown and identity are polled. const POLL_INTERVAL: Duration = Duration::from_millis(25); /// An OpenVMM process this caller launched and has not released yet. @@ -20,8 +22,10 @@ const POLL_INTERVAL: Duration = Duration::from_millis(25); /// Release it with [`detach_child`], or terminate it with [`kill_child`]. pub struct Launched { #[cfg(not(windows))] + /// Child process retained until it is detached or terminated. child: Child, #[cfg(windows)] + /// Owned Windows process handle and identifier. process: platform::LaunchedProcess, } diff --git a/nvx-rs/src/host/session.rs b/nvx-rs/src/host/session.rs index 132258306..8c087c2f1 100644 --- a/nvx-rs/src/host/session.rs +++ b/nvx-rs/src/host/session.rs @@ -132,11 +132,17 @@ pub enum ExecEvent { /// Received bytes are buffered until a whole record is available, so a read that times out /// loses nothing and callers can poll with short deadlines. pub struct ControlSession { + /// Platform transport connected to OpenVMM's control endpoint. transport: Box, + /// Bytes received beyond the last complete outer record. received: Vec, + /// Random instance identity assigned by the broker during attachment. instance_id: [u8; 16], + /// Broker connection epoch assigned during attachment. epoch: u64, + /// Sequence number of the next host-to-guest outer record. send_sequence: u64, + /// Sequence number expected on the next guest-to-host outer record. receive_sequence: u64, } @@ -325,6 +331,7 @@ impl ControlSession { Ok(()) } + /// Encodes and sends one application frame in the next sequenced outer record. fn send_app( &mut self, kind: u8, @@ -345,6 +352,7 @@ impl ControlSession { Ok(()) } + /// Reads and validates the next application frame, advancing the receive sequence. fn read_app( &mut self, deadline: Option, @@ -372,6 +380,7 @@ impl ControlSession { )) } + /// Buffers transport bytes until one complete outer record can be returned. fn read_outer( &mut self, deadline: Option, @@ -423,6 +432,7 @@ fn remaining(deadline: Option) -> Result, SessionError } } +/// Generates a random nonzero request identifier. fn request_id() -> Result { loop { let mut bytes = [0u8; 8]; diff --git a/nvx-rs/src/net/cidr.rs b/nvx-rs/src/net/cidr.rs index 39177fdf4..bb7d7369d 100644 --- a/nvx-rs/src/net/cidr.rs +++ b/nvx-rs/src/net/cidr.rs @@ -8,7 +8,9 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; /// Networks order by address, IPv4 before IPv6, and then by prefix length. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Cidr { + /// First address of the network, with every host bit clear. address: IpAddr, + /// Number of leading network bits, from zero through the address-family width. prefix: u8, } @@ -68,10 +70,12 @@ impl Cidr { self.contains(other) || other.contains(self) } + /// Returns the bit width of this network's address family. fn width(self) -> u8 { if self.is_ipv6() { 128 } else { 32 } } + /// Returns the network address as an integer, with IPv4 in the low 32 bits. fn bits(self) -> u128 { match self.address { IpAddr::V4(address) => u128::from(u32::from(address)), @@ -88,6 +92,7 @@ impl Cidr { Self { address, prefix } } + /// Splits this network into its equally sized low and high child networks. fn halves(self) -> [Self; 2] { let prefix = self.prefix + 1; [ @@ -133,6 +138,7 @@ impl Cidr { && high.subtract_sorted(&excluded[split..], limit, remainder) } + /// Clears the host bits and returns the resulting network address. fn network(self) -> IpAddr { self.with_bits(mask(self.bits(), self.prefix, self.width()), self.prefix) .address diff --git a/nvx-rs/src/protocol/features.rs b/nvx-rs/src/protocol/features.rs index 4b8e90925..900d6ab31 100644 --- a/nvx-rs/src/protocol/features.rs +++ b/nvx-rs/src/protocol/features.rs @@ -46,6 +46,7 @@ impl GuestFeatures { | Self::HOST_MAPPING_TABLE.0, ); + /// Human-readable names of every required feature, used in compatibility errors. pub(crate) const NAMES: [(Self, &'static str); 6] = [ (Self::CANCEL, "cancellation"), (Self::WORKLOAD_ACCOUNT, "workload accounts"), diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index 3fa5cde86..96118f220 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -79,10 +79,12 @@ impl fmt::Display for ProtocolError { impl std::error::Error for ProtocolError {} +/// Constructs a protocol error whose message describes the violated wire contract. fn violation(message: impl Into) -> ProtocolError { ProtocolError(message.into()) } +/// Copies an exactly `N`-byte wire field into an array for integer decoding. fn array(bytes: &[u8]) -> [u8; N] { let mut output = [0u8; N]; output.copy_from_slice(bytes); diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index 2dcd49e9c..d4180de83 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -66,6 +66,7 @@ use crate::protocol::{ encode_outer, }; +/// Largest workload-output frame emitted by the fake guest. const OUTPUT_CHUNK_BYTES: usize = 32 * 1024; /// Control features of the current guest: cancellation, workload accounts, workload @@ -138,22 +139,37 @@ fn atoi(value: &str) -> i32 { } } +/// Parsed OpenVMM-compatible command line and fake-specific test controls. struct Options { /// Name of the executable, which prefixes the fake's diagnostics. program: String, + /// Control endpoint at which the fake accepts host sessions. endpoint: String, + /// Path to the synthetic OpenVMM outcome report. report: PathBuf, + /// Backend name recorded in the synthetic report. hypervisor: String, + /// Path that receives the parsed command-line dump. args_dump: PathBuf, + /// Path that receives the mounted-host-path dump. maps_dump: PathBuf, + /// Status with which to stop immediately after launch, when requested. exit_on_start: Option, + /// Artificial delay before the fake guest becomes ready. boot_delay: Duration, + /// Artificial delay before the fake OpenVMM crashes, when requested. crash_after: Option, + /// Whether stop requests are deliberately left unanswered. ignore_stop: bool, + /// Whether cancellation requests are deliberately ignored. ignore_cancel: bool, + /// Whether the guest omits feature negotiation like an older image. legacy_guest: bool, + /// Whether host-mapping requests are deliberately refused. refuse_maps: bool, + /// UID whose account and environment the fake guest models. workload_uid: u32, + /// Feature mask returned during guest feature negotiation. guest_features: GuestFeatures, /// Host directories of the aggregate export, in child order. children: Vec, @@ -229,6 +245,7 @@ fn program_name() -> String { .unwrap_or_else(|| "nvx-fake-openvmm".to_owned()) } +/// Parses the OpenVMM command line plus fake controls, rejecting unsupported combinations. fn parse(program: &str, arguments: &[String]) -> Result { const VALUED: [&str; 24] = [ "--mount-aggregate", @@ -454,6 +471,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { }) } +/// Reads exactly one nonzero control capability from standard input. fn read_capability() -> Result<[u8; CAPABILITY_LEN], String> { let mut stdin = io::stdin().lock(); let mut capability = [0u8; CAPABILITY_LEN]; @@ -472,6 +490,7 @@ fn read_capability() -> Result<[u8; CAPABILITY_LEN], String> { /// Guest state that lives in memory until the VM stops. struct Guest { + /// Values exposed by the fake's scripted `get` and `set` commands. values: BTreeMap, /// Environment that workloads inherit unless they replace it. default_environment: Environment, @@ -484,6 +503,7 @@ struct Guest { } impl Guest { + /// Constructs the initial guest environment and filesystem model from `options`. fn new(options: &Options) -> Self { // The Alpine image resolves the default identity to `nobody`; for any other host-selected // identity, the managed init creates the `nvx` account homed in /tmp. @@ -567,15 +587,21 @@ impl Guest { } } +/// Whether the fake should accept another control session or terminate. enum Flow { + /// Keep the fake VM running after the current session. Continue, + /// Stop the fake VM after a successful stop request. Exit, } /// Outcome of control traffic observed while a workload runs. enum Interrupt { + /// No control request is waiting. None, + /// The workload's matching cancellation request arrived. Cancel, + /// The host disconnected and abandoned the workload. Disconnected, } @@ -633,6 +659,7 @@ fn control_record(record_type: u8, instance: &[u8; 16], epoch: u64) -> Vec { encode_outer(record_type, instance, epoch, 0, &[]).expect("an empty payload fits a record") } +/// Decoded outer header paired with its owned payload. type Record = (OuterHeader, Vec); /// Reads one outer record, returning `None` at end of stream. @@ -651,16 +678,24 @@ fn read_record(stream: &mut S) -> io::Result> { /// One authenticated host session over a connected stream. struct Session<'a, S> { + /// Connected transport to the host. stream: &'a mut S, + /// Executable name used to prefix diagnostics. program: &'a str, + /// Random identity assigned to this attachment. instance: [u8; 16], + /// Monotonic attachment epoch. epoch: u64, + /// Sequence number of the next guest record. guest_sequence: u64, + /// Sequence number expected from the next host record. host_sequence: u64, + /// Whether matching cancellation requests are ignored for fault injection. ignore_cancel: bool, } impl Session<'_, S> { + /// Encodes and sends one sequenced guest application frame. fn send(&mut self, kind: u8, request_id: u64, status: i32, payload: &[u8]) -> io::Result<()> { let frame = encode_app(kind, request_id, status, payload); let record = encode_outer( @@ -692,6 +727,7 @@ impl Session<'_, S> { Ok(Some((frame.kind, frame.request_id, frame.payload.to_vec()))) } + /// Serves requests until the host disconnects or asks the VM to stop. fn run(&mut self, options: &Options, guest: &mut Guest) -> io::Result { loop { let Some((kind, request_id, payload)) = self.request()? else { @@ -749,6 +785,7 @@ impl Session<'_, S> { .map(|outcome| outcome.is_some()) } + /// Interprets one fake-workload script and emits the same records a guest agent would. fn exec_script( &mut self, request_id: u64, @@ -926,6 +963,7 @@ impl Session<'_, S> { self.exit(request_id, status, ExitCategory::Exit) } + /// Sends one frame and adapts success to the optional value expected by script execution. fn send_some( &mut self, kind: u8, @@ -1009,6 +1047,7 @@ fn serve_client( } } +/// Writes the successful synthetic OpenVMM outcome report if the destination is new. fn write_report(options: &Options) { let report = serde_json::json!({ "schema_version": 1, @@ -1027,6 +1066,7 @@ fn write_report(options: &Options) { } #[cfg(unix)] +/// Serves the fake control endpoint as a Unix-domain socket until a stop request. fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener; @@ -1047,6 +1087,7 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> } #[cfg(windows)] +/// Serves the fake control endpoint as a Windows named pipe until a stop request. fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { use std::fs::File; use std::os::windows::ffi::OsStrExt; From e065c2e31b8fbe39b0a84402ebbdceb9eada2617 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Wed, 14 Oct 2026 12:34:21 -0300 Subject: [PATCH 2/4] nvx-rs: port the standard small guest tools Add the Linux guest feature and size-optimized musl profile, then port nvx-console-pending, nvx-mmio-write, nvx-port-io, and nvx-reseed with their C command lines, statuses, output, retry rules, and cleanup diagnostics. Golden unit and integration tests pin those contracts. Make every system call through nix 0.31.3, the latest MIT-licensed release compatible with Rust 1.89, with default features disabled, and confine its unsafe ioctl, mmap, signal, and x86 operations to a documented safe guest::sys interface. Read random samples with the getrandom crate. The guest code uses no libc beyond the FIONREAD number that nix takes from it. OwnedFd-backed descriptors, nix's Flock, and owned mappings provide Drop cleanup plus consuming close and unmap operations where the C programs report failures; raw descriptors never leave the system layer. Have the tools share one typed error, whose kind selects the exit status from a shared set, and deny unsafe code outside the system layer. Describe errno values with nix's text, a few of which differ from the C library's, and parse numeric arguments as decimal or 0x hexadecimal with the standard library. The C programs' strtoull also accepted whitespace, signs, and octal, which no caller passes; those are now usage errors. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/Cargo.lock | 35 ++ nvx-rs/Cargo.toml | 61 +++ nvx-rs/README.md | 34 +- nvx-rs/src/bin/nvx-console-pending.rs | 13 + nvx-rs/src/bin/nvx-mmio-write.rs | 13 + nvx-rs/src/bin/nvx-port-io.rs | 13 + nvx-rs/src/bin/nvx-reseed.rs | 13 + nvx-rs/src/guest/console_pending.rs | 173 ++++++++ nvx-rs/src/guest/mmio_write.rs | 188 +++++++++ nvx-rs/src/guest/mod.rs | 34 ++ nvx-rs/src/guest/parse.rs | 82 ++++ nvx-rs/src/guest/port_io.rs | 566 ++++++++++++++++++++++++++ nvx-rs/src/guest/reseed.rs | 440 ++++++++++++++++++++ nvx-rs/src/guest/status.rs | 60 +++ nvx-rs/src/guest/sys/fd.rs | 103 +++++ nvx-rs/src/guest/sys/ioctl.rs | 93 +++++ nvx-rs/src/guest/sys/mapping.rs | 94 +++++ nvx-rs/src/guest/sys/mod.rs | 165 ++++++++ nvx-rs/src/guest/sys/process.rs | 80 ++++ nvx-rs/src/lib.rs | 6 + nvx-rs/tests/guest_tools.rs | 360 ++++++++++++++++ 21 files changed, 2624 insertions(+), 2 deletions(-) create mode 100644 nvx-rs/src/bin/nvx-console-pending.rs create mode 100644 nvx-rs/src/bin/nvx-mmio-write.rs create mode 100644 nvx-rs/src/bin/nvx-port-io.rs create mode 100644 nvx-rs/src/bin/nvx-reseed.rs create mode 100644 nvx-rs/src/guest/console_pending.rs create mode 100644 nvx-rs/src/guest/mmio_write.rs create mode 100644 nvx-rs/src/guest/mod.rs create mode 100644 nvx-rs/src/guest/parse.rs create mode 100644 nvx-rs/src/guest/port_io.rs create mode 100644 nvx-rs/src/guest/reseed.rs create mode 100644 nvx-rs/src/guest/status.rs create mode 100644 nvx-rs/src/guest/sys/fd.rs create mode 100644 nvx-rs/src/guest/sys/ioctl.rs create mode 100644 nvx-rs/src/guest/sys/mapping.rs create mode 100644 nvx-rs/src/guest/sys/mod.rs create mode 100644 nvx-rs/src/guest/sys/process.rs create mode 100644 nvx-rs/tests/guest_tools.rs diff --git a/nvx-rs/Cargo.lock b/nvx-rs/Cargo.lock index 007d48a36..46a1f11c5 100644 --- a/nvx-rs/Cargo.lock +++ b/nvx-rs/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[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" @@ -23,6 +29,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" @@ -113,12 +125,35 @@ 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.toml b/nvx-rs/Cargo.toml index 384752bde..4fc5b1e8f 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -29,6 +29,9 @@ bundled = ["host", "dep:sha2"] testing = ["dep:getrandom", "dep:serde", "dep:serde_json", "dep:libc", "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 +# them with `--no-default-features --features guest` and the `guest` profile; see README.md. +guest = ["dep:nix", "dep:getrandom"] [dependencies] getrandom = { version = "0.4", optional = true } @@ -38,6 +41,24 @@ serde_json = { version = "1.0.120", optional = true } [target.'cfg(unix)'.dependencies] libc = { version = "0.2.160", optional = true } +[target.'cfg(target_os = "linux")'.dependencies] +nix = { version = "0.31.3", default-features = false, optional = true, features = [ + "fs", + "ioctl", + "mman", + "mount", + "net", + "poll", + "process", + "sched", + "signal", + "socket", + "term", + "time", + "uio", + "user", +] } + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", optional = true, features = [ "Win32_Foundation", @@ -64,6 +85,46 @@ path = "src/bin/nvx-fake-openvmm.rs" required-features = ["testing"] doc = false +# The guest binaries. Their unit tests live in the library, so the binaries have no test harness. +[[bin]] +name = "nvx-console-pending" +path = "src/bin/nvx-console-pending.rs" +required-features = ["guest"] +doc = false +test = false + +[[bin]] +name = "nvx-mmio-write" +path = "src/bin/nvx-mmio-write.rs" +required-features = ["guest"] +doc = false +test = false + +[[bin]] +name = "nvx-port-io" +path = "src/bin/nvx-port-io.rs" +required-features = ["guest"] +doc = false +test = false + +[[bin]] +name = "nvx-reseed" +path = "src/bin/nvx-reseed.rs" +required-features = ["guest"] +doc = false +test = false + +# The guest binaries: small, static, and stripped. +[profile.guest] +inherits = "release" +opt-level = "s" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true +debug = false +incremental = false + [lints.rust] missing_docs = "warn" unsafe_op_in_unsafe_fn = "deny" diff --git a/nvx-rs/README.md b/nvx-rs/README.md index a09492384..a050041b9 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -28,7 +28,8 @@ nvx-rs/ │ ├── host/ feature `host` (default): artifacts, launch, network, filesystem, session, │ │ process, platform, VM instances, and outcome reports │ ├── testing/ feature `testing`: the fake OpenVMM -│ └── bin/ nvx-fake-openvmm +│ ├── guest/ feature `guest`: the programs that run in Linux guests +│ └── bin/ nvx-fake-openvmm and the guest binaries ├── build.rs bundled-release staging └── artifacts.json the NVX release that the `bundled` feature stages ``` @@ -46,6 +47,7 @@ nvx-rs/ | `nvx::host::instance` | `Instance`: a VM in a directory of its own. It starts OpenVMM with a fresh capability and waits until the guest is ready and has mounted the mapped paths, reconciles recorded state, recovers interrupted launches, runs workloads and pumps their output, and stops the VM with a forced fallback | | `nvx::host::session`, `process`, `platform`, `report` | The authenticated control session, OpenVMM processes, Linux and Windows primitives, and OpenVMM's outcome report | | `nvx::testing::fake_openvmm` | A fake OpenVMM that serves the control console and runs scripted workloads without a VM | +| `nvx::guest` | The programs that run in Linux guests; see [Guest binaries](#guest-binaries) | Lowering functions take the names by which messages refer to their inputs, such as `EgressRules::name` and `PathList::name`, so callers' messages name their own @@ -59,10 +61,12 @@ fields. | `bundled` | Stages a release at build time; see [Bundled artifacts](#bundled-artifacts) | | `testing` | The fake OpenVMM and the `nvx-fake-openvmm` binary | | `serde` | The serde form of `Hypervisor` | +| `guest` | The `guest` module and the guest binaries; see [Guest binaries](#guest-binaries) | Without default features, the crate has only `protocol`, `contract`, `net`, `Hypervisor`, and `Error`, and depends on nothing but `serde` when `serde` is -enabled; `aci_edge_sandboxes_model` uses it that way. +enabled and `nix` and `getrandom` when `guest` is; `aci_edge_sandboxes_model` +uses it that way. ## Example @@ -148,6 +152,32 @@ of `nvx::contract`. The build scripts of dependents receive the staged directory as `DEP_NVX_ARTIFACTS_DIR` and can ship it next to their executables as `nvx/`. +## Guest binaries + +The `guest` feature adds the programs that run in NVX's Linux guests: the +`nvx::guest` module and one binary per program. Guest images install these +executables as `/sbin/nvx-*`. + +| Binary | Program | +| --- | --- | +| `nvx-console-pending DEVICE` | Prints the number of bytes waiting to be read from a device, such as a console | +| `nvx-mmio-write ADDRESS VALUE` | Stores a 32-bit value at a physical address through `/dev/mem` (x86_64) | +| `nvx-port-io COMMAND ...` | Reads or writes an I/O port through `/dev/port`, or reads the VM generation ID | +| `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 | + +Each binary's command line, exit statuses, and byte output are compatibility +contracts; the [`nvx::guest`](src/guest/mod.rs) documentation describes each +program. Guests run them as static `x86_64-unknown-linux-musl` executables. On +Linux, with the musl target and a C compiler driver, `cc`, Cargo builds them +into `target/x86_64-unknown-linux-musl/guest/`: + +```bash +cd nvx-rs +rustup target add x86_64-unknown-linux-musl +cargo build --locked --profile guest --target x86_64-unknown-linux-musl \ + --no-default-features --features guest --bins +``` + ## Testing ```bash diff --git a/nvx-rs/src/bin/nvx-console-pending.rs b/nvx-rs/src/bin/nvx-console-pending.rs new file mode 100644 index 000000000..bfd552256 --- /dev/null +++ b/nvx-rs/src/bin/nvx-console-pending.rs @@ -0,0 +1,13 @@ +//! `nvx-console-pending DEVICE`; see `nvx::guest::console_pending`. + +#[cfg(target_os = "linux")] +fn main() -> std::process::ExitCode { + nvx::guest::console_pending::main() +} + +// The guest programs run 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/bin/nvx-mmio-write.rs b/nvx-rs/src/bin/nvx-mmio-write.rs new file mode 100644 index 000000000..122dcd54f --- /dev/null +++ b/nvx-rs/src/bin/nvx-mmio-write.rs @@ -0,0 +1,13 @@ +//! `nvx-mmio-write ADDRESS VALUE`; see `nvx::guest::mmio_write`. + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +fn main() -> std::process::ExitCode { + nvx::guest::mmio_write::main() +} + +// The program stores to the MMIO of NVX's x86_64 guests. Elsewhere this stub fails, so that every +// target that CI checks with `--all-features` still builds. +#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] +fn main() -> std::process::ExitCode { + std::process::ExitCode::FAILURE +} diff --git a/nvx-rs/src/bin/nvx-port-io.rs b/nvx-rs/src/bin/nvx-port-io.rs new file mode 100644 index 000000000..51bd7e295 --- /dev/null +++ b/nvx-rs/src/bin/nvx-port-io.rs @@ -0,0 +1,13 @@ +//! `nvx-port-io`; see `nvx::guest::port_io`. + +#[cfg(target_os = "linux")] +fn main() -> std::process::ExitCode { + nvx::guest::port_io::main() +} + +// The guest programs run 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/bin/nvx-reseed.rs b/nvx-rs/src/bin/nvx-reseed.rs new file mode 100644 index 000000000..971f39fb4 --- /dev/null +++ b/nvx-rs/src/bin/nvx-reseed.rs @@ -0,0 +1,13 @@ +//! `nvx-reseed`; see `nvx::guest::reseed`. + +#[cfg(target_os = "linux")] +fn main() -> std::process::ExitCode { + nvx::guest::reseed::main() +} + +// The guest programs run 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/console_pending.rs b/nvx-rs/src/guest/console_pending.rs new file mode 100644 index 000000000..34acde7e6 --- /dev/null +++ b/nvx-rs/src/guest/console_pending.rs @@ -0,0 +1,173 @@ +//! `nvx-console-pending DEVICE`: prints the number of bytes waiting to be read from a device, such +//! as a console, which the console-snapshot test reads from `/dev/hvc1`. + +use std::ffi::CString; +use std::io::{self, Write}; +use std::process::ExitCode; + +use super::status::{CommandError, ExitStatus}; +use super::sys::{self, Errno, Mode, OpenFlags}; + +/// Runs `nvx-console-pending` with the process's arguments. It exits with 0, 1 when an operation +/// fails, or 2 for a usage error. +pub fn main() -> ExitCode { + sys::restore_sigpipe(); + ExitStatus::from(run( + &sys::args(), + &mut sys::Stdout::new(), + &mut io::stderr(), + )) + .into() +} + +/// Opens the requested device, queries and closes it, and prints its pending byte count. +fn run(args: &[CString], out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CommandError> { + let [_, device] = args else { + sys::report(err, &[b"usage: nvx-console-pending DEVICE\n"]); + return Err(CommandError::Usage); + }; + let fail = |err: &mut dyn Write, operation: &[u8], errno: Errno| { + let device = device.as_bytes(); + sys::report( + err, + &[ + b"nvx-console-pending: ", + operation, + b" ", + device, + b": ", + errno.desc().as_bytes(), + b"\n", + ], + ); + }; + + let flags = + OpenFlags::O_RDONLY | OpenFlags::O_CLOEXEC | OpenFlags::O_NOCTTY | OpenFlags::O_NONBLOCK; + let descriptor = sys::open(device, flags, Mode::empty()).map_err(|error| { + fail(err, b"open", error); + CommandError::Operation + })?; + let pending = sys::console_pending(&descriptor).map_err(|error| { + fail(err, b"ioctl", error); + CommandError::Operation + })?; + descriptor.close().map_err(|error| { + fail(err, b"close", error); + CommandError::Operation + })?; + if let Err(errno) = sys::print(out, format!("{pending}\n").as_bytes()) { + let text = errno.desc().as_bytes(); + sys::report(err, &[b"nvx-console-pending: write: ", text, b"\n"]); + return Err(CommandError::Operation); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::OpenOptionsExt; + use std::path::Path; + + use super::*; + use crate::guest::sys::tests::Failing; + + fn console_pending(arguments: &[&[u8]]) -> (u8, Vec, Vec) { + let mut args = vec![CString::new("nvx-console-pending").unwrap()]; + args.extend( + arguments + .iter() + .map(|argument| CString::new(*argument).unwrap()), + ); + let (mut out, mut err) = (Vec::new(), Vec::new()); + let status = ExitStatus::from(run(&args, &mut out, &mut err)); + (status as u8, out, err) + } + + fn message(operation: &str, path: &Path, errno: Errno) -> Vec { + let mut message = format!("nvx-console-pending: {operation} ").into_bytes(); + message.extend_from_slice(path.as_os_str().as_bytes()); + message.extend_from_slice(b": "); + message.extend_from_slice(errno.desc().as_bytes()); + message.push(b'\n'); + message + } + + #[test] + fn usage_takes_exactly_one_device() { + let usage = b"usage: nvx-console-pending DEVICE\n".to_vec(); + for arguments in [&[][..], &[&b"a"[..], b"b"]] { + assert_eq!(console_pending(arguments), (2, Vec::new(), usage.clone())); + } + } + + #[test] + fn prints_the_bytes_waiting_in_a_fifo() { + let directory = tempfile::tempdir().unwrap(); + let fifo = directory.path().join("console"); + let path = CString::new(fifo.as_os_str().as_bytes()).unwrap(); + assert_eq!( + sys::create_fifo(&path, Mode::S_IRUSR | Mode::S_IWUSR), + Ok(()) + ); + let path = fifo.as_os_str().as_bytes(); + assert_eq!(console_pending(&[path]), (0, b"0\n".to_vec(), Vec::new())); + + // Opening a FIFO's reader without blocking lets the writer open it. The reader stays + // open until the test ends, so the FIFO keeps the bytes. + let _reader = fs::File::options() + .read(true) + .custom_flags(OpenFlags::O_NONBLOCK.bits()) + .open(&fifo) + .unwrap(); + fs::write(&fifo, b"twelve bytes").unwrap(); + assert_eq!(console_pending(&[path]), (0, b"12\n".to_vec(), Vec::new())); + } + + #[test] + fn prints_the_unread_size_of_a_regular_file() { + let file = tempfile::NamedTempFile::new().unwrap(); + fs::write(file.path(), b"12345").unwrap(); + let path = file.path().as_os_str().as_bytes(); + assert_eq!(console_pending(&[path]), (0, b"5\n".to_vec(), Vec::new())); + } + + #[test] + fn reports_failures_with_the_errno_description() { + let directory = tempfile::tempdir().unwrap(); + let missing = directory.path().join("missing"); + assert_eq!( + console_pending(&[missing.as_os_str().as_bytes()]), + (1, Vec::new(), message("open", &missing, Errno::ENOENT)) + ); + // A directory opens, but has no FIONREAD. + assert_eq!( + console_pending(&[directory.path().as_os_str().as_bytes()]), + ( + 1, + Vec::new(), + message("ioctl", directory.path(), Errno::ENOTTY) + ) + ); + } + + #[test] + fn reports_a_failed_write() { + let file = tempfile::NamedTempFile::new().unwrap(); + let args = [ + CString::new("nvx-console-pending").unwrap(), + CString::new(file.path().as_os_str().as_bytes()).unwrap(), + ]; + let mut err = Vec::new(); + assert_eq!( + ExitStatus::from(run(&args, &mut Failing(Errno::EIO), &mut err)), + ExitStatus::Failure + ); + let mut expected = b"nvx-console-pending: write: ".to_vec(); + expected.extend_from_slice(Errno::EIO.desc().as_bytes()); + expected.push(b'\n'); + assert_eq!(err, expected); + } +} diff --git a/nvx-rs/src/guest/mmio_write.rs b/nvx-rs/src/guest/mmio_write.rs new file mode 100644 index 000000000..09eba081f --- /dev/null +++ b/nvx-rs/src/guest/mmio_write.rs @@ -0,0 +1,188 @@ +//! `nvx-mmio-write ADDRESS VALUE`: stores a 32-bit value at a physical address through +//! `/dev/mem`, as `nvx-virtio-restore-probe` does to kick a virtio queue. + +use std::ffi::{CStr, CString}; +use std::io::{self, Write}; +use std::num::NonZeroUsize; +use std::process::ExitCode; + +use super::parse::parse_u64; +use super::status::{CommandError, ExitStatus}; +use super::sys::{self, Errno, Mode, OpenFlags}; + +/// Runs `nvx-mmio-write` with the process's arguments. It exits with 0, 1 when an operation +/// fails, or 2 for a usage error, and prints nothing on success. +pub fn main() -> ExitCode { + sys::restore_sigpipe(); + ExitStatus::from(run(&sys::args(), c"/dev/mem", &mut io::stderr())).into() +} + +/// Validates the command, maps one physical page of `memory`, which stands for `/dev/mem`, +/// stores the value, and releases its resources. +fn run(args: &[CString], memory: &CStr, err: &mut dyn Write) -> Result<(), CommandError> { + let parsed = match args { + [_, address, value] => parse_u64(address).zip(parse_u64(value)), + _ => None, + }; + let Some((address, value)) = parsed.filter(|&(_, value)| value <= u64::from(u32::MAX)) else { + sys::report(err, &[b"usage: nvx-mmio-write ADDRESS VALUE\n"]); + return Err(CommandError::Usage); + }; + let value = value as u32; + + let Some(page_size) = sys::page_size() else { + sys::report(err, &[b"invalid page size\n"]); + return Err(CommandError::Operation); + }; + let page_size_u64 = page_size as u64; + let page_base = address & !(page_size_u64 - 1); + let page_offset = address - page_base; + if page_offset > page_size_u64.wrapping_sub(4) { + sys::report(err, &[b"MMIO write crosses a page boundary\n"]); + return Err(CommandError::Operation); + } + + let descriptor = sys::open(memory, OpenFlags::O_RDWR | OpenFlags::O_SYNC, Mode::empty()) + .map_err(|error| { + perror(err, b"open /dev/mem", error); + CommandError::Operation + })?; + let length = NonZeroUsize::new(page_size).expect("a valid page size is nonzero"); + let mut mapping = sys::MemoryMapping::shared_read_write(&descriptor, length, page_base) + .map_err(|error| { + perror(err, b"mmap /dev/mem", error); + CommandError::Operation + })?; + if !mapping.write_u32(page_offset as usize, value) { + // The boundary validation above proves this unreachable, but preserve a typed failure if + // the mapping abstraction and validation ever diverge. + sys::report(err, &[b"MMIO write crosses a page boundary\n"]); + return Err(CommandError::Operation); + } + mapping.unmap().map_err(|error| { + perror(err, b"munmap /dev/mem", error); + CommandError::Operation + })?; + descriptor.close().map_err(|error| { + perror(err, b"close /dev/mem", error); + CommandError::Operation + })?; + Ok(()) +} + +/// Reports `errno` as C's `perror(prefix)` does, with `nix`'s description. +fn perror(err: &mut dyn Write, prefix: &[u8], errno: Errno) { + sys::report(err, &[prefix, b": ", errno.desc().as_bytes(), b"\n"]); +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::os::unix::ffi::OsStrExt; + use std::path::Path; + + use super::*; + + fn page_size() -> u64 { + sys::page_size().expect("tests need a page size") as u64 + } + + fn mmio_write(memory: &Path, arguments: &[&str]) -> (u8, Vec) { + let mut args = vec![CString::new("nvx-mmio-write").unwrap()]; + args.extend( + arguments + .iter() + .map(|argument| CString::new(*argument).unwrap()), + ); + let memory = CString::new(memory.as_os_str().as_bytes()).unwrap(); + let mut err = Vec::new(); + let status = ExitStatus::from(run(&args, &memory, &mut err)); + (status as u8, err) + } + + fn perror_text(prefix: &str, errno: Errno) -> Vec { + let mut text = format!("{prefix}: ").into_bytes(); + text.extend_from_slice(errno.desc().as_bytes()); + text.push(b'\n'); + text + } + + #[test] + fn usage_requires_an_address_and_a_32_bit_value() { + let directory = tempfile::tempdir().unwrap(); + let memory = directory.path().join("mem"); + let usage = b"usage: nvx-mmio-write ADDRESS VALUE\n".to_vec(); + for arguments in [ + &[][..], + &["0x1000"], + &["0x1000", "1", "2"], + &["", "1"], + &["0x1000", ""], + &["0x", "1"], + &["08", "1"], + &["010", "1"], + &["+1", "1"], + &[" 1", "1"], + &["1 ", "1"], + &["0x1g", "1"], + &["18446744073709551616", "1"], + &["0x1000", "4294967296"], + &["0x1000", "-1"], + ] { + assert_eq!( + mmio_write(&memory, arguments), + (2, usage.clone()), + "{arguments:?}" + ); + } + assert!(!memory.exists()); + } + + #[test] + fn refuses_a_store_that_crosses_a_page() { + let directory = tempfile::tempdir().unwrap(); + let memory = directory.path().join("mem"); + let address = format!("{}", page_size() - 3); + assert_eq!( + mmio_write(&memory, &[&address, "1"]), + (1, b"MMIO write crosses a page boundary\n".to_vec()) + ); + } + + #[test] + fn stores_the_value_in_native_byte_order() { + let file = tempfile::NamedTempFile::new().unwrap(); + let page = page_size(); + fs::write(file.path(), vec![0; 2 * page as usize]).unwrap(); + for (address, value, offset) in [ + (format!("{:#x}", page + 4), "0xdeadbeef", page as usize + 4), + (format!("{:#X}", page + 9), "305419896", page as usize + 9), + (format!("{}", page - 4), "4294967295", page as usize - 4), + ] { + assert_eq!(mmio_write(file.path(), &[&address, value]), (0, Vec::new())); + let stored = parse_u64(&CString::new(value).unwrap()).unwrap() as u32; + let contents = fs::read(file.path()).unwrap(); + assert_eq!(&contents[offset..offset + 4], &stored.to_ne_bytes()); + } + } + + #[test] + fn reports_failures_as_perror() { + let directory = tempfile::tempdir().unwrap(); + assert_eq!( + mmio_write(&directory.path().join("missing"), &["0", "0"]), + (1, perror_text("open /dev/mem", Errno::ENOENT)) + ); + // A FIFO opens for reading and writing, but has no mmap. + let fifo = directory.path().join("fifo"); + let path = CString::new(fifo.as_os_str().as_bytes()).unwrap(); + assert_eq!( + sys::create_fifo(&path, Mode::S_IRUSR | Mode::S_IWUSR), + Ok(()) + ); + assert_eq!( + mmio_write(&fifo, &["0", "0"]), + (1, perror_text("mmap /dev/mem", Errno::ENODEV)) + ); + } +} diff --git a/nvx-rs/src/guest/mod.rs b/nvx-rs/src/guest/mod.rs new file mode 100644 index 000000000..4c0694606 --- /dev/null +++ b/nvx-rs/src/guest/mod.rs @@ -0,0 +1,34 @@ +//! The programs that run in NVX's Linux guests, which ship as `/sbin/nvx-*`. +//! +//! 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. +//! +//! # 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. +//! - 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 +//! program released one before a later step, its scope ends there, for example in a helper +//! function that returns what the later step needs. +//! - Every program fails with the shared `CommandError`, whose kind selects the program's exit +//! status from the shared `ExitStatus`. Every module, type, field, variant, constant, static, +//! and function is documented, which CI enforces with `clippy::missing_docs_in_private_items`. +//! - The programs use `nix`, `getrandom`, or the standard library rather than reimplement a +//! facility, and keep a parser of their own only for forms that no library parses. +#![deny(unsafe_code)] + +mod parse; +mod status; +#[allow(unsafe_code)] +mod sys; + +pub mod console_pending; +#[cfg(target_arch = "x86_64")] +pub mod mmio_write; +pub mod port_io; +pub mod reseed; diff --git a/nvx-rs/src/guest/parse.rs b/nvx-rs/src/guest/parse.rs new file mode 100644 index 000000000..aeabdf1a4 --- /dev/null +++ b/nvx-rs/src/guest/parse.rs @@ -0,0 +1,82 @@ +//! Numeric command-line arguments: decimal, or hexadecimal after `0x`. +//! +//! 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. + +use std::ffi::CStr; + +/// Parses a whole, nonempty decimal number, or a hexadecimal one after a `0x` or `0X` prefix. +/// +/// Returns `None` for an empty argument, any other character, a decimal number with a leading +/// zero, which C read as octal, or a value above `u64::MAX`. +pub(crate) fn parse_u64(text: &CStr) -> Option { + let text = text.to_str().ok()?; + let (digits, radix) = match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) { + Some(digits) => (digits, 16), + None if text.len() > 1 && text.starts_with('0') => return None, + None => (text, 10), + }; + // `from_str_radix` alone would also accept a leading `+`. + if digits.is_empty() || !digits.chars().all(|digit| digit.is_digit(radix)) { + return None; + } + u64::from_str_radix(digits, radix).ok() +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + + use super::*; + + fn parse(text: &[u8]) -> Option { + parse_u64(&CString::new(text).unwrap()) + } + + #[test] + fn parses_decimal_and_hexadecimal_numbers() { + let cases: &[(&[u8], u64)] = &[ + (b"0", 0), + (b"7", 7), + (b"4096", 4096), + (b"18446744073709551615", u64::MAX), + (b"0x0", 0), + (b"0x10", 16), + (b"0XfF", 255), + (b"0x00ff", 255), + (b"0xffffffffffffffff", u64::MAX), + ]; + for &(text, expected) in cases { + assert_eq!(parse(text), Some(expected), "{}", text.escape_ascii()); + } + } + + #[test] + fn rejects_every_other_form() { + for text in [ + &b""[..], + b"0x", + b"0X", + b"00", + b"010", + b"08", + b"+1", + b"-1", + b" 1", + b"1 ", + b"1a", + b"0x1g", + b"0x+1", + b"0x-1", + b"1_000", + b"0b1", + b"18446744073709551616", + b"0x10000000000000000", + b"\xa01", + "\u{661}".as_bytes(), + ] { + assert_eq!(parse(text), None, "{}", text.escape_ascii()); + } + } +} diff --git a/nvx-rs/src/guest/port_io.rs b/nvx-rs/src/guest/port_io.rs new file mode 100644 index 000000000..2cfd4e110 --- /dev/null +++ b/nvx-rs/src/guest/port_io.rs @@ -0,0 +1,566 @@ +//! `nvx-port-io`: reads and writes x86 I/O ports through `/dev/port`, and reads the VM generation +//! ID that OpenVMM exposes behind a selector port. +//! +//! - `read-u8 PORT` prints the byte at PORT in decimal. +//! - `write-u8 PORT VALUE` writes the byte VALUE to PORT. +//! - `read-generation-id DATA_PORT SELECT_PORT [OUTPUT]` checks the availability bit of the +//! status byte at SELECT_PORT, selects the generation ID, reads its 16 bytes from DATA_PORT, and +//! prints them in hexadecimal or writes them to OUTPUT. It holds the lock of the time ABI's +//! selector transactions meanwhile. + +use std::ffi::{CStr, CString}; +use std::io::{self, Write}; +use std::process::ExitCode; + +use super::parse::parse_u64; +use super::status::{CommandError, ExitStatus}; +use super::sys::{self, Errno, FileDescriptor, FileLock, Mode, OpenFlags}; + +/// Exact usage diagnostic inherited from the C program. +const USAGE: &[u8] = b"usage: nvx-port-io read-generation-id DATA_PORT SELECT_PORT [OUTPUT] \ +| read-u8 PORT | write-u8 PORT VALUE\n"; +/// Largest x86 I/O-port number accepted by the command line. +const PORT_MAX: u64 = 0xffff; +/// Selector value that exposes the VM generation ID on the data port. +const GENERATION_ID_SELECT: u8 = 0xa6; +/// Size in bytes of a VM generation ID. +const GENERATION_ID_SIZE: usize = 16; +/// Status-port bit that says the VM generation ID is available. +const STATUS_GENERATION_ID_AVAILABLE: u8 = 32; + +/// The files that the program uses. +struct Paths<'a> { + /// The I/O port device, `/dev/port`. + port: &'a CStr, + /// The directory of the lock, `/run/nvx`. + lock_directory: &'a CStr, + /// The lock that serializes selector transactions with the time ABI, `/run/nvx/portb.lock`. + lock: &'a CStr, +} + +/// Production device and lock paths. +const PATHS: Paths<'static> = Paths { + port: c"/dev/port", + lock_directory: c"/run/nvx", + lock: c"/run/nvx/portb.lock", +}; + +/// Runs `nvx-port-io` with the process's arguments. It exits with 0, 1 when an operation fails, +/// or 2 for a usage error. +pub fn main() -> ExitCode { + sys::restore_sigpipe(); + ExitStatus::from(run( + &sys::args(), + &PATHS, + &mut sys::Stdout::new(), + &mut io::stderr(), + )) + .into() +} + +/// Selects the requested port operation and runs it against injectable paths and output +/// streams, with owned resources. +fn run( + args: &[CString], + paths: &Paths<'_>, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result<(), CommandError> { + let port = |index: usize| parse_u64(&args[index]).filter(|&port| port <= PORT_MAX); + if args.len() >= 3 + && let Some(offset) = port(2) + { + let command = args[1].as_bytes(); + if args.len() == 3 && command == b"read-u8" { + return read_u8(paths, offset, out, err); + } + if args.len() == 4 + && command == b"write-u8" + && let Some(value) = parse_u64(&args[3]).filter(|&value| value <= u64::from(u8::MAX)) + { + return write_u8(paths, offset, value as u8, err); + } + if (args.len() == 4 || args.len() == 5) + && command == b"read-generation-id" + && let Some(select_offset) = port(3) + { + return read_generation_id(paths, offset, select_offset, args.get(4), out, err); + } + } + sys::report(err, &[USAGE]); + Err(CommandError::Usage) +} + +/// Reads and prints one byte from `offset` of the port device. +fn read_u8( + paths: &Paths<'_>, + offset: u64, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result<(), CommandError> { + let port = open( + paths.port, + OpenFlags::O_RDONLY | OpenFlags::O_CLOEXEC, + Mode::empty(), + err, + )?; + let value = read_port_byte(&port, offset, err)?; + close_port(paths, port, err)?; + // The C program ignored the result of its printf. + let _ = sys::print(out, format!("{value}\n").as_bytes()); + Ok(()) +} + +/// Writes `value` to `offset` of the port device. +fn write_u8( + paths: &Paths<'_>, + offset: u64, + value: u8, + err: &mut dyn Write, +) -> Result<(), CommandError> { + let port = open( + paths.port, + OpenFlags::O_WRONLY | OpenFlags::O_CLOEXEC, + Mode::empty(), + err, + )?; + write_port_byte(&port, offset, value, err)?; + close_port(paths, port, err) +} + +/// Reads the selected VM generation ID and writes it to `output` or standard output. +fn read_generation_id( + paths: &Paths<'_>, + data_offset: u64, + select_offset: u64, + output: Option<&CString>, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result<(), CommandError> { + let (port, generation_id) = read_locked_generation_id(paths, data_offset, select_offset, err)?; + close_port(paths, port, err)?; + + let Some(path) = output else { + let mut line: String = generation_id + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + line.push('\n'); + if sys::print(out, line.as_bytes()).is_err() { + sys::report(err, &[b"nvx-port-io: write output failed\n"]); + return Err(CommandError::Operation); + } + return Ok(()); + }; + let flags = OpenFlags::O_WRONLY + | OpenFlags::O_CREAT + | OpenFlags::O_TRUNC + | OpenFlags::O_CLOEXEC + | OpenFlags::O_NOFOLLOW; + let descriptor = open(path, flags, Mode::S_IRUSR | Mode::S_IWUSR, err)?; + write_all(&descriptor, &generation_id, err)?; + if let Err(errno) = descriptor.close() { + report(err, b"close", path, errno); + return Err(CommandError::Operation); + } + Ok(()) +} + +/// Selects and reads the VM generation ID under the portb lock, and returns it with the open +/// port device. +/// +/// The lock is released when this returns, before the caller closes the port, as in the C +/// program. On a failure the port closes first, then the lock, also as in the C program. +fn read_locked_generation_id( + paths: &Paths<'_>, + data_offset: u64, + select_offset: u64, + err: &mut dyn Write, +) -> Result<(FileDescriptor, [u8; GENERATION_ID_SIZE]), CommandError> { + let _lock = lock_portb(paths, err)?; + let port = open( + paths.port, + OpenFlags::O_RDWR | OpenFlags::O_CLOEXEC, + Mode::empty(), + err, + )?; + let status = read_port_byte(&port, select_offset, err)?; + if status & STATUS_GENERATION_ID_AVAILABLE == 0 { + let message = + format!("nvx-port-io: VM generation ID is unavailable on port {select_offset:#x}\n"); + sys::report(err, &[message.as_bytes()]); + return Err(CommandError::Operation); + } + let mut generation_id = [0; GENERATION_ID_SIZE]; + write_port_byte(&port, select_offset, GENERATION_ID_SELECT, err)?; + read_port_bytes(&port, data_offset, &mut generation_id, err)?; + Ok((port, generation_id)) +} + +/// Takes the lock that serializes selector transactions on portb with the time ABI. +fn lock_portb(paths: &Paths<'_>, err: &mut dyn Write) -> Result { + if let Err(errno) = sys::create_directory( + paths.lock_directory, + Mode::S_IRWXU | Mode::S_IRGRP | Mode::S_IXGRP | Mode::S_IROTH | Mode::S_IXOTH, + ) && errno != Errno::EEXIST + { + report(err, b"mkdir", paths.lock_directory, errno); + return Err(CommandError::Operation); + } + let lock = open( + paths.lock, + OpenFlags::O_RDWR | OpenFlags::O_CREAT | OpenFlags::O_CLOEXEC, + Mode::S_IRUSR | Mode::S_IWUSR, + err, + )?; + lock.lock_exclusive().map_err(|errno| { + report(err, b"flock", paths.lock, errno); + CommandError::Operation + }) +} + +/// Opens `path`, and reports a failure. +fn open( + path: &CStr, + flags: OpenFlags, + mode: Mode, + err: &mut dyn Write, +) -> Result { + sys::open(path, flags, mode).map_err(|errno| { + report(err, b"open", path, errno); + CommandError::Operation + }) +} + +/// Closes the port device, and reports a failure. +fn close_port( + paths: &Paths<'_>, + port: FileDescriptor, + err: &mut dyn Write, +) -> Result<(), CommandError> { + if let Err(errno) = port.close() { + report(err, b"close", paths.port, errno); + return Err(CommandError::Operation); + } + Ok(()) +} + +/// Reads the byte at `offset` of the port device, retrying after `EINTR`. +fn read_port_byte( + port: &FileDescriptor, + offset: u64, + err: &mut dyn Write, +) -> Result { + let mut value = 0_u8; + match sys::retry(|| port.pread(std::slice::from_mut(&mut value), offset)) { + Ok(1) => return Ok(value), + Ok(0) => { + let message = format!("nvx-port-io: read port {offset:#x}: "); + sys::report(err, &[message.as_bytes(), b"unexpected end of file\n"]); + } + Ok(_) => unreachable!("a one-byte pread cannot return more than one byte"), + Err(errno) => { + let message = format!("nvx-port-io: read port {offset:#x}: "); + sys::report(err, &[message.as_bytes(), errno.desc().as_bytes(), b"\n"]); + } + } + Err(CommandError::Operation) +} + +/// Writes `value` at `offset` of the port device, retrying after `EINTR`. +fn write_port_byte( + port: &FileDescriptor, + offset: u64, + value: u8, + err: &mut dyn Write, +) -> Result<(), CommandError> { + match sys::retry(|| port.pwrite(std::slice::from_ref(&value), offset)) { + Ok(1) => return Ok(()), + Ok(0) => { + let message = format!("nvx-port-io: write port {offset:#x}: "); + sys::report(err, &[message.as_bytes(), b"no byte written\n"]); + } + Ok(_) => unreachable!("a one-byte pwrite cannot return more than one byte"), + Err(errno) => { + let message = format!("nvx-port-io: write port {offset:#x}: "); + sys::report(err, &[message.as_bytes(), errno.desc().as_bytes(), b"\n"]); + } + } + Err(CommandError::Operation) +} + +/// Reads each byte of `buffer` from the same port. +fn read_port_bytes( + port: &FileDescriptor, + offset: u64, + buffer: &mut [u8], + err: &mut dyn Write, +) -> Result<(), CommandError> { + for byte in buffer { + *byte = read_port_byte(port, offset, err)?; + } + Ok(()) +} + +/// Writes all of `bytes`, retrying after `EINTR` and short writes. +fn write_all( + descriptor: &FileDescriptor, + bytes: &[u8], + err: &mut dyn Write, +) -> Result<(), CommandError> { + let mut offset = 0; + while offset < bytes.len() { + let rest = &bytes[offset..]; + match sys::retry(|| descriptor.write(rest)) { + Ok(count) if count > 0 => { + offset += count; + continue; + } + Ok(_) => { + sys::report(err, &[b"nvx-port-io: write output: no byte written\n"]); + } + Err(errno) => { + let reason = errno.desc().as_bytes(); + sys::report(err, &[b"nvx-port-io: write output: ", reason, b"\n"]); + } + } + return Err(CommandError::Operation); + } + Ok(()) +} + +/// Reports a failed operation on `path`, as `nvx-port-io: OPERATION PATH: TEXT`. +fn report(err: &mut dyn Write, operation: &[u8], path: &CStr, errno: Errno) { + sys::report( + err, + &[ + b"nvx-port-io: ", + operation, + b" ", + path.to_bytes(), + b": ", + errno.desc().as_bytes(), + b"\n", + ], + ); +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::os::unix::ffi::OsStrExt; + use std::path::{Path, PathBuf}; + + use super::*; + use crate::guest::sys::tests::Failing; + + /// A regular file that stands for `/dev/port`, and a lock below a temporary directory. + struct Fixture { + directory: tempfile::TempDir, + port: PathBuf, + lock_directory: PathBuf, + } + + impl Fixture { + fn new() -> Self { + let directory = tempfile::tempdir().unwrap(); + let port = directory.path().join("port"); + fs::write(&port, [0; 0x100]).unwrap(); + let lock_directory = directory.path().join("run"); + Self { + directory, + port, + lock_directory, + } + } + + fn run_with(&self, arguments: &[&str], out: &mut dyn Write) -> (u8, Vec) { + let mut args = vec![CString::new("nvx-port-io").unwrap()]; + args.extend( + arguments + .iter() + .map(|argument| CString::new(*argument).unwrap()), + ); + let c_path = |path: &Path| CString::new(path.as_os_str().as_bytes()).unwrap(); + let (port, lock_directory) = (c_path(&self.port), c_path(&self.lock_directory)); + let lock = c_path(&self.lock_directory.join("portb.lock")); + let paths = Paths { + port: &port, + lock_directory: &lock_directory, + lock: &lock, + }; + let mut err = Vec::new(); + let status = ExitStatus::from(run(&args, &paths, out, &mut err)); + (status as u8, err) + } + + fn run(&self, arguments: &[&str]) -> (u8, Vec, Vec) { + let mut out = Vec::new(); + let (status, err) = self.run_with(arguments, &mut out); + (status, out, err) + } + + fn set(&self, offset: usize, value: u8) { + let mut bytes = fs::read(&self.port).unwrap(); + bytes[offset] = value; + fs::write(&self.port, bytes).unwrap(); + } + + fn byte(&self, offset: usize) -> u8 { + fs::read(&self.port).unwrap()[offset] + } + } + + fn message(operation: &str, path: &Path, errno: Errno) -> Vec { + let mut message = format!("nvx-port-io: {operation} ").into_bytes(); + message.extend_from_slice(path.as_os_str().as_bytes()); + message.extend_from_slice(b": "); + message.extend_from_slice(errno.desc().as_bytes()); + message.push(b'\n'); + message + } + + #[test] + fn usage_names_every_command() { + let fixture = Fixture::new(); + for arguments in [ + &[][..], + &["read-u8"], + &["read-u8", "1", "2"], + &["read-u8", "0x10000"], + &["read-u8", "-1"], + &["read-u8", "+1"], + &["read-u8", "1 "], + &["read-u8", "0161"], + &["write-u8", "1"], + &["write-u8", "1", "256"], + &["write-u8", "1", "0x"], + &["read-generation-id", "1"], + &["read-generation-id", "1", "65536"], + &["read-generation-id", "1", "2", "out", "extra"], + &["read", "1"], + ] { + assert_eq!( + fixture.run(arguments), + (2, Vec::new(), USAGE.to_vec()), + "{arguments:?}" + ); + } + assert_eq!( + USAGE, + b"usage: nvx-port-io read-generation-id DATA_PORT SELECT_PORT [OUTPUT] | \ +read-u8 PORT | write-u8 PORT VALUE\n" + ); + } + + #[test] + fn reads_and_writes_a_byte() { + let fixture = Fixture::new(); + fixture.set(0x70, 42); + assert_eq!( + fixture.run(&["read-u8", "0x70"]), + (0, b"42\n".to_vec(), Vec::new()) + ); + assert_eq!( + fixture.run(&["write-u8", "113", "255"]), + (0, Vec::new(), Vec::new()) + ); + assert_eq!(fixture.byte(0x71), 255); + // The C program ignored a failure to print the byte. + let mut out = Failing(Errno::EIO); + assert_eq!( + fixture.run_with(&["read-u8", "0x70"], &mut out), + (0, Vec::new()) + ); + } + + #[test] + fn reports_a_read_past_the_end_and_a_missing_device() { + let fixture = Fixture::new(); + assert_eq!( + fixture.run(&["read-u8", "0x200"]), + ( + 1, + Vec::new(), + b"nvx-port-io: read port 0x200: unexpected end of file\n".to_vec() + ) + ); + fs::remove_file(&fixture.port).unwrap(); + for arguments in [&["read-u8", "1"][..], &["write-u8", "1", "1"]] { + assert_eq!( + fixture.run(arguments), + (1, Vec::new(), message("open", &fixture.port, Errno::ENOENT)) + ); + } + } + + #[test] + fn reads_the_generation_id() { + let fixture = Fixture::new(); + fixture.set(0x50, STATUS_GENERATION_ID_AVAILABLE | 1); + fixture.set(0x51, 0x5c); + let mut expected = "5c".repeat(GENERATION_ID_SIZE).into_bytes(); + expected.push(b'\n'); + assert_eq!( + fixture.run(&["read-generation-id", "0x51", "0x50"]), + (0, expected, Vec::new()) + ); + assert_eq!(fixture.byte(0x50), GENERATION_ID_SELECT); + assert!(fixture.lock_directory.join("portb.lock").is_file()); + + fixture.set(0x50, STATUS_GENERATION_ID_AVAILABLE); + let output = fixture.directory.path().join("generation-id"); + let arguments = ["read-generation-id", "81", "80", output.to_str().unwrap()]; + assert_eq!(fixture.run(&arguments), (0, Vec::new(), Vec::new())); + assert_eq!(fs::read(&output).unwrap(), [0x5c; GENERATION_ID_SIZE]); + + fixture.set(0x50, STATUS_GENERATION_ID_AVAILABLE); + let mut out = Failing(Errno::EIO); + assert_eq!( + fixture.run_with(&["read-generation-id", "0x51", "0x50"], &mut out), + (1, b"nvx-port-io: write output failed\n".to_vec()) + ); + } + + #[test] + fn reports_an_unavailable_generation_id() { + let fixture = Fixture::new(); + fixture.set(0x50, !STATUS_GENERATION_ID_AVAILABLE); + assert_eq!( + fixture.run(&["read-generation-id", "0x51", "0x50"]), + ( + 1, + Vec::new(), + b"nvx-port-io: VM generation ID is unavailable on port 0x50\n".to_vec() + ) + ); + assert_eq!(fixture.byte(0x50), !STATUS_GENERATION_ID_AVAILABLE); + } + + #[test] + fn refuses_to_follow_an_output_symlink() { + let fixture = Fixture::new(); + fixture.set(0x50, STATUS_GENERATION_ID_AVAILABLE); + let link = fixture.directory.path().join("link"); + std::os::unix::fs::symlink(fixture.directory.path().join("target"), &link).unwrap(); + let arguments = ["read-generation-id", "0x51", "0x50", link.to_str().unwrap()]; + assert_eq!( + fixture.run(&arguments), + (1, Vec::new(), message("open", &link, Errno::ELOOP)) + ); + } + + #[test] + fn reports_a_lock_directory_that_cannot_exist() { + let mut fixture = Fixture::new(); + fixture.lock_directory = fixture.port.join("run"); + assert_eq!( + fixture.run(&["read-generation-id", "0x51", "0x50"]), + ( + 1, + Vec::new(), + message("mkdir", &fixture.lock_directory, Errno::ENOTDIR) + ) + ); + } +} diff --git a/nvx-rs/src/guest/reseed.rs b/nvx-rs/src/guest/reseed.rs new file mode 100644 index 000000000..877825329 --- /dev/null +++ b/nvx-rs/src/guest/reseed.rs @@ -0,0 +1,440 @@ +//! `nvx-reseed`: reseeds the kernel's random number generator from a seed file, such as the one +//! that `nvx-snapshot` reads after a restore, and checks the VM generation ID in the seed. +//! +//! - `nvx-reseed SEED_FILE [PREVIOUS_GENERATION_ID]` adds the 64-byte seed to the entropy pool +//! with full credit and reseeds the CRNG. With a previous generation ID, it first checks that +//! the seed's generation ID, its first 16 bytes, differs, and afterwards prints it in +//! hexadecimal. +//! - `nvx-reseed --generation-only SEED_FILE PREVIOUS_GENERATION_ID` only checks and prints the +//! generation ID. +//! - `nvx-reseed --sample` prints 32 bytes from `getrandom` in hexadecimal. + +use std::ffi::{CStr, CString}; +use std::io::{self, Write}; +use std::process::ExitCode; + +use super::status::{CommandError, ExitStatus}; +use super::sys::{self, Errno, Mode, OpenFlags}; + +/// Number of seed bytes credited to the kernel entropy pool. +const SEED_BYTES: usize = 64; +/// Number of leading seed bytes occupied by the VM generation ID. +const GENERATION_ID_BYTES: usize = 16; +/// Number of random bytes printed by `--sample`. +const RANDOM_SAMPLE_BYTES: usize = 32; +/// Exact usage diagnostic inherited from the C program. +const USAGE: &[u8] = + b"usage: nvx-reseed --sample | [--generation-only] SEED_FILE [PREVIOUS_GENERATION_ID]\n"; + +/// Runs `nvx-reseed` with the process's arguments. It exits with 0, 1 when an operation or a +/// check fails, or 2 for a usage error. +pub fn main() -> ExitCode { + sys::restore_sigpipe(); + ExitStatus::from(run( + &sys::args(), + c"/dev/random", + &mut sys::Stdout::new(), + &mut io::stderr(), + )) + .into() +} + +/// Selects sampling, generation checking, or reseeding and performs that operation, with +/// `random` standing for `/dev/random`. +fn run( + args: &[CString], + random: &CStr, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result<(), CommandError> { + if let [_, flag] = args + && flag.as_bytes() == b"--sample" + { + return print_random_sample(out, err); + } + // As in the C program, a first argument of `--generation-only` names the seed file unless + // three arguments follow the program name. + let (generation_only, seed_path, previous) = match args { + [_, seed] => (false, seed, None), + [_, seed, previous] => (false, seed, Some(previous)), + [_, flag, seed, previous] if flag.as_bytes() == b"--generation-only" => { + (true, seed, Some(previous)) + } + _ => { + sys::report(err, &[USAGE]); + return Err(CommandError::Usage); + } + }; + + let mut seed = [0; SEED_BYTES]; + read_seed(seed_path, &mut seed, err)?; + let generation_id = &seed[..GENERATION_ID_BYTES]; + if let Some(previous) = previous { + validate_generation_change(previous.as_bytes(), generation_id, err)?; + } + if generation_only { + return print_hex(generation_id, out, err); + } + + reseed(random, &seed, err)?; + if previous.is_some() { + print_hex(&seed[..GENERATION_ID_BYTES], out, err)?; + } + Ok(()) +} + +/// Adds `seed` to the entropy pool through `random`, which stands for `/dev/random`, and reseeds +/// the CRNG. +/// +/// The device closes when this returns, before the caller prints the generation ID, as in the C +/// program, which ignored the result of that close. +fn reseed(random: &CStr, seed: &[u8; SEED_BYTES], err: &mut dyn Write) -> Result<(), CommandError> { + let descriptor = sys::open( + random, + OpenFlags::O_WRONLY | OpenFlags::O_CLOEXEC, + Mode::empty(), + ) + .map_err(|errno| { + let path = random.to_bytes(); + let text = errno.desc().as_bytes(); + sys::report(err, &[b"nvx-reseed: open ", path, b": ", text, b"\n"]); + CommandError::Operation + })?; + sys::reseed_random(&descriptor, seed).map_err(|errno| { + let text = errno.desc().as_bytes(); + sys::report(err, &[b"nvx-reseed: random ioctl: ", text, b"\n"]); + CommandError::Operation + }) +} + +/// Reads exactly `bytes.len()` bytes from the file at `path`. Like the C program, it does not +/// retry after `EINTR`. +fn read_seed(path: &CStr, bytes: &mut [u8], err: &mut dyn Write) -> Result<(), CommandError> { + let fail = |err: &mut dyn Write, operation: &[u8], reason: &[u8]| { + let path = path.to_bytes(); + sys::report( + err, + &[b"nvx-reseed: ", operation, b" ", path, b": ", reason, b"\n"], + ); + }; + let descriptor = sys::open( + path, + OpenFlags::O_RDONLY | OpenFlags::O_CLOEXEC, + Mode::empty(), + ) + .map_err(|errno| { + fail(err, b"open", errno.desc().as_bytes()); + CommandError::Operation + })?; + let mut offset = 0; + while offset < bytes.len() { + let rest = &mut bytes[offset..]; + match descriptor.read(rest) { + Ok(0) => { + fail(err, b"read", b"unexpected end of file"); + return Err(CommandError::Operation); + } + Ok(count) => offset += count, + Err(errno) => { + fail(err, b"read", errno.desc().as_bytes()); + return Err(CommandError::Operation); + } + } + } + Ok(()) +} + +/// Checks that `previous` is 32 hexadecimal digits that differ from `generation_id`. +fn validate_generation_change( + previous: &[u8], + generation_id: &[u8], + err: &mut dyn Write, +) -> Result<(), CommandError> { + if previous.len() != GENERATION_ID_BYTES * 2 { + sys::report( + err, + &[b"nvx-reseed: previous VM generation ID is not 32 hex digits\n"], + ); + return Err(CommandError::Operation); + } + let mut previous_bytes = [0; GENERATION_ID_BYTES]; + for (byte, digits) in previous_bytes.iter_mut().zip(previous.chunks_exact(2)) { + let (Some(high), Some(low)) = (hex_value(digits[0]), hex_value(digits[1])) else { + sys::report( + err, + &[b"nvx-reseed: previous VM generation ID is not hexadecimal\n"], + ); + return Err(CommandError::Operation); + }; + *byte = (high << 4) | low; + } + if previous_bytes == generation_id { + sys::report(err, &[b"nvx-reseed: VM generation ID did not change\n"]); + return Err(CommandError::Operation); + } + Ok(()) +} + +/// Returns one hexadecimal nibble, accepting the upper- and lowercase C forms. +fn hex_value(digit: u8) -> Option { + match digit { + b'0'..=b'9' => Some(digit - b'0'), + b'a'..=b'f' => Some(digit - b'a' + 10), + b'A'..=b'F' => Some(digit - b'A' + 10), + _ => None, + } +} + +/// Prints `bytes` in lowercase hexadecimal and a newline. +fn print_hex(bytes: &[u8], out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CommandError> { + let mut line: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + line.push('\n'); + if sys::print(out, line.as_bytes()).is_err() { + sys::report(err, &[b"nvx-reseed: write output failed\n"]); + return Err(CommandError::Operation); + } + Ok(()) +} + +/// Prints `RANDOM_SAMPLE_BYTES` bytes from the kernel's `getrandom` system call in hexadecimal. +/// +/// The `getrandom` crate retries after `EINTR` and short reads, as the C program did. It reads +/// `/dev/urandom` instead only when the kernel lacks the system call or a seccomp policy blocks +/// it. +fn print_random_sample(out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CommandError> { + let mut sample = [0; RANDOM_SAMPLE_BYTES]; + if let Err(error) = getrandom::fill(&mut sample) { + let reason = error.raw_os_error().map_or_else( + || error.to_string(), + |code| Errno::from_raw(code).desc().to_owned(), + ); + sys::report(err, &[b"nvx-reseed: getrandom: ", reason.as_bytes(), b"\n"]); + return Err(CommandError::Operation); + } + print_hex(&sample, out, err) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::os::unix::ffi::OsStrExt; + use std::path::Path; + + use super::*; + use crate::guest::sys::tests::Failing; + + /// A seed whose bytes count up from 0, so its generation ID is 000102...0f. + const GENERATION_ID: &str = "000102030405060708090a0b0c0d0e0f"; + + struct Fixture { + directory: tempfile::TempDir, + } + + impl Fixture { + fn new() -> Self { + let directory = tempfile::tempdir().unwrap(); + let seed: Vec = (0..SEED_BYTES as u8).collect(); + fs::write(directory.path().join("seed"), seed).unwrap(); + fs::write(directory.path().join("random"), b"").unwrap(); + Self { directory } + } + + fn path(&self, name: &str) -> String { + self.directory + .path() + .join(name) + .to_str() + .unwrap() + .to_owned() + } + + fn run_with(&self, arguments: &[&str], out: &mut dyn Write) -> (u8, Vec) { + let mut args = vec![CString::new("nvx-reseed").unwrap()]; + args.extend( + arguments + .iter() + .map(|argument| CString::new(*argument).unwrap()), + ); + let random = CString::new(self.path("random")).unwrap(); + let mut err = Vec::new(); + let status = ExitStatus::from(run(&args, &random, out, &mut err)); + (status as u8, err) + } + + fn run(&self, arguments: &[&str]) -> (u8, Vec, Vec) { + let mut out = Vec::new(); + let (status, err) = self.run_with(arguments, &mut out); + (status, out, err) + } + } + + fn message(operation: &str, path: &Path, reason: &[u8]) -> Vec { + let mut message = format!("nvx-reseed: {operation} ").into_bytes(); + message.extend_from_slice(path.as_os_str().as_bytes()); + message.extend_from_slice(b": "); + message.extend_from_slice(reason); + message.push(b'\n'); + message + } + + #[test] + fn usage_takes_one_to_three_arguments() { + let fixture = Fixture::new(); + for arguments in [ + &[][..], + &["a", "b", "c"], + &["--generation-only", "a", "b", "c"], + &["--sample", "a", "b"], + ] { + assert_eq!( + fixture.run(arguments), + (2, Vec::new(), USAGE.to_vec()), + "{arguments:?}" + ); + } + } + + #[test] + fn options_name_the_seed_file_with_fewer_arguments() { + let fixture = Fixture::new(); + for (arguments, path) in [ + ( + &["--generation-only", GENERATION_ID][..], + "--generation-only", + ), + (&["--sample", GENERATION_ID], "--sample"), + ] { + let reason = Errno::ENOENT.desc().as_bytes(); + assert_eq!( + fixture.run(arguments), + (1, Vec::new(), message("open", Path::new(path), reason)) + ); + } + } + + #[test] + fn prints_a_random_sample() { + let fixture = Fixture::new(); + let (status, first, err) = fixture.run(&["--sample"]); + assert_eq!((status, err.as_slice()), (0, &b""[..])); + assert_eq!(first.len(), RANDOM_SAMPLE_BYTES * 2 + 1); + assert!( + first[..first.len() - 1] + .iter() + .all(|digit| matches!(digit, b'0'..=b'9' | b'a'..=b'f')) + ); + assert_eq!(first.last(), Some(&b'\n')); + assert_ne!(fixture.run(&["--sample"]).1, first); + } + + #[test] + fn checks_and_prints_the_generation_id() { + let fixture = Fixture::new(); + let seed = fixture.path("seed"); + let expected = format!("{GENERATION_ID}\n").into_bytes(); + for previous in [ + "ffffffffffffffffffffffffffffffff", + "000102030405060708090A0B0C0D0E0E", + ] { + assert_eq!( + fixture.run(&["--generation-only", &seed, previous]), + (0, expected.clone(), Vec::new()) + ); + } + let cases: &[(&str, &[u8])] = &[ + ( + "0001020304050607", + b"nvx-reseed: previous VM generation ID is not 32 hex digits\n", + ), + ( + "000102030405060708090a0b0c0d0e0f0", + b"nvx-reseed: previous VM generation ID is not 32 hex digits\n", + ), + ( + "000102030405060708090a0b0c0d0e0g", + b"nvx-reseed: previous VM generation ID is not hexadecimal\n", + ), + ( + "000102030405060708090A0B0C0D0E0F", + b"nvx-reseed: VM generation ID did not change\n", + ), + ]; + for &(previous, error) in cases { + for arguments in [ + &["--generation-only", &seed, previous][..], + &[&seed, previous], + ] { + assert_eq!( + fixture.run(arguments), + (1, Vec::new(), error.to_vec()), + "{arguments:?}" + ); + } + } + let mut out = Failing(Errno::EIO); + let arguments = [ + "--generation-only", + &seed, + "ffffffffffffffffffffffffffffffff", + ]; + assert_eq!( + fixture.run_with(&arguments, &mut out), + (1, b"nvx-reseed: write output failed\n".to_vec()) + ); + } + + #[test] + fn reports_seeds_that_cannot_be_read() { + let fixture = Fixture::new(); + let missing = fixture.directory.path().join("missing"); + assert_eq!( + fixture.run(&[missing.to_str().unwrap()]), + ( + 1, + Vec::new(), + message("open", &missing, Errno::ENOENT.desc().as_bytes()) + ) + ); + let short = fixture.directory.path().join("short"); + fs::write(&short, [0; SEED_BYTES - 1]).unwrap(); + assert_eq!( + fixture.run(&[short.to_str().unwrap()]), + ( + 1, + Vec::new(), + message("read", &short, b"unexpected end of file") + ) + ); + let directory = fixture.directory.path(); + assert_eq!( + fixture.run(&[directory.to_str().unwrap()]), + ( + 1, + Vec::new(), + message("read", directory, Errno::EISDIR.desc().as_bytes()) + ) + ); + } + + #[test] + fn reports_a_failed_reseed() { + let fixture = Fixture::new(); + let seed = fixture.path("seed"); + // A regular file stands for /dev/random, and has no random ioctls. + let mut expected = b"nvx-reseed: random ioctl: ".to_vec(); + expected.extend_from_slice(Errno::ENOTTY.desc().as_bytes()); + expected.push(b'\n'); + assert_eq!(fixture.run(&[&seed]), (1, Vec::new(), expected)); + + fs::remove_file(fixture.path("random")).unwrap(); + let random = fixture.directory.path().join("random"); + assert_eq!( + fixture.run(&[&seed, "ffffffffffffffffffffffffffffffff"]), + ( + 1, + Vec::new(), + message("open", &random, Errno::ENOENT.desc().as_bytes()) + ) + ); + } +} diff --git a/nvx-rs/src/guest/status.rs b/nvx-rs/src/guest/status.rs new file mode 100644 index 000000000..502bf199b --- /dev/null +++ b/nvx-rs/src/guest/status.rs @@ -0,0 +1,60 @@ +//! Exit statuses and failures shared by the guest programs. + +use std::process::ExitCode; + +/// Process exit statuses of the guest programs. +/// +/// A program exits with [`Success`](Self::Success), or with the status that its +/// [`CommandError`] selects. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum ExitStatus { + /// The program completed. + Success = 0, + /// An operation failed. + Failure = 1, + /// The command line was invalid. + Usage = 2, +} + +/// Typed failure of a guest program, which selects its exit status. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CommandError { + /// The command line is invalid. A program with a usage message has printed it. + Usage, + /// An operation failed. A program that reports failures has printed why. + Operation, +} + +impl From> for ExitStatus { + fn from(result: Result<(), CommandError>) -> Self { + match result { + Ok(()) => Self::Success, + Err(CommandError::Usage) => Self::Usage, + Err(CommandError::Operation) => Self::Failure, + } + } +} + +impl From for ExitCode { + fn from(status: ExitStatus) -> Self { + Self::from(status as u8) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failures_select_the_documented_statuses() { + let cases = [ + (Ok(()), 0), + (Err(CommandError::Operation), 1), + (Err(CommandError::Usage), 2), + ]; + for (result, code) in cases { + assert_eq!(ExitStatus::from(result) as u8, code, "{result:?}"); + } + } +} diff --git a/nvx-rs/src/guest/sys/fd.rs b/nvx-rs/src/guest/sys/fd.rs new file mode 100644 index 000000000..800aa45e0 --- /dev/null +++ b/nvx-rs/src/guest/sys/fd.rs @@ -0,0 +1,103 @@ +//! Owned file descriptors and safe file-oriented system operations. + +use std::ffi::CStr; +use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd}; + +pub(crate) use nix::fcntl::OFlag as OpenFlags; +use nix::fcntl::{Flock, FlockArg}; +pub(crate) use nix::sys::stat::Mode; + +use super::Errno; + +/// An exclusive advisory lock on an owned descriptor, released when it is dropped. +/// +/// `nix` unlocks the file, then closes its descriptor. The C tools only closed the descriptor, +/// which also releases the lock. Unlocking an owned descriptor cannot fail, and a failure would +/// panic. +pub(crate) type FileLock = Flock; + +/// An owned file descriptor. +/// +/// The inner [`OwnedFd`] closes the descriptor on every early-return path. [`close`](Self::close) +/// consumes the owner and reports the close error for the C code paths whose diagnostics depend +/// on it; dropping the value is the best-effort fallback where the C program ignored close. +#[derive(Debug)] +pub(crate) struct FileDescriptor(OwnedFd); + +impl FileDescriptor { + /// Reads bytes once, without retrying an interrupted call. + pub(crate) fn read(&self, bytes: &mut [u8]) -> Result { + nix::unistd::read(&self.0, bytes) + } + + /// Writes bytes once, without retrying an interrupted or short call. + pub(crate) fn write(&self, bytes: &[u8]) -> Result { + nix::unistd::write(&self.0, bytes) + } + + /// 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 _) + } + + /// Writes at `offset` once, preserving the C cast from `u64` to `off_t`. + pub(crate) fn pwrite(&self, bytes: &[u8], offset: u64) -> Result { + nix::sys::uio::pwrite(&self.0, bytes, offset as _) + } + + /// Takes an exclusive advisory lock on the descriptor, retrying after `EINTR`. + /// + /// The descriptor closes on failure, as in the C tools. + pub(crate) fn lock_exclusive(self) -> Result { + let mut descriptor = self.0; + loop { + match Flock::lock(descriptor, FlockArg::LockExclusive) { + Ok(lock) => return Ok(lock), + Err((returned, Errno::EINTR)) => descriptor = returned, + Err((_, errno)) => return Err(errno), + } + } + } + + /// Closes this descriptor and returns an error if the kernel reports one. + pub(crate) fn close(self) -> Result<(), Errno> { + nix::unistd::close(self.0) + } + + /// Borrows the descriptor for another safe wrapper in this module tree. + pub(super) fn borrowed(&self) -> BorrowedFd<'_> { + self.0.as_fd() + } + + /// Returns the raw descriptor only to the ioctl wrappers in this module tree. + pub(super) fn raw(&self) -> RawFd { + self.0.as_raw_fd() + } +} + +/// Opens `path` with typed `flags` and `mode`, returning an owned descriptor. +pub(crate) fn open(path: &CStr, flags: OpenFlags, mode: Mode) -> Result { + nix::fcntl::open(path, flags, mode).map(FileDescriptor) +} + +/// 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)] +pub(crate) fn create_fifo(path: &CStr, mode: Mode) -> Result<(), Errno> { + nix::unistd::mkfifo(path, mode) +} + +/// 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) + .ok() + .flatten() + .and_then(|size| usize::try_from(size).ok()) + .filter(|size| size.is_power_of_two()) +} diff --git a/nvx-rs/src/guest/sys/ioctl.rs b/nvx-rs/src/guest/sys/ioctl.rs new file mode 100644 index 000000000..f48274986 --- /dev/null +++ b/nvx-rs/src/guest/sys/ioctl.rs @@ -0,0 +1,93 @@ +//! Safe, operation-specific wrappers for the Linux ioctls of the guest tools. + +use std::ffi::c_int; +use std::mem::size_of; + +use super::{Errno, FileDescriptor}; + +/// Number of bytes that `nvx-reseed` adds to the entropy pool. +const SEED_BYTES: usize = 64; + +/// C layout required by `RNDADDENTROPY`. +#[repr(C)] +struct SeedPayload { + /// Number of entropy bits credited to the payload. + entropy_count: c_int, + /// Number of following seed bytes. + buf_size: c_int, + /// Seed bytes added to the kernel pool. + bytes: [u8; SEED_BYTES], +} + +nix::ioctl_read_bad!( + /// Reads the number of bytes waiting to be read: Linux `FIONREAD`, whose number `nix` + /// takes from its `libc` dependency because it defines no constant of its own. + fionread, + nix::libc::FIONREAD, + c_int +); + +nix::ioctl_write_ptr_bad!( + /// Adds entropy to the kernel pool: Linux `RNDADDENTROPY`, `_IOW('R', 0x03, int[2])`. + rnd_add_entropy, + nix::request_code_write!(b'R', 0x03, size_of::<[c_int; 2]>()), + SeedPayload +); + +nix::ioctl_none!( + /// Reseeds the kernel's CRNG: Linux `RNDRESEEDCRNG`, `_IO('R', 0x07)`. + rnd_reseed_crng, + b'R', + 0x07 +); + +/// Returns the number of bytes available for reading from `descriptor`. +/// +/// The local integer outlives `ioctl`, and the wrapper returns it only after the kernel reports +/// success, so callers never handle the raw pointer or descriptor. +pub(crate) fn console_pending(descriptor: &FileDescriptor) -> Result { + let mut pending = 0; + // SAFETY: `pending` is writable for one C integer and outlives the `FIONREAD` call. + unsafe { fionread(descriptor.raw(), &raw mut pending) }?; + Ok(pending) +} + +/// Adds a 64-byte seed with full entropy credit and asks Linux to reseed the CRNG. +/// +/// The payload owns the exact C layout consumed synchronously by `RNDADDENTROPY`. +/// `RNDRESEEDCRNG` takes no argument. If either request fails, the error of the failing request +/// is returned exactly as the C `||` expression did. +pub(crate) fn reseed_random( + descriptor: &FileDescriptor, + seed: &[u8; SEED_BYTES], +) -> Result<(), Errno> { + let payload = SeedPayload { + entropy_count: (SEED_BYTES * 8) as c_int, + buf_size: SEED_BYTES as c_int, + bytes: *seed, + }; + // SAFETY: `payload` has the kernel ABI's layout and outlives this synchronous ioctl. + unsafe { rnd_add_entropy(descriptor.raw(), &raw const payload) }?; + // SAFETY: `RNDRESEEDCRNG` reads no argument. + unsafe { rnd_reseed_crng(descriptor.raw()) }?; + Ok(()) +} + +#[cfg(test)] +mod tests { + //! ABI checks for the random-device ioctls. + + use super::*; + + #[test] + fn random_ioctl_numbers_match_linux() { + // `_IOC(direction, type, number, size)` on x86 and most other architectures. + let ioc = |direction: u32, number: u32, size: u32| { + (direction << 30) | (size << 16) | (u32::from(b'R') << 8) | number + }; + let add_entropy = nix::request_code_write!(b'R', 0x03, size_of::<[c_int; 2]>()); + assert_eq!(add_entropy as u32, ioc(1, 0x03, 2 * 4)); + assert_eq!(nix::request_code_none!(b'R', 0x07) as u32, ioc(0, 0x07, 0)); + assert_eq!(size_of::(), 8 + SEED_BYTES); + } +} diff --git a/nvx-rs/src/guest/sys/mapping.rs b/nvx-rs/src/guest/sys/mapping.rs new file mode 100644 index 000000000..0ecfe37df --- /dev/null +++ b/nvx-rs/src/guest/sys/mapping.rs @@ -0,0 +1,94 @@ +//! Owned memory mappings with explicit and best-effort unmapping. + +use std::ffi::c_void; +use std::num::NonZeroUsize; +use std::ptr::NonNull; + +use nix::sys::mman::{MapFlags, ProtFlags}; + +use super::{Errno, FileDescriptor}; + +/// One writable shared file or device mapping. +/// +/// The constructor records the nonzero mapped length and successful pointer. [`unmap`](Self::unmap) +/// consumes the mapping and reports a failure for code paths where the C tool called `perror`; +/// [`Drop`] makes every earlier return best-effort RAII cleanup. +pub(crate) struct MemoryMapping { + /// Mapping base while it remains owned. + address: Option>, + /// Number of mapped bytes. + length: NonZeroUsize, +} + +impl MemoryMapping { + /// Maps `length` bytes of `descriptor` read-write and shared at `offset`. + pub(crate) fn shared_read_write( + descriptor: &FileDescriptor, + length: NonZeroUsize, + offset: u64, + ) -> Result { + // SAFETY: the descriptor remains open for the call, `length` is nonzero, no fixed address + // is requested, and the returned mapping is owned and unmapped exactly once by this type. + let address = unsafe { + nix::sys::mman::mmap( + None, + length, + ProtFlags::PROT_READ | ProtFlags::PROT_WRITE, + MapFlags::MAP_SHARED, + descriptor.borrowed(), + offset as _, + ) + }?; + Ok(Self { + address: Some(address), + length, + }) + } + + /// Stores a native-endian 32-bit value at `offset`, even when it is unaligned. + /// + /// Returns `false` without writing unless all four bytes lie inside the mapping. The mapping + /// is writable by construction. The single x86 store mirrors the volatile C device access + /// and prevents the compiler from splitting, eliding, or combining it. + #[cfg(target_arch = "x86_64")] + pub(crate) fn write_u32(&mut self, offset: usize, value: u32) -> bool { + if offset + .checked_add(size_of::()) + .is_none_or(|end| end > self.length.get()) + { + return false; + } + let address = self.address.expect("owned mapping has an address"); + // SAFETY: the checked four-byte range lies inside this writable mapping. x86 permits an + // unaligned 32-bit store; the instruction has exactly the volatile device-store behavior + // required by the C implementation. + unsafe { + core::arch::asm!( + "mov dword ptr [{target}], {value:e}", + target = in(reg) address.as_ptr().cast::().add(offset), + value = in(reg) value, + options(nostack, preserves_flags), + ); + } + true + } + + /// Unmaps this mapping and reports the kernel error, if any. + pub(crate) fn unmap(mut self) -> Result<(), Errno> { + let address = self.address.take().expect("mapping is owned"); + // SAFETY: this type owns the exact mapping and length, removes the address before the + // call, and therefore cannot unmap it a second time from `Drop`. + unsafe { nix::sys::mman::munmap(address, self.length.get()) } + } +} + +impl Drop for MemoryMapping { + fn drop(&mut self) { + let Some(address) = self.address.take() else { + return; + }; + // SAFETY: this type still owns the exact mapping. Drop is the best-effort cleanup path, + // matching the C tools' ignored cleanup failures on earlier errors. + let _ = unsafe { nix::sys::mman::munmap(address, self.length.get()) }; + } +} diff --git a/nvx-rs/src/guest/sys/mod.rs b/nvx-rs/src/guest/sys/mod.rs new file mode 100644 index 000000000..a3acc34f8 --- /dev/null +++ b/nvx-rs/src/guest/sys/mod.rs @@ -0,0 +1,165 @@ +//! Safe operating-system interface shared by the guest tools. +//! +//! 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. + +mod fd; +mod ioctl; +mod mapping; +mod process; + +use std::ffi::CString; +use std::io::{self, Write}; +use std::os::unix::ffi::OsStringExt; + +pub(crate) use nix::errno::Errno; + +#[cfg(test)] +pub(crate) use fd::create_fifo; +pub(crate) use fd::{FileDescriptor, FileLock, Mode, OpenFlags, create_directory, open, page_size}; +pub(crate) use ioctl::{console_pending, reseed_random}; +pub(crate) use mapping::MemoryMapping; +pub(crate) use process::{Stdout, restore_sigpipe}; + +/// Returns the process arguments, program name first, as the C strings that `main` received. +/// +/// Rust exposes Unix arguments as arbitrary bytes. Converting them back to `CString` is +/// infallible for real process arguments because the kernel terminates, rather than embeds, +/// their NUL bytes. +pub(crate) fn args() -> Vec { + std::env::args_os() + .map(|argument| CString::new(argument.into_vec()).expect("arguments are C strings")) + .collect() +} + +/// Writes the concatenation of `parts` to `stream` in one write. +/// +/// The C tools wrote diagnostics through unbuffered `stderr` and ignored a diagnostic write +/// failure. This helper deliberately preserves both behaviors. +pub(crate) fn report(stream: &mut dyn Write, parts: &[&[u8]]) { + let _ = stream.write_all(&parts.concat()); +} + +/// Writes `bytes` to `stream`, flushes it, and returns the corresponding `errno` on failure. +pub(crate) fn print(stream: &mut dyn Write, bytes: &[u8]) -> Result<(), Errno> { + stream + .write_all(bytes) + .and_then(|()| stream.flush()) + .map_err(|error| errno_of(&error)) +} + +/// Returns the `errno` of an I/O error, or `EIO` for an error without one. +/// +/// 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 { + error.raw_os_error().map_or(Errno::EIO, Errno::from_raw) +} + +/// Repeats a safe system operation while it fails with `EINTR`. +/// +/// The first success or non-interruption error is returned. Callers use this only where the C +/// implementation had an explicit retry loop; operations such as seed-file reads deliberately +/// call their wrapper directly. +pub(crate) fn retry(mut operation: impl FnMut() -> Result) -> Result { + loop { + match operation() { + Err(Errno::EINTR) => {} + result => return result, + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + //! Test streams and focused checks shared by the guest tools. + + use std::io::{self, Write}; + + use super::*; + + /// A stream whose writes fail with a chosen `errno`. + pub(crate) struct Failing(pub(crate) Errno); + + impl Write for Failing { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::Error::from(self.0)) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn errno_of_takes_the_os_error_or_eio() { + let error = io::Error::from_raw_os_error(Errno::ENOSPC as i32); + assert_eq!(errno_of(&error), Errno::ENOSPC); + assert_eq!(errno_of(&io::ErrorKind::WriteZero.into()), Errno::EIO); + } + + #[test] + fn report_concatenates_and_ignores_failures() { + let mut stream = Vec::new(); + report(&mut stream, &[b"a: ", b"b", b"\n"]); + assert_eq!(stream, b"a: b\n"); + report(&mut Failing(Errno::EIO), &[b"lost\n"]); + } + + #[test] + fn print_returns_the_errno_of_a_failed_write() { + let mut stream = Vec::new(); + assert_eq!(print(&mut stream, b"7\n"), Ok(())); + assert_eq!(stream, b"7\n"); + assert_eq!(print(&mut Failing(Errno::EPIPE), b"7\n"), Err(Errno::EPIPE)); + } + + #[test] + fn retry_repeats_only_after_eintr() { + let mut results = vec![Err(Errno::EINTR), Err(Errno::EINTR), Ok(3)].into_iter(); + let mut calls = 0; + let result = retry(|| { + calls += 1; + results.next().expect("one result per call") + }); + assert_eq!((result, calls), (Ok(3), 3)); + + let mut calls = 0; + let result: Result = retry(|| { + calls += 1; + Err(Errno::EIO) + }); + assert_eq!((result, calls), (Err(Errno::EIO), 1)); + } + + #[test] + fn explicit_close_reports_errno() { + let descriptor = open(c"/dev/null", OpenFlags::O_RDONLY, Mode::empty()).unwrap(); + assert_eq!(descriptor.close(), Ok(())); + } + + #[test] + fn file_locks_are_exclusive_until_their_scope_ends() { + use nix::fcntl::{Flock, FlockArg}; + + let file = tempfile::NamedTempFile::new().unwrap(); + let path = CString::new(file.path().to_str().unwrap()).unwrap(); + let try_lock = || { + Flock::lock( + std::fs::File::open(file.path()).unwrap(), + FlockArg::LockExclusiveNonblock, + ) + }; + { + let _lock = open(&path, OpenFlags::O_RDWR, Mode::empty()) + .unwrap() + .lock_exclusive() + .unwrap(); + assert_eq!(try_lock().map(|_| ()).unwrap_err().1, Errno::EWOULDBLOCK); + } + assert!(try_lock().is_ok()); + } +} diff --git a/nvx-rs/src/guest/sys/process.rs b/nvx-rs/src/guest/sys/process.rs new file mode 100644 index 000000000..1849edda2 --- /dev/null +++ b/nvx-rs/src/guest/sys/process.rs @@ -0,0 +1,80 @@ +//! Process-runtime behavior inherited from the C guest tools. + +use std::io::{self, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use nix::sys::signal::{SigHandler, Signal}; + +/// Whether this process inherited an ignored SIGPIPE across `exec`. +static SIGPIPE_IGNORED: AtomicBool = AtomicBool::new(false); + +/// Runs before Rust's standard runtime changes SIGPIPE to `SIG_IGN`. +#[used] +#[unsafe(link_section = ".init_array")] +static CAPTURE_SIGPIPE: extern "C" fn() = capture_sigpipe; + +/// Records the inherited SIGPIPE disposition before Rust startup, and leaves it in place. +extern "C" fn capture_sigpipe() { + // SAFETY: `SIG_IGN` installs no handler. Setting it returns the previous disposition, which + // the second call restores before another constructor runs. + let Ok(inherited) = (unsafe { nix::sys::signal::signal(Signal::SIGPIPE, SigHandler::SigIgn) }) + else { + return; + }; + // SAFETY: `inherited` is the disposition that the preceding call returned. + let _ = unsafe { nix::sys::signal::signal(Signal::SIGPIPE, inherited) }; + SIGPIPE_IGNORED.store(matches!(inherited, SigHandler::SigIgn), Ordering::Relaxed); +} + +/// Restores the SIGPIPE disposition inherited across `exec`. +/// +/// POSIX preserves `SIG_IGN` across `exec` and resets caught handlers to `SIG_DFL`. Rust's +/// standard runtime changes either state to `SIG_IGN` before `main`, so the constructor above +/// records the original first. The default disposition is restored unless the constructor saw an +/// ignored one, which preserves the historical behavior if a platform fails to run it. +pub(crate) fn restore_sigpipe() { + let handler = if SIGPIPE_IGNORED.load(Ordering::Relaxed) { + SigHandler::SigIgn + } else { + SigHandler::SigDfl + }; + // SAFETY: neither `SIG_DFL` nor `SIG_IGN` installs a Rust callback. + let _ = unsafe { nix::sys::signal::signal(Signal::SIGPIPE, handler) }; +} + +/// 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 +/// destinations until `exit`, whose flush failure these tools ignored. This adapter performs the +/// write immediately but suppresses the same nonterminal errors; interrupted writes remain +/// visible so `Write::write_all` retries them. +pub(crate) struct Stdout { + /// Whether C stdio would line-buffer standard output. + terminal: bool, +} + +impl Stdout { + /// Returns an adapter for the process's standard output. + pub(crate) fn new() -> Self { + let stdout = io::stdout(); + let terminal = nix::unistd::isatty(&stdout).unwrap_or(false); + Self { terminal } + } +} + +impl Write for Stdout { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let stdout = io::stdout(); + match nix::unistd::write(&stdout, bytes) { + Ok(count) => Ok(count), + Err(error) if self.terminal || error == nix::errno::Errno::EINTR => { + Err(io::Error::from(error)) + } + Err(_) => Ok(bytes.len()), + } + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} diff --git a/nvx-rs/src/lib.rs b/nvx-rs/src/lib.rs index 2b83493af..ede8fdc59 100644 --- a/nvx-rs/src/lib.rs +++ b/nvx-rs/src/lib.rs @@ -18,6 +18,8 @@ //! supervision of VM instances: starting, reconciling, executing workloads, and stopping. //! - `testing` (feature `testing`): a fake OpenVMM that serves the control console without //! running a VM. +//! - `guest` (feature `guest`, Linux): the programs that run in NVX guests, such as +//! `nvx-console-pending`. The crate's `nvx-*` guest binaries run them. //! //! # Example //! @@ -74,6 +76,8 @@ //! or copies the release directory that `NVX_BUNDLE_DIR` names. //! - `testing`: the fake OpenVMM of `testing::fake_openvmm` and its `nvx-fake-openvmm` binary. //! - `serde`: the serde form of [`Hypervisor`]. +//! - `guest`: the `guest` module and the guest binaries, which the `guest` profile builds as +//! static `x86_64-unknown-linux-musl` executables. pub mod contract; mod error; @@ -81,6 +85,8 @@ mod hypervisor; pub mod net; pub mod protocol; +#[cfg(all(feature = "guest", target_os = "linux"))] +pub mod guest; #[cfg(feature = "host")] pub mod host; #[cfg(feature = "testing")] diff --git a/nvx-rs/tests/guest_tools.rs b/nvx-rs/tests/guest_tools.rs new file mode 100644 index 000000000..cc08e9ecc --- /dev/null +++ b/nvx-rs/tests/guest_tools.rs @@ -0,0 +1,360 @@ +//! Runs the guest binaries as programs: their command lines, exit statuses, and output. +//! +//! The tests run the binaries that Cargo builds for them, or, when `NVX_GUEST_BINARIES` names a +//! directory, the binaries there, such as the `guest`-profile builds that CI checks. +#![cfg(all(target_os = "linux", feature = "guest"))] + +use std::ffi::OsStr; +use std::fs; +use std::io; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::sync::RwLock; + +use nix::errno::Errno; + +const PORT_IO_USAGE: &[u8] = b"usage: nvx-port-io read-generation-id DATA_PORT SELECT_PORT \ +[OUTPUT] | read-u8 PORT | write-u8 PORT VALUE\n"; + +/// Returns the path of the guest binary `name`. +fn binary(name: &str) -> PathBuf { + if let Some(directory) = std::env::var_os("NVX_GUEST_BINARIES") { + return Path::new(&directory).join(name); + } + PathBuf::from(match name { + "nvx-console-pending" => env!("CARGO_BIN_EXE_nvx-console-pending"), + "nvx-mmio-write" => env!("CARGO_BIN_EXE_nvx-mmio-write"), + "nvx-port-io" => env!("CARGO_BIN_EXE_nvx-port-io"), + "nvx-reseed" => env!("CARGO_BIN_EXE_nvx-reseed"), + _ => panic!("no guest binary {name}"), + }) +} + +/// Held shared by `output_of` while it starts a child, and exclusively by `broken_pipe` while a +/// pipe's read end exists, which a child started meanwhile would keep open until it execs. Tests +/// therefore start every child through `output_of`. +static SPAWNING: RwLock<()> = RwLock::new(()); + +/// Runs `command`, spawned under `SPAWNING`, with a null stdin and a captured stderr, and +/// returns its output. +fn output_of(command: &mut Command) -> Output { + let child = { + let _spawning = SPAWNING.read().unwrap(); + command.stdin(Stdio::null()).stderr(Stdio::piped()).spawn() + }; + child + .and_then(Child::wait_with_output) + .unwrap_or_else(|error| panic!("cannot run {command:?}: {error}")) +} + +/// Runs the guest binary `name` with `arguments`, and returns its exit status, stdout, and +/// stderr. +fn run(name: &str, arguments: I) -> (Option, Vec, Vec) +where + I: IntoIterator, + S: AsRef, +{ + let Output { + status, + stdout, + stderr, + } = output_of( + Command::new(binary(name)) + .args(arguments) + .stdout(Stdio::piped()), + ); + (status.code(), stdout, stderr) +} + +/// Returns `parts` joined, with the bytes of a path where it occurs. +fn bytes(parts: &[&dyn AsRef]) -> Vec { + parts + .iter() + .flat_map(|part| part.as_ref().as_bytes().to_vec()) + .collect() +} + +/// Returns whether the tests run as root, with which the binaries would reach the host's real +/// devices. +fn is_root() -> bool { + fs::metadata("/proc/self").map_or(true, |metadata| metadata.uid() == 0) +} + +/// Returns the `errno` with which this process fails to open the device at `path`, as an +/// unprivileged guest binary does, or `None` when the tests run as root or the device opens: the +/// binaries would then reach the host's real devices. +fn device_error(path: &str, read: bool, write: bool) -> Option { + if is_root() { + return None; + } + fs::File::options() + .read(read) + .write(write) + .open(path) + .err() + .map(|error| Errno::from_raw(error.raw_os_error().expect("an OS error"))) +} + +#[test] +fn console_pending_usage() { + for arguments in [&[][..], &["a", "b"]] { + assert_eq!( + run("nvx-console-pending", arguments), + ( + Some(2), + Vec::new(), + b"usage: nvx-console-pending DEVICE\n".to_vec() + ) + ); + } +} + +/// Runs the guest binary `name` with `arguments` and its stdout on `stdout`, and returns how it +/// ended and its stderr. +fn run_to( + name: &str, + arguments: I, + stdout: impl Into, +) -> (std::process::ExitStatus, Vec) +where + I: IntoIterator, + S: AsRef, +{ + let output = output_of(Command::new(binary(name)).args(arguments).stdout(stdout)); + (output.status, output.stderr) +} + +/// Runs a guest binary after a shell sets SIGPIPE to `SIG_IGN`, preserving that disposition +/// across `exec`. +fn run_to_ignoring_sigpipe( + name: &str, + arguments: I, + stdout: impl Into, +) -> (std::process::ExitStatus, Vec) +where + I: IntoIterator, + S: AsRef, +{ + let output = output_of( + Command::new("sh") + .args(["-c", "trap '' PIPE; exec \"$@\"", "sh"]) + .arg(binary(name)) + .args(arguments) + .stdout(stdout), + ); + (output.status, output.stderr) +} + +/// Returns a pipe whose reader is closed, so that a write to it raises `SIGPIPE`. +fn broken_pipe() -> io::PipeWriter { + let _creating = SPAWNING.write().unwrap(); + // The wildcard binds nothing, so the reader closes at the end of the statement. + let (_, writer) = io::pipe().unwrap(); + writer +} + +#[test] +fn console_pending_handles_failed_writes_as_c_stdio() { + use std::os::unix::process::ExitStatusExt; + + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("console"); + fs::write(&file, b"pending").unwrap(); + // C's stdio buffers a file's stdout until exit, which ignores the failure. + let (status, stderr) = run_to( + "nvx-console-pending", + [&file], + fs::File::create("/dev/full").unwrap(), + ); + assert_eq!((status.code(), stderr), (Some(0), Vec::new())); + // The default SIGPIPE disposition ends the program at a closed pipe. + let (status, stderr) = run_to("nvx-console-pending", [&file], broken_pipe()); + assert_eq!((status.signal(), stderr), (Some(13), Vec::new())); + // An ignored disposition survives exec, so the buffered nonterminal failure is dropped. + let (status, stderr) = run_to_ignoring_sigpipe("nvx-console-pending", [&file], broken_pipe()); + assert_eq!((status.code(), stderr), (Some(0), Vec::new())); +} + +#[test] +fn console_pending_reports_the_unread_bytes() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("console"); + fs::write(&file, b"pending").unwrap(); + assert_eq!( + run("nvx-console-pending", [&file]), + (Some(0), b"7\n".to_vec(), Vec::new()) + ); + + let missing = directory.path().join("missing"); + let message = bytes(&[ + &"nvx-console-pending: open ", + &missing, + &format!(": {}\n", Errno::ENOENT.desc()), + ]); + assert_eq!( + run("nvx-console-pending", [&missing]), + (Some(1), Vec::new(), message) + ); +} + +#[test] +#[cfg(target_arch = "x86_64")] +fn mmio_write_usage_and_page_boundary() { + let usage = b"usage: nvx-mmio-write ADDRESS VALUE\n".to_vec(); + for arguments in [ + &[][..], + &["0x1000"], + &["0x1000", "1", "2"], + &["0x", "1"], + &["0x1000", "4294967296"], + &["0x1000", "-1"], + ] { + assert_eq!( + run("nvx-mmio-write", arguments), + (Some(2), Vec::new(), usage.clone()), + "{arguments:?}" + ); + } + // Guests have 4 KiB pages. + assert_eq!( + run("nvx-mmio-write", ["0xffd", "1"]), + ( + Some(1), + Vec::new(), + b"MMIO write crosses a page boundary\n".to_vec() + ) + ); +} + +#[test] +#[cfg(target_arch = "x86_64")] +fn mmio_write_reports_an_inaccessible_dev_mem() { + let Some(errno) = device_error("/dev/mem", true, true) else { + return; + }; + assert_eq!( + run("nvx-mmio-write", ["0x1000", "1"]), + ( + Some(1), + Vec::new(), + format!("open /dev/mem: {}\n", errno.desc()).into_bytes() + ) + ); +} + +#[test] +fn port_io_usage() { + for arguments in [ + &[][..], + &["read-u8"], + &["read-u8", "0x10000"], + &["write-u8", "1", "256"], + &["read-generation-id", "1"], + &["read-generation-id", "1", "2", "out", "extra"], + ] { + assert_eq!( + run("nvx-port-io", arguments), + (Some(2), Vec::new(), PORT_IO_USAGE.to_vec()), + "{arguments:?}" + ); + } +} + +#[test] +fn port_io_reports_an_inaccessible_dev_port() { + for (arguments, read, write) in [ + (&["read-u8", "0x70"][..], true, false), + (&["write-u8", "0x70", "0"], false, true), + ] { + let Some(errno) = device_error("/dev/port", read, write) else { + return; + }; + assert_eq!( + run("nvx-port-io", arguments), + ( + Some(1), + Vec::new(), + format!("nvx-port-io: open /dev/port: {}\n", errno.desc()).into_bytes() + ) + ); + } +} + +#[test] +fn reseed_usage_and_sample() { + let usage = b"usage: nvx-reseed --sample | [--generation-only] SEED_FILE \ +[PREVIOUS_GENERATION_ID]\n"; + for arguments in [&[][..], &["a", "b", "c"]] { + assert_eq!( + run("nvx-reseed", arguments), + (Some(2), Vec::new(), usage.to_vec()) + ); + } + let (status, sample, err) = run("nvx-reseed", ["--sample"]); + assert_eq!((status, err), (Some(0), Vec::new())); + let (digits, newline) = sample.split_at(64); + assert!(digits.iter().all(u8::is_ascii_hexdigit), "{sample:?}"); + assert_eq!(newline, b"\n"); + // C's stdio buffers a file's stdout until exit, which ignores the failure. + let (status, stderr) = run_to( + "nvx-reseed", + ["--sample"], + fs::File::create("/dev/full").unwrap(), + ); + assert_eq!((status.code(), stderr), (Some(0), Vec::new())); +} + +#[test] +fn reseed_checks_the_generation_id() { + let directory = tempfile::tempdir().unwrap(); + let seed = directory.path().join("seed"); + fs::write(&seed, [0x5a; 64]).unwrap(); + let previous = "00000000000000000000000000000000"; + assert_eq!( + run( + "nvx-reseed", + [ + OsStr::new("--generation-only"), + seed.as_os_str(), + OsStr::new(previous) + ] + ), + ( + Some(0), + format!("{}\n", "5a".repeat(16)).into_bytes(), + Vec::new() + ) + ); + assert_eq!( + run( + "nvx-reseed", + [seed.as_os_str(), OsStr::new(&"5A".repeat(16))] + ), + ( + Some(1), + Vec::new(), + b"nvx-reseed: VM generation ID did not change\n".to_vec() + ) + ); +} + +#[test] +fn reseed_needs_privileges_to_reseed() { + if is_root() { + return; + } + let directory = tempfile::tempdir().unwrap(); + let seed = directory.path().join("seed"); + fs::write(&seed, [0x5a; 64]).unwrap(); + // Anyone may open /dev/random for writing, but RNDADDENTROPY takes CAP_SYS_ADMIN. + assert_eq!( + run("nvx-reseed", [&seed]), + ( + Some(1), + Vec::new(), + format!("nvx-reseed: random ioctl: {}\n", Errno::EPERM.desc()).into_bytes() + ) + ); +} From 775f731dffbd6f6734d886ffea0af2c1a50cdb49 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Wed, 14 Oct 2026 12:34:43 -0300 Subject: [PATCH 3/4] nvx-rs: port nvx-exit and nvx-device-io Port the remaining two small guest tools into nvx::guest on the nix system layer that the other four use. Add owned truncate, sync, and readiness wrappers, a connected UDP socket, a standard output that writes directly and reports every failure, and a monotonic clock read with nix's clock_gettime. That reads the clock through the vDSO, whereas the C nvx-device-io made the system call, so each clock read in its timed loops costs less than in the C helper. Keep the programs' own parsers, which no library matches: nvx-exit's strict 0-255 exit code, and nvx-device-io's wrapping decimal and leading-zero dotted-quad parsers. Preserve device-io's deterministic offsets, block pattern, 384-byte result buffer, and exact statuses 0, 2, 3, 4, and 255: its setup, transfer, and output failures extend the error and exit statuses that all the tools share. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/Cargo.toml | 36 +- nvx-rs/README.md | 2 + nvx-rs/src/bin/nvx-device-io.rs | 13 + nvx-rs/src/bin/nvx-exit.rs | 13 + nvx-rs/src/guest/device_io.rs | 578 ++++++++++++++++++++++++++++++++ nvx-rs/src/guest/exit.rs | 137 ++++++++ nvx-rs/src/guest/mod.rs | 4 + nvx-rs/src/guest/status.rs | 21 +- nvx-rs/src/guest/sys/clock.rs | 41 +++ nvx-rs/src/guest/sys/fd.rs | 28 ++ nvx-rs/src/guest/sys/mod.rs | 38 ++- nvx-rs/src/guest/sys/process.rs | 17 + nvx-rs/src/guest/sys/socket.rs | 23 ++ nvx-rs/tests/guest_tools.rs | 208 ++++++++++++ 14 files changed, 1146 insertions(+), 13 deletions(-) create mode 100644 nvx-rs/src/bin/nvx-device-io.rs create mode 100644 nvx-rs/src/bin/nvx-exit.rs create mode 100644 nvx-rs/src/guest/device_io.rs create mode 100644 nvx-rs/src/guest/exit.rs create mode 100644 nvx-rs/src/guest/sys/clock.rs create mode 100644 nvx-rs/src/guest/sys/socket.rs diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index 4fc5b1e8f..b56dcc9bf 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -41,6 +41,17 @@ 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", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_LibraryLoader", + "Win32_System_Pipes", + "Win32_System_Threading", +] } + [target.'cfg(target_os = "linux")'.dependencies] nix = { version = "0.31.3", default-features = false, optional = true, features = [ "fs", @@ -59,17 +70,6 @@ nix = { version = "0.31.3", default-features = false, optional = true, features "user", ] } -[target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", optional = true, features = [ - "Win32_Foundation", - "Win32_Security", - "Win32_Storage_FileSystem", - "Win32_System_IO", - "Win32_System_LibraryLoader", - "Win32_System_Pipes", - "Win32_System_Threading", -] } - [build-dependencies] # Only the `bundled` build script reads JSON; `host`, which `bundled` enables, enables it here too. serde_json = { version = "1.0.120", optional = true } @@ -114,6 +114,20 @@ required-features = ["guest"] doc = false test = false +[[bin]] +name = "nvx-exit" +path = "src/bin/nvx-exit.rs" +required-features = ["guest"] +doc = false +test = false + +[[bin]] +name = "nvx-device-io" +path = "src/bin/nvx-device-io.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 a050041b9..b7d98a064 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -164,6 +164,8 @@ executables as `/sbin/nvx-*`. | `nvx-mmio-write ADDRESS VALUE` | Stores a 32-bit value at a physical address through `/dev/mem` (x86_64) | | `nvx-port-io COMMAND ...` | Reads or writes an I/O port through `/dev/port`, or reads the VM generation ID | | `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) | Each binary's command line, exit statuses, and byte output are compatibility contracts; the [`nvx::guest`](src/guest/mod.rs) documentation describes each diff --git a/nvx-rs/src/bin/nvx-device-io.rs b/nvx-rs/src/bin/nvx-device-io.rs new file mode 100644 index 000000000..6a7ebdab3 --- /dev/null +++ b/nvx-rs/src/bin/nvx-device-io.rs @@ -0,0 +1,13 @@ +//! `nvx-device-io`; see `nvx::guest::device_io`. + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +fn main() -> std::process::ExitCode { + nvx::guest::device_io::main() +} + +// The benchmark helper runs in NVX's x86_64 guests. Elsewhere this stub fails, so that every +// target that CI checks with `--all-features` still builds. +#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] +fn main() -> std::process::ExitCode { + std::process::ExitCode::FAILURE +} diff --git a/nvx-rs/src/bin/nvx-exit.rs b/nvx-rs/src/bin/nvx-exit.rs new file mode 100644 index 000000000..671f0ea73 --- /dev/null +++ b/nvx-rs/src/bin/nvx-exit.rs @@ -0,0 +1,13 @@ +//! `nvx-exit [CODE]`; see `nvx::guest::exit`. + +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +fn main() -> std::process::ExitCode { + nvx::guest::exit::main() +} + +// The program writes to an I/O port of NVX's x86_64 guests. Elsewhere this stub fails, so that +// every target that CI checks with `--all-features` still builds. +#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] +fn main() -> std::process::ExitCode { + std::process::ExitCode::FAILURE +} diff --git a/nvx-rs/src/guest/device_io.rs b/nvx-rs/src/guest/device_io.rs new file mode 100644 index 000000000..1bd7b8434 --- /dev/null +++ b/nvx-rs/src/guest/device_io.rs @@ -0,0 +1,578 @@ +//! `nvx-device-io`: the benchmark helper that measures file and UDP round-trip I/O. +//! +//! - `file PATH read|write DURATION SIZE direct|buffered create|existing` reads or writes +//! page-aligned 4096-byte blocks of PATH, at deterministic pseudo-random offsets within its +//! first SIZE bytes, until DURATION milliseconds have passed. +//! - `network IPV4 PORT DURATION` sends 64-byte UDP datagrams to an echo server until DURATION +//! milliseconds have passed, and counts those that come back within 100 ms. +//! +//! Each mode prints one JSON line with its operation count, bytes per operation, and elapsed +//! monotonic nanoseconds. The program exits with 0, 2 for a usage error, 3 when it cannot open, +//! connect, size, or time, 4 when a transfer or send is incomplete, or 255 when it cannot print +//! the result. It prints nothing else. + +use std::ffi::{CStr, CString}; +use std::io::Write; +use std::net::{Ipv4Addr, SocketAddrV4}; +use std::process::ExitCode; + +use super::status::{CommandError, ExitStatus}; +use super::sys::{self, Mode, OpenFlags}; + +/// Size and required alignment of each file transfer. +const BLOCK_SIZE: usize = 4096; +/// Size of the C program's fixed result buffer, which silently truncates a longer line. +const OUTPUT_SIZE: usize = 384; +/// Size of each UDP echo payload. +const PAYLOAD_SIZE: usize = 64; +/// Milliseconds for which each round trip waits for its echo. +const ECHO_TIMEOUT_MS: u16 = 100; +/// Initial state of the C program's block-offset generator. +const LCG_SEED: u64 = 0x9e37_79b9_7f4a_7c15; +/// Multiplier of its 64-bit linear congruential generator. +const LCG_MULTIPLIER: u64 = 6_364_136_223_846_793_005; +/// Increment of its 64-bit linear congruential generator. +const LCG_INCREMENT: u64 = 1_442_695_040_888_963_407; + +/// One page-aligned file block, as `O_DIRECT` transfers require. +#[repr(C, align(4096))] +struct Block([u8; BLOCK_SIZE]); + +/// Parsed `file PATH read|write DURATION SIZE direct|buffered create|existing` benchmark. +struct FileBenchmark<'a> { + /// Path of the file. + path: &'a CStr, + /// Whether transfers write rather than read. + writing: bool, + /// Requested duration in milliseconds. + duration_ms: u64, + /// Size of the file in bytes, at least one block. + size_bytes: u64, + /// Whether to open the file with `O_DIRECT`. + direct: bool, + /// Whether to create the file and set its size. + create: bool, +} + +/// Parsed `network IPV4 PORT DURATION` benchmark. +struct NetworkBenchmark { + /// Address and port of the UDP echo server. + peer: SocketAddrV4, + /// Requested duration in milliseconds. + duration_ms: u64, +} + +/// Benchmark that the command line selects. +enum Benchmark<'a> { + /// File benchmark. + File(FileBenchmark<'a>), + /// UDP echo benchmark. + Network(NetworkBenchmark), +} + +/// Operations that a timed loop completed, and the time that it took. +struct Measurement { + /// Number of completed operations. + operations: u64, + /// Elapsed monotonic nanoseconds, wrapping as the C program's arithmetic did. + elapsed_ns: u64, +} + +/// Runs `nvx-device-io` with the process's arguments; see the module documentation. +pub fn main() -> ExitCode { + sys::restore_sigpipe(); + ExitStatus::from(run(&sys::args(), &mut sys::DirectStdout)).into() +} + +/// Parses and runs one benchmark that prints its result to `out`. +fn run(args: &[CString], out: &mut dyn Write) -> Result<(), CommandError> { + match parse(args).ok_or(CommandError::Usage)? { + Benchmark::File(benchmark) => run_file(&benchmark, out), + Benchmark::Network(benchmark) => run_network(&benchmark, out), + } +} + +/// Parses a complete command line, returning `None` for the usage error. +fn parse(args: &[CString]) -> Option> { + let mode = args.get(1)?.as_bytes(); + if mode == b"file" { + let [_, _, path, operation, duration, size, access, creation] = args else { + return None; + }; + let duration_ms = parse_decimal(duration.as_bytes()).filter(|&duration| duration != 0)?; + let size_bytes = + parse_decimal(size.as_bytes()).filter(|&size| size >= BLOCK_SIZE as u64)?; + let writing = match operation.as_bytes() { + b"write" => true, + b"read" => false, + _ => return None, + }; + let direct = match access.as_bytes() { + b"direct" => true, + b"buffered" => false, + _ => return None, + }; + let create = match creation.as_bytes() { + b"create" => true, + b"existing" => false, + _ => return None, + }; + return Some(Benchmark::File(FileBenchmark { + path, + writing, + duration_ms, + size_bytes, + direct, + create, + })); + } + if mode == b"network" { + let [_, _, address, port, duration] = args else { + return None; + }; + let address = parse_ipv4(address.as_bytes())?; + let port = parse_decimal(port.as_bytes()) + .filter(|&port| port != 0 && port <= u64::from(u16::MAX))?; + let duration_ms = parse_decimal(duration.as_bytes()).filter(|&duration| duration != 0)?; + return Some(Benchmark::Network(NetworkBenchmark { + peer: SocketAddrV4::new(address, port as u16), + duration_ms, + })); + } + None +} + +/// Parses at least one decimal digit, wrapping on overflow as the C program's parser did. +/// +/// Rust's integer parsers reject an overflow instead of wrapping it, and also accept a leading +/// `+`, so the program keeps its own parser. +fn parse_decimal(text: &[u8]) -> Option { + if text.is_empty() { + return None; + } + let mut value = 0_u64; + for &byte in text { + if !byte.is_ascii_digit() { + return None; + } + value = value.wrapping_mul(10).wrapping_add(u64::from(byte - b'0')); + } + Some(value) +} + +/// Parses four decimal octets separated by dots, with any number of leading zeros. +/// +/// Rust's `Ipv4Addr` parser rejects leading zeros, so the program keeps the C program's parser. +fn parse_ipv4(text: &[u8]) -> Option { + let mut octets = [0_u8; 4]; + let mut index = 0; + let mut digits = 0_usize; + for &byte in text { + match byte { + b'.' => { + if digits == 0 || index == 3 { + return None; + } + index += 1; + digits = 0; + } + b'0'..=b'9' => { + let octet = u16::from(octets[index]) * 10 + u16::from(byte - b'0'); + octets[index] = u8::try_from(octet).ok()?; + digits += 1; + } + _ => return None, + } + } + (index == 3 && digits != 0).then_some(Ipv4Addr::from(octets)) +} + +/// Deterministic pseudo-random block offsets of the file benchmark. +struct Offsets { + /// Current state of the linear congruential generator. + state: u64, + /// Number of whole blocks in the file, at least one. + blocks: u64, +} + +impl Offsets { + /// Creates the offsets of a file with `blocks` whole blocks. + fn new(blocks: u64) -> Self { + Self { + state: LCG_SEED, + blocks, + } + } + + /// Advances the generator and returns the next byte offset. + fn next_offset(&mut self) -> u64 { + self.state = self + .state + .wrapping_mul(LCG_MULTIPLIER) + .wrapping_add(LCG_INCREMENT); + (self.state % self.blocks) * BLOCK_SIZE as u64 + } +} + +/// Fills a file block with byte `i * 31 + 17`, modulo 256. +fn fill_pattern(block: &mut [u8; BLOCK_SIZE]) { + for (index, byte) in block.iter_mut().enumerate() { + *byte = (index * 31 + 17) as u8; + } +} + +/// Fills a UDP payload with the bytes 1 through 64. +fn fill_payload(payload: &mut [u8; PAYLOAD_SIZE]) { + for (index, byte) in payload.iter_mut().enumerate() { + *byte = index as u8 + 1; + } +} + +/// Returns the monotonic clock in nanoseconds, or 0 when it cannot be read, as in the C program. +fn now() -> u64 { + sys::monotonic_ns().unwrap_or(0) +} + +/// Reads the clock that starts a timed loop, or fails the setup when it cannot be read. +fn start() -> Result { + match now() { + 0 => Err(CommandError::Setup), + started => Ok(started), + } +} + +/// Runs a file benchmark and prints its result. +fn run_file(benchmark: &FileBenchmark<'_>, out: &mut dyn Write) -> Result<(), CommandError> { + let Measurement { + operations, + elapsed_ns, + } = measure_file(benchmark)?; + let operation = if benchmark.writing { "write" } else { "read" }; + let line = result_line("file", operation, operations, BLOCK_SIZE as u64, elapsed_ns); + out.write_all(&line).map_err(|_| CommandError::Output) +} + +/// Transfers blocks of the benchmark's file until its duration has passed. +/// +/// The file closes when this returns, before the caller prints the result, and a close error is +/// ignored, as in the C program. +fn measure_file(benchmark: &FileBenchmark<'_>) -> Result { + let mut flags = if benchmark.writing { + OpenFlags::O_RDWR + } else { + OpenFlags::O_RDONLY + } | OpenFlags::O_CLOEXEC; + if benchmark.direct { + flags |= OpenFlags::O_DIRECT; + } + if benchmark.create { + flags |= OpenFlags::O_CREAT; + } + let file = sys::open(benchmark.path, flags, Mode::S_IRUSR | Mode::S_IWUSR) + .map_err(|_| CommandError::Setup)?; + if benchmark.create { + file.truncate(benchmark.size_bytes) + .map_err(|_| CommandError::Setup)?; + } + let mut block = Block([0; BLOCK_SIZE]); + fill_pattern(&mut block.0); + let mut offsets = Offsets::new(benchmark.size_bytes / BLOCK_SIZE as u64); + let started = start()?; + let duration_ns = benchmark.duration_ms.wrapping_mul(1_000_000); + let mut ended = started; + let mut operations = 0_u64; + while ended.wrapping_sub(started) < duration_ns { + let offset = offsets.next_offset(); + let transferred = if benchmark.writing { + file.pwrite(&block.0, offset) + } else { + file.pread(&mut block.0, offset) + }; + if transferred != Ok(BLOCK_SIZE) { + return Err(CommandError::Transfer); + } + operations = operations.wrapping_add(1); + if operations & 63 == 0 { + ended = now(); + } + } + ended = now(); + if benchmark.writing { + // The C program ignored the result of this flush, which starts after the timing ends. + let _ = file.sync(); + } + Ok(Measurement { + operations, + elapsed_ns: ended.wrapping_sub(started), + }) +} + +/// Runs a UDP echo benchmark and prints its result. +fn run_network(benchmark: &NetworkBenchmark, out: &mut dyn Write) -> Result<(), CommandError> { + let Measurement { + operations, + elapsed_ns, + } = measure_network(benchmark)?; + let line = result_line( + "network", + "roundtrip", + operations, + PAYLOAD_SIZE as u64, + elapsed_ns, + ); + out.write_all(&line).map_err(|_| CommandError::Output) +} + +/// Sends payloads to the benchmark's echo server, and counts their echoes, until its duration has +/// passed. +/// +/// The socket closes when this returns, before the caller prints the result, as in the C +/// program. +fn measure_network(benchmark: &NetworkBenchmark) -> Result { + let socket = sys::connect_datagram(benchmark.peer).map_err(|_| CommandError::Setup)?; + let mut payload = [0; PAYLOAD_SIZE]; + fill_payload(&mut payload); + let started = start()?; + let duration_ns = benchmark.duration_ms.wrapping_mul(1_000_000); + let mut ended = started; + let mut operations = 0_u64; + while ended.wrapping_sub(started) < duration_ns { + if socket.write(&payload) != Ok(PAYLOAD_SIZE) { + return Err(CommandError::Transfer); + } + // A failed or empty wait or receive only leaves this round trip uncounted, and the echo + // replaces the payload, as in the C program. + if socket.wait_readable(ECHO_TIMEOUT_MS) == Ok(true) + && socket.read(&mut payload) == Ok(PAYLOAD_SIZE) + { + operations = operations.wrapping_add(1); + } + ended = now(); + } + Ok(Measurement { + operations, + elapsed_ns: ended.wrapping_sub(started), + }) +} + +/// Returns the newline-terminated JSON result, truncated to the C program's 384-byte buffer. +fn result_line( + device: &str, + operation: &str, + operations: u64, + bytes_per_operation: u64, + elapsed_ns: u64, +) -> Vec { + let mut line = format!( + "{{\"device\":\"{device}\",\"operation\":\"{operation}\",\"operations\":{operations},\ + \"bytes_per_operation\":{bytes_per_operation},\"elapsed_ns\":{elapsed_ns}}}\n" + ) + .into_bytes(); + line.truncate(OUTPUT_SIZE); + line +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use crate::guest::sys::tests::Failing; + + fn args(arguments: &[&str]) -> Vec { + let mut args = vec![CString::new("nvx-device-io").unwrap()]; + args.extend( + arguments + .iter() + .map(|argument| CString::new(*argument).unwrap()), + ); + args + } + + #[test] + fn parses_decimals_that_wrap_around() { + assert_eq!(parse_decimal(b""), None); + assert_eq!(parse_decimal(b"0"), Some(0)); + assert_eq!(parse_decimal(b"0042"), Some(42)); + assert_eq!(parse_decimal(b"18446744073709551615"), Some(u64::MAX)); + assert_eq!(parse_decimal(b"18446744073709551616"), Some(0)); + assert_eq!(parse_decimal(b"18446744073709551617"), Some(1)); + for invalid in ["+1", "-1", " 1", "1 ", "0x10", "1a"] { + assert_eq!(parse_decimal(invalid.as_bytes()), None, "{invalid}"); + } + } + + #[test] + fn parses_dotted_quads() { + assert_eq!(parse_ipv4(b"127.0.0.1"), Some(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(parse_ipv4(b"10.0.2.2"), Some(Ipv4Addr::new(10, 0, 2, 2))); + assert_eq!(parse_ipv4(b"255.255.255.255"), Some(Ipv4Addr::BROADCAST)); + assert_eq!( + parse_ipv4(b"010.0.0.0001"), + Some(Ipv4Addr::new(10, 0, 0, 1)) + ); + for invalid in [ + "", + "1", + "1.2.3", + "1.2.3.4.5", + "1..2.3", + ".1.2.3", + "1.2.3.", + "256.0.0.1", + "1.2.3.4a", + "1.2.3.-4", + " 1.2.3.4", + ] { + assert_eq!(parse_ipv4(invalid.as_bytes()), None, "{invalid}"); + } + } + + #[test] + fn parses_file_benchmarks() { + let file = args(&["file", "/f", "write", "250", "8192", "direct", "create"]); + let Some(Benchmark::File(parsed)) = parse(&file) else { + panic!("not a file benchmark"); + }; + assert_eq!(parsed.path, c"/f"); + assert_eq!( + ( + parsed.writing, + parsed.duration_ms, + parsed.size_bytes, + parsed.direct, + parsed.create + ), + (true, 250, 8192, true, true) + ); + let read = ["file", "/f", "read", "1", "4096", "buffered", "existing"]; + let read_args = args(&read); + let Some(Benchmark::File(parsed)) = parse(&read_args) else { + panic!("not a file benchmark"); + }; + assert_eq!( + ( + parsed.writing, + parsed.duration_ms, + parsed.size_bytes, + parsed.direct, + parsed.create + ), + (false, 1, 4096, false, false) + ); + for (position, invalid) in [ + (3, "0"), + (3, "-1"), + (3, ""), + (4, "4095"), + (2, "readwrite"), + (2, "Read"), + (5, "cached"), + (6, "new"), + (0, "x"), + ] { + let mut arguments = read; + arguments[position] = invalid; + assert!(parse(&args(&arguments)).is_none(), "{arguments:?}"); + } + assert!(parse(&args(&read[..6])).is_none()); + assert!(parse(&args(&[&read[..], &["extra"]].concat())).is_none()); + } + + #[test] + fn parses_network_benchmarks() { + let network = ["network", "10.0.2.2", "18446744073709551617", "50"]; + let Some(Benchmark::Network(parsed)) = parse(&args(&network)) else { + panic!("not a network benchmark"); + }; + assert_eq!( + (parsed.peer, parsed.duration_ms), + (SocketAddrV4::new(Ipv4Addr::new(10, 0, 2, 2), 1), 50) + ); + for (position, invalid) in [(1, "10.0.2"), (2, "0"), (2, "65536"), (3, "0"), (0, "net")] { + let mut arguments = network; + arguments[position] = invalid; + assert!(parse(&args(&arguments)).is_none(), "{arguments:?}"); + } + assert!(parse(&args(&[])).is_none()); + assert!(parse(&args(&network[..3])).is_none()); + } + + #[test] + fn offsets_follow_the_c_generator() { + let mut offsets = Offsets::new(256); + let first: Vec = (0..4).map(|_| offsets.next_offset()).collect(); + assert_eq!(first, [0, 79 * 4096, 50 * 4096, 25 * 4096]); + let mut offsets = Offsets::new(1); + assert!((0..100).all(|_| offsets.next_offset() == 0)); + } + + #[test] + fn fills_the_patterns() { + let mut block = Block([0; BLOCK_SIZE]); + fill_pattern(&mut block.0); + assert_eq!(&block.0[..4], &[17, 48, 79, 110]); + assert_eq!(block.0[4095], (4095 * 31 + 17) as u8); + assert_eq!(align_of::(), BLOCK_SIZE); + let mut payload = [0; PAYLOAD_SIZE]; + fill_payload(&mut payload); + assert_eq!((payload[0], payload[63]), (1, 64)); + } + + #[test] + fn formats_and_truncates_the_result_line() { + assert_eq!( + result_line("file", "read", 12, 4096, 1_000_000_007), + b"{\"device\":\"file\",\"operation\":\"read\",\"operations\":12,\ + \"bytes_per_operation\":4096,\"elapsed_ns\":1000000007}\n" + ); + assert_eq!( + result_line("network", "roundtrip", 0, 64, u64::MAX), + b"{\"device\":\"network\",\"operation\":\"roundtrip\",\"operations\":0,\ + \"bytes_per_operation\":64,\"elapsed_ns\":18446744073709551615}\n" + ); + let long = "x".repeat(OUTPUT_SIZE); + let line = result_line("file", &long, 1, 2, 3); + assert_eq!(line.len(), OUTPUT_SIZE); + assert!(line.starts_with(b"{\"device\":\"file\",\"operation\":\"xxx")); + } + + #[test] + fn measures_a_file_and_reports_each_failure() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("disk"); + let path = file.to_str().unwrap(); + let mut out = Vec::new(); + let write = ["file", path, "write", "1", "8192", "buffered", "create"]; + let status = ExitStatus::from(run(&args(&write), &mut out)); + assert_eq!(status, ExitStatus::Success); + let line = String::from_utf8(out).unwrap(); + assert!( + line.starts_with("{\"device\":\"file\",\"operation\":\"write\",\"operations\":"), + "{line}" + ); + assert!( + line.contains(",\"bytes_per_operation\":4096,\"elapsed_ns\":") && line.ends_with("}\n"), + "{line}" + ); + assert_eq!(fs::metadata(&file).unwrap().len(), 8192); + + let read = ["file", path, "read", "1", "8192", "buffered", "existing"]; + let status = ExitStatus::from(run(&args(&read), &mut Failing(sys::Errno::ENOSPC))); + assert_eq!(status, ExitStatus::OutputFailure); + + let missing = directory.path().join("missing"); + let mut arguments = read; + arguments[1] = missing.to_str().unwrap(); + let status = ExitStatus::from(run(&args(&arguments), &mut Vec::new())); + assert_eq!(status, ExitStatus::SetupFailure); + + // An empty file cannot transfer a block. + fs::write(&missing, b"").unwrap(); + arguments[4] = "4096"; + let mut out = Vec::new(); + let status = ExitStatus::from(run(&args(&arguments), &mut out)); + assert_eq!(status, ExitStatus::TransferFailure); + assert!(out.is_empty()); + } +} diff --git a/nvx-rs/src/guest/exit.rs b/nvx-rs/src/guest/exit.rs new file mode 100644 index 000000000..e4349c200 --- /dev/null +++ b/nvx-rs/src/guest/exit.rs @@ -0,0 +1,137 @@ +//! `nvx-exit [CODE]`: stops the VM with an 8-bit exit code, by writing one byte to OpenVMM's +//! control port 0x604 through `/dev/port`. +//! +//! CODE defaults to 1, and so does anything other than a decimal number from 0 through 255. +//! Further arguments are ignored, and the program prints nothing. It exits with 1 when it cannot +//! write the byte. + +use std::ffi::{CStr, CString}; +use std::process::ExitCode; + +use super::status::{CommandError, ExitStatus}; +use super::sys::{self, Mode, OpenFlags}; + +/// Offset of OpenVMM's control port in `/dev/port`. +const CONTROL_PORT: u64 = 0x604; + +/// Runs `nvx-exit` with the process's arguments. It exits with 0 after it writes the byte, +/// unless the VM stops first, or with 1 when it cannot write the byte. +pub fn main() -> ExitCode { + sys::restore_sigpipe(); + ExitStatus::from(run(&sys::args(), c"/dev/port")).into() +} + +/// Writes the selected exit code through `port`, which stands for `/dev/port`. +/// +/// Any failure to open the device or write the byte is an operation failure, which the program +/// does not report. The descriptor closes when the write returns, whereas the C program left it +/// open until the process exited; neither the output nor the status differs. +fn run(args: &[CString], port: &CStr) -> Result<(), CommandError> { + let code = exit_code(args.get(1).map(|argument| argument.as_bytes())); + let written = sys::open(port, OpenFlags::O_WRONLY, Mode::empty()) + .and_then(|descriptor| descriptor.pwrite(&[code], CONTROL_PORT)); + if written == Ok(1) { + Ok(()) + } else { + Err(CommandError::Operation) + } +} + +/// Returns the VM exit code that `argument` selects. +/// +/// A nonempty decimal number from 0 through 255 is accepted, including leading zeros. A missing +/// or empty argument, a sign, whitespace, any other character, or a larger value selects 1, as in +/// the C program. Rust's `u8::from_str` also accepts a leading `+`, so the program keeps its own +/// parser. +fn exit_code(argument: Option<&[u8]>) -> u8 { + let Some(argument) = argument else { + return 1; + }; + let mut value = 0_u32; + for &byte in argument { + if !byte.is_ascii_digit() { + return 1; + } + // The function returns as soon as the value exceeds 255, so this cannot overflow. + value = value * 10 + u32::from(byte - b'0'); + if value > 255 { + return 1; + } + } + if argument.is_empty() { 1 } else { value as u8 } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::os::unix::ffi::OsStrExt; + + use super::*; + + fn args(arguments: &[&[u8]]) -> Vec { + let mut args = vec![CString::new("nvx-exit").unwrap()]; + args.extend( + arguments + .iter() + .map(|argument| CString::new(*argument).unwrap()), + ); + args + } + + #[test] + fn exit_code_takes_a_decimal_number_up_to_255() { + let cases: &[(&[&[u8]], u8)] = &[ + (&[], 1), + (&[b""], 1), + (&[b"0"], 0), + (&[b"37"], 37), + (&[b"255"], 255), + (&[b"256"], 1), + (&[b"-1"], 1), + (&[b"+5"], 1), + (&[b" 7"], 1), + (&[b"7 "], 1), + (&[b"12a"], 1), + (&[b"0x10"], 1), + (&[b"99999999999999999999"], 1), + (&[b"4294967296"], 1), + (&[b"4294967551"], 1), + (&[b"00000000000000000255"], 255), + (&[b"010"], 10), + (&[b"7", b"9"], 7), + ]; + for &(arguments, expected) in cases { + let args = args(arguments); + let code = exit_code(args.get(1).map(|argument| argument.as_bytes())); + assert_eq!(code, expected, "{arguments:?}"); + } + } + + #[test] + fn writes_one_byte_at_the_control_port() { + let file = tempfile::NamedTempFile::new().unwrap(); + let port = CString::new(file.path().as_os_str().as_bytes()).unwrap(); + let status = ExitStatus::from(run(&args(&[b"37", b"extra"]), &port)); + assert_eq!(status, ExitStatus::Success); + let mut expected = vec![0; CONTROL_PORT as usize]; + expected.push(37); + assert_eq!(fs::read(file.path()).unwrap(), expected); + assert_eq!( + ExitStatus::from(run(&args(&[]), &port)), + ExitStatus::Success + ); + expected[CONTROL_PORT as usize] = 1; + assert_eq!(fs::read(file.path()).unwrap(), expected); + } + + #[test] + fn fails_when_it_cannot_open_or_write_the_device() { + let directory = tempfile::tempdir().unwrap(); + let missing = directory.path().join("port"); + let missing = CString::new(missing.as_os_str().as_bytes()).unwrap(); + for port in [missing.as_c_str(), c"/dev/full"] { + let status = ExitStatus::from(run(&args(&[b"0"]), port)); + assert_eq!(status, ExitStatus::Failure, "{port:?}"); + } + } +} diff --git a/nvx-rs/src/guest/mod.rs b/nvx-rs/src/guest/mod.rs index 4c0694606..ef5986f14 100644 --- a/nvx-rs/src/guest/mod.rs +++ b/nvx-rs/src/guest/mod.rs @@ -29,6 +29,10 @@ mod sys; pub mod console_pending; #[cfg(target_arch = "x86_64")] +pub mod device_io; +#[cfg(target_arch = "x86_64")] +pub mod exit; +#[cfg(target_arch = "x86_64")] pub mod mmio_write; pub mod port_io; pub mod reseed; diff --git a/nvx-rs/src/guest/status.rs b/nvx-rs/src/guest/status.rs index 502bf199b..997336041 100644 --- a/nvx-rs/src/guest/status.rs +++ b/nvx-rs/src/guest/status.rs @@ -5,7 +5,8 @@ use std::process::ExitCode; /// Process exit statuses of the guest programs. /// /// A program exits with [`Success`](Self::Success), or with the status that its -/// [`CommandError`] selects. +/// [`CommandError`] selects. Only `nvx-device-io` exits with the benchmark statuses 3, 4, and +/// 255. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub(crate) enum ExitStatus { @@ -15,6 +16,12 @@ pub(crate) enum ExitStatus { Failure = 1, /// The command line was invalid. Usage = 2, + /// A benchmark could not open, connect, size, or time its device. + SetupFailure = 3, + /// A benchmark transfer or send was incomplete. + TransferFailure = 4, + /// A benchmark could not write its result. + OutputFailure = 255, } /// Typed failure of a guest program, which selects its exit status. @@ -24,6 +31,12 @@ pub(crate) enum CommandError { Usage, /// An operation failed. A program that reports failures has printed why. Operation, + /// A benchmark could not open, connect, size, or time its device. + Setup, + /// A benchmark transfer or send was incomplete. + Transfer, + /// A benchmark could not write its result. + Output, } impl From> for ExitStatus { @@ -32,6 +45,9 @@ impl From> for ExitStatus { Ok(()) => Self::Success, Err(CommandError::Usage) => Self::Usage, Err(CommandError::Operation) => Self::Failure, + Err(CommandError::Setup) => Self::SetupFailure, + Err(CommandError::Transfer) => Self::TransferFailure, + Err(CommandError::Output) => Self::OutputFailure, } } } @@ -52,6 +68,9 @@ mod tests { (Ok(()), 0), (Err(CommandError::Operation), 1), (Err(CommandError::Usage), 2), + (Err(CommandError::Setup), 3), + (Err(CommandError::Transfer), 4), + (Err(CommandError::Output), 255), ]; for (result, code) in cases { assert_eq!(ExitStatus::from(result) as u8, code, "{result:?}"); diff --git a/nvx-rs/src/guest/sys/clock.rs b/nvx-rs/src/guest/sys/clock.rs new file mode 100644 index 000000000..87016d263 --- /dev/null +++ b/nvx-rs/src/guest/sys/clock.rs @@ -0,0 +1,41 @@ +//! The monotonic clock. + +use nix::time::{ClockId, clock_gettime}; + +use super::Errno; + +/// Returns the monotonic clock in nanoseconds, wrapping as the C programs' `u64` arithmetic did. +/// +/// The C library reads this clock through the kernel's vDSO, without a system call. The C +/// `nvx-device-io` made the system call itself, so each read here costs less than there, and the +/// benchmark loops that read the clock can complete slightly more operations. The clock never +/// moves backwards, and its precision is that of the guest kernel's clocksource. +pub(crate) fn monotonic_ns() -> Result { + let time = clock_gettime(ClockId::CLOCK_MONOTONIC)?; + Ok((time.tv_sec() as u64) + .wrapping_mul(1_000_000_000) + .wrapping_add(time.tv_nsec() as u64)) +} + +#[cfg(test)] +mod tests { + //! Checks of the clock against the standard library's. + + use std::time::{Duration, Instant}; + + use super::*; + + #[test] + fn advances_with_the_standard_monotonic_clock() { + let started = monotonic_ns().unwrap(); + let instant = Instant::now(); + std::thread::sleep(Duration::from_millis(2)); + let elapsed = instant.elapsed(); + let ended = monotonic_ns().unwrap(); + assert!(started > 0); + assert!( + ended - started >= elapsed.as_nanos() as u64, + "{started} {ended} {elapsed:?}" + ); + } +} diff --git a/nvx-rs/src/guest/sys/fd.rs b/nvx-rs/src/guest/sys/fd.rs index 800aa45e0..7a1386444 100644 --- a/nvx-rs/src/guest/sys/fd.rs +++ b/nvx-rs/src/guest/sys/fd.rs @@ -5,6 +5,7 @@ use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd}; pub(crate) use nix::fcntl::OFlag as OpenFlags; use nix::fcntl::{Flock, FlockArg}; +use nix::poll::{PollFd, PollFlags}; pub(crate) use nix::sys::stat::Mode; use super::Errno; @@ -45,6 +46,33 @@ impl FileDescriptor { nix::sys::uio::pwrite(&self.0, bytes, offset as _) } + /// Sets the file's size to `length` bytes, preserving the C cast from `u64` to `off_t`. + pub(crate) fn truncate(&self, length: u64) -> Result<(), Errno> { + nix::unistd::ftruncate(&self.0, length as _) + } + + /// Writes the file's modified data and metadata to its storage device. + pub(crate) fn sync(&self) -> Result<(), Errno> { + nix::unistd::fsync(&self.0) + } + + /// Waits at most `timeout_ms` milliseconds for data to read, with one `poll` call. + /// + /// Returns whether `poll` reported `POLLIN`. An interrupted call is an error, not a retry. + pub(crate) fn wait_readable(&self, timeout_ms: u16) -> Result { + let mut descriptors = [PollFd::new(self.0.as_fd(), PollFlags::POLLIN)]; + let ready = nix::poll::poll(&mut descriptors, timeout_ms)?; + Ok(ready > 0 + && descriptors[0] + .revents() + .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 { + Self(descriptor) + } + /// Takes an exclusive advisory lock on the descriptor, retrying after `EINTR`. /// /// The descriptor closes on failure, as in the C tools. diff --git a/nvx-rs/src/guest/sys/mod.rs b/nvx-rs/src/guest/sys/mod.rs index a3acc34f8..71d71f55c 100644 --- a/nvx-rs/src/guest/sys/mod.rs +++ b/nvx-rs/src/guest/sys/mod.rs @@ -6,10 +6,13 @@ //! wrappers that establish their pointer, lifetime, and ownership invariants. Errors are `nix`'s //! [`Errno`], whose descriptions the tools print. +#[cfg(target_arch = "x86_64")] +mod clock; mod fd; mod ioctl; mod mapping; mod process; +mod socket; use std::ffi::CString; use std::io::{self, Write}; @@ -17,12 +20,15 @@ use std::os::unix::ffi::OsStringExt; pub(crate) use nix::errno::Errno; +#[cfg(target_arch = "x86_64")] +pub(crate) use clock::monotonic_ns; #[cfg(test)] pub(crate) use fd::create_fifo; pub(crate) use fd::{FileDescriptor, FileLock, Mode, OpenFlags, create_directory, open, page_size}; pub(crate) use ioctl::{console_pending, reseed_random}; pub(crate) use mapping::MemoryMapping; -pub(crate) use process::{Stdout, restore_sigpipe}; +pub(crate) use process::{DirectStdout, Stdout, restore_sigpipe}; +pub(crate) use socket::connect_datagram; /// Returns the process arguments, program name first, as the C strings that `main` received. /// @@ -162,4 +168,34 @@ pub(crate) mod tests { } assert!(try_lock().is_ok()); } + + #[test] + fn truncate_and_sync_resize_a_file() { + let file = tempfile::NamedTempFile::new().unwrap(); + let path = std::ffi::CString::new(file.path().to_str().unwrap()).unwrap(); + let descriptor = open(&path, OpenFlags::O_RDWR, Mode::empty()).unwrap(); + assert_eq!(descriptor.truncate(8192), Ok(())); + assert_eq!(descriptor.sync(), Ok(())); + assert_eq!(std::fs::metadata(file.path()).unwrap().len(), 8192); + let read_only = open(&path, OpenFlags::O_RDONLY, Mode::empty()).unwrap(); + assert!(read_only.truncate(0).is_err()); + } + + #[test] + fn connected_datagrams_wait_for_and_receive_echoes() { + let server = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let std::net::SocketAddr::V4(peer) = server.local_addr().unwrap() else { + panic!("an IPv4 server"); + }; + let client = connect_datagram(peer).unwrap(); + assert_eq!(client.wait_readable(1), Ok(false)); + assert_eq!(client.write(b"echo"), Ok(4)); + let mut buffer = [0; 8]; + let (length, source) = server.recv_from(&mut buffer).unwrap(); + assert_eq!(&buffer[..length], b"echo"); + server.send_to(b"back", source).unwrap(); + assert_eq!(client.wait_readable(5_000), Ok(true)); + assert_eq!(client.read(&mut buffer), Ok(4)); + assert_eq!(&buffer[..4], b"back"); + } } diff --git a/nvx-rs/src/guest/sys/process.rs b/nvx-rs/src/guest/sys/process.rs index 1849edda2..09fd002af 100644 --- a/nvx-rs/src/guest/sys/process.rs +++ b/nvx-rs/src/guest/sys/process.rs @@ -78,3 +78,20 @@ impl Write for Stdout { Ok(()) } } + +/// Standard output written with direct `write` calls, whose every failure is reported. +/// +/// `nvx-device-io`'s C program wrote its result with its own `write` loop rather than stdio. The +/// loop retried only `EINTR`, and failed on any other error or on a write that wrote nothing. +/// [`Write::write_all`] over this adapter does the same. +pub(crate) struct DirectStdout; + +impl Write for DirectStdout { + fn write(&mut self, bytes: &[u8]) -> io::Result { + nix::unistd::write(io::stdout(), bytes).map_err(io::Error::from) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} diff --git a/nvx-rs/src/guest/sys/socket.rs b/nvx-rs/src/guest/sys/socket.rs new file mode 100644 index 000000000..4d0b07aac --- /dev/null +++ b/nvx-rs/src/guest/sys/socket.rs @@ -0,0 +1,23 @@ +//! Connected datagram sockets. + +use std::net::SocketAddrV4; +use std::os::fd::AsRawFd; + +use nix::sys::socket::{AddressFamily, SockFlag, SockType, SockaddrIn}; + +use super::{Errno, FileDescriptor}; + +/// Creates a close-on-exec IPv4 UDP socket connected to `peer`. +/// +/// Connecting fixes the destination of every `write` and the only source from which `read` +/// receives, as the `socket` and `connect` calls of `nvx-device-io`'s C program did. +pub(crate) fn connect_datagram(peer: SocketAddrV4) -> Result { + let socket = nix::sys::socket::socket( + AddressFamily::Inet, + SockType::Datagram, + SockFlag::SOCK_CLOEXEC, + None, + )?; + nix::sys::socket::connect(socket.as_raw_fd(), &SockaddrIn::from(peer))?; + Ok(FileDescriptor::from_owned(socket)) +} diff --git a/nvx-rs/tests/guest_tools.rs b/nvx-rs/tests/guest_tools.rs index cc08e9ecc..58fedaf75 100644 --- a/nvx-rs/tests/guest_tools.rs +++ b/nvx-rs/tests/guest_tools.rs @@ -28,6 +28,8 @@ fn binary(name: &str) -> PathBuf { "nvx-mmio-write" => env!("CARGO_BIN_EXE_nvx-mmio-write"), "nvx-port-io" => env!("CARGO_BIN_EXE_nvx-port-io"), "nvx-reseed" => env!("CARGO_BIN_EXE_nvx-reseed"), + "nvx-exit" => env!("CARGO_BIN_EXE_nvx-exit"), + "nvx-device-io" => env!("CARGO_BIN_EXE_nvx-device-io"), _ => panic!("no guest binary {name}"), }) } @@ -358,3 +360,209 @@ fn reseed_needs_privileges_to_reseed() { ) ); } + +#[test] +#[cfg(target_arch = "x86_64")] +fn exit_fails_silently_without_dev_port() { + if device_error("/dev/port", false, true).is_none() { + return; + } + for arguments in [&["0"][..], &[], &["256", "extra"]] { + assert_eq!( + run("nvx-exit", arguments), + (Some(1), Vec::new(), Vec::new()), + "{arguments:?}" + ); + } +} + +/// Runs `nvx-device-io` with `arguments`, checks that it exits with `expected` and writes nothing +/// to stderr, and returns its result line, which must be the only output. +#[cfg(target_arch = "x86_64")] +fn device_io(arguments: I, expected: i32) -> Option +where + I: IntoIterator, + S: AsRef, +{ + let Output { + status, + stdout, + stderr, + } = output_of( + Command::new(binary("nvx-device-io")) + .args(arguments) + .stdout(Stdio::piped()), + ); + assert!( + status.code() == Some(expected) && stderr.is_empty(), + "expected exit status {expected} and no stderr, got {status}, stdout {:?}, stderr {:?}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr), + ); + if stdout.is_empty() { + return None; + } + let line = String::from_utf8(stdout).unwrap(); + let keys = [ + r#"{"device":""#, + r#"","operation":""#, + r#"","operations":"#, + r#","bytes_per_operation":"#, + r#","elapsed_ns":"#, + "}\n", + ]; + let mut rest = line.as_str(); + for key in keys { + let position = rest.find(key).unwrap_or_else(|| panic!("{key} in {line}")); + rest = &rest[position + key.len()..]; + } + assert_eq!(rest, "", "{line}"); + Some(serde_json::from_str(&line).unwrap()) +} + +#[test] +#[cfg(target_arch = "x86_64")] +fn device_io_usage() { + for arguments in [ + &[][..], + &["file"], + &["disk", "x"], + &["file", "f", "read", "0", "4096", "buffered", "create"], + &["file", "f", "read", "1", "4095", "buffered", "create"], + &["file", "f", "copy", "1", "4096", "buffered", "create"], + &["file", "f", "read", "1", "4096", "cached", "create"], + &[ + "file", "f", "read", "1", "4096", "buffered", "create", "extra", + ], + &["network", "127.0.0.1", "0", "1"], + &["network", "127.0.0.1", "65536", "1"], + &["network", "127.0.0.256", "1", "1"], + &["network", "127.0.0.1", "1", "0"], + ] { + assert_eq!(device_io(arguments, 2), None, "{arguments:?}"); + } +} + +#[test] +#[cfg(target_arch = "x86_64")] +fn device_io_measures_a_file() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("disk"); + let arguments = |operation: &str, creation: &str| { + let path = file.to_str().unwrap(); + ["file", path, operation, "50", "65536", "buffered", creation].map(str::to_owned) + }; + for (operation, creation) in [("write", "create"), ("read", "existing")] { + let result = device_io(arguments(operation, creation), 0).expect("a result line"); + assert_eq!(result["device"], "file"); + assert_eq!(result["operation"], operation); + assert_eq!(result["bytes_per_operation"], 4096); + assert!(result["operations"].as_u64().unwrap() > 0, "{result}"); + assert!( + result["elapsed_ns"].as_u64().unwrap() >= 50_000_000, + "{result}" + ); + } + assert_eq!(fs::metadata(&file).unwrap().len(), 65536); + let contents = fs::read(&file).unwrap(); + let pattern: Vec = (0..4096_usize) + .map(|index| (index * 31 + 17) as u8) + .collect(); + // The first offset of a 64 KiB file is its first block. + assert_eq!(&contents[..4096], pattern); + + // A missing file cannot open, and an empty one cannot transfer a block. + let missing = directory.path().join("missing"); + let mut arguments = arguments("read", "existing"); + arguments[1] = missing.to_str().unwrap().to_owned(); + assert_eq!(device_io(&arguments, 3), None); + fs::write(&missing, b"").unwrap(); + arguments[4] = "4096".to_owned(); + assert_eq!(device_io(&arguments, 4), None); +} + +#[test] +#[cfg(target_arch = "x86_64")] +fn device_io_measures_a_network_round_trip() { + use std::net::UdpSocket; + use std::sync::mpsc; + use std::thread; + use std::time::Duration; + + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let port = socket.local_addr().unwrap().port(); + let (first, received) = mpsc::channel(); + let echo = thread::spawn(move || { + let mut buffer = [0; 128]; + let mut count = 0; + // The benchmark sends until its duration passes and stops when its socket closes, so + // the echo stops at the first silence, when a receive times out. Linux does not restart + // a receive with a timeout that a signal interrupts, as the SIGCHLD of a child of a + // concurrent test can, so an interrupted receive starts over. + loop { + let (length, peer) = match socket.recv_from(&mut buffer) { + Ok(received) => received, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) => panic!("the echo cannot receive: {error}"), + }; + if count == 0 { + first.send(buffer[..length].to_vec()).unwrap(); + } + count += 1; + socket.send_to(&buffer[..length], peer).unwrap(); + socket + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + } + }); + let port = port.to_string(); + let result = device_io(["network", "127.0.0.1", &port, "100"], 0).expect("a result line"); + assert_eq!(result["device"], "network"); + assert_eq!(result["operation"], "roundtrip"); + assert_eq!(result["bytes_per_operation"], 64); + assert!(result["operations"].as_u64().unwrap() > 0, "{result}"); + assert!( + result["elapsed_ns"].as_u64().unwrap() >= 100_000_000, + "{result}" + ); + let payload: Vec = (1..=64).collect(); + assert_eq!(received.recv().unwrap(), payload); + echo.join().unwrap(); +} + +#[test] +#[cfg(target_arch = "x86_64")] +fn device_io_exits_255_when_it_cannot_print() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("disk"); + let output = output_of( + Command::new(binary("nvx-device-io")) + .args([ + "file", + file.to_str().unwrap(), + "write", + "1", + "4096", + "buffered", + ]) + .arg("create") + .stdout(fs::File::create("/dev/full").unwrap()), + ); + assert_eq!( + (output.status.code(), output.stderr), + (Some(255), Vec::new()) + ); + // A closed pipe raises SIGPIPE, whose default disposition the program keeps. + use std::os::unix::process::ExitStatusExt; + let path = file.to_str().unwrap(); + let arguments = ["file", path, "read", "1", "4096", "buffered", "existing"]; + let (status, stderr) = run_to("nvx-device-io", arguments, broken_pipe()); + assert_eq!((status.signal(), stderr), (Some(13), Vec::new())); + // With inherited SIG_IGN, write reports EPIPE and the tool returns its output-failure status. + let (status, stderr) = run_to_ignoring_sigpipe("nvx-device-io", arguments, broken_pipe()); + assert_eq!((status.code(), stderr), (Some(255), Vec::new())); +} From 0525d6ffa31fac70e2ef80fc0c59efde05c1a15b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Wed, 14 Oct 2026 12:35:09 -0300 Subject: [PATCH 4/4] ci: check, build, and test guest binaries through nvx.py Add check-guest-binaries to install the musl target for the pinned or selected Rust toolchain, run Clippy with warnings denied on every target of the guest feature, and run its musl tests against binaries built from the checkout. Add build-guest-binaries to validate the exact Rust pin, build the six PR 1 executables with one Cargo build in a fresh remapped target, suppress build IDs, check every static ELF, and atomically write source and binary SHA-256 values to a manifest. Add test-guest-binaries to run the musl integration suite against that staged inventory. Make check-nvx-rs call those commands instead of duplicating rustup and Cargo arguments in YAML, without dropping any existing gate. Register focused Python tests in validate-nvx and document the commands and guest conventions. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/actions/check-nvx-rs/action.yml | 19 ++ .github/actions/validate-nvx/action.yml | 4 +- doc/ci.md | 11 + doc/contribute.md | 2 +- doc/project-structure.md | 10 +- doc/usage.md | 55 +++ nvx-rs/README.md | 23 +- scripts/nvx.py | 63 ++++ scripts/nvx_tools/build_constants.py | 33 ++ scripts/nvx_tools/elf.py | 128 +++++++ scripts/nvx_tools/guest_binaries.py | 358 ++++++++++++++++++++ scripts/test_guest_binaries.py | 432 ++++++++++++++++++++++++ scripts/test_nvx_tools.py | 210 ++++++++++++ 13 files changed, 1335 insertions(+), 13 deletions(-) create mode 100644 scripts/nvx_tools/elf.py create mode 100644 scripts/nvx_tools/guest_binaries.py create mode 100644 scripts/test_guest_binaries.py diff --git a/.github/actions/check-nvx-rs/action.yml b/.github/actions/check-nvx-rs/action.yml index 6b05e3783..f4ff8af1c 100644 --- a/.github/actions/check-nvx-rs/action.yml +++ b/.github/actions/check-nvx-rs/action.yml @@ -114,3 +114,22 @@ runs: rustup target add --toolchain "${{ steps.toolchains.outputs.toolchain }}" aarch64-apple-darwin cargo "+${{ steps.toolchains.outputs.toolchain }}" check --locked --all-targets --all-features \ --target aarch64-apple-darwin + + # The guest binaries are static x86_64-unknown-linux-musl executables; see the README's + # guest binaries section. This step also adds the musl target that the build step needs. + - name: Lint and test the guest binaries + if: runner.os == 'Linux' + shell: bash + run: >- + python3 scripts/nvx.py check-guest-binaries + --toolchain "${{ steps.toolchains.outputs.toolchain }}" + + - name: Build and check the guest binaries + if: runner.os == 'Linux' + shell: bash + working-directory: nvx-rs + run: | + set -euo pipefail + directory="${PWD}/../build/nvx-rs-guest-binaries" + python3 ../scripts/nvx.py build-guest-binaries --output "${directory}" + python3 ../scripts/nvx.py test-guest-binaries --binaries "${directory}" diff --git a/.github/actions/validate-nvx/action.yml b/.github/actions/validate-nvx/action.yml index 87d771c00..85611cdc2 100644 --- a/.github/actions/validate-nvx/action.yml +++ b/.github/actions/validate-nvx/action.yml @@ -10,7 +10,7 @@ runs: run: | set -euo pipefail python3 -m compileall -q scripts - python3 -m unittest scripts/test_performance.py scripts/test_nvx_tools.py scripts/test_microvm_tests.py scripts/test_development_release.py scripts/test_egress_policy.py scripts/test_control_session.py scripts/test_managed_agent.py scripts/test_guest_time.py scripts/test_guest_ingress.py scripts/test_host_telemetry.py scripts/test_time_abi.py scripts/test_tool_versions.py -v + python3 -m unittest scripts/test_performance.py scripts/test_nvx_tools.py scripts/test_guest_binaries.py scripts/test_microvm_tests.py scripts/test_development_release.py scripts/test_egress_policy.py scripts/test_control_session.py scripts/test_managed_agent.py scripts/test_guest_time.py scripts/test_guest_ingress.py scripts/test_host_telemetry.py scripts/test_time_abi.py scripts/test_tool_versions.py -v python3 scripts/test_adversarial.py -v python3 .github/skills/nvx-host-connect/scripts/test_hosts.py -v python3 scripts/nvx.py --help @@ -29,7 +29,7 @@ runs: $ErrorActionPreference = "Stop" python -m compileall -q scripts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - python -m unittest scripts/test_performance.py scripts/test_nvx_tools.py scripts/test_microvm_tests.py scripts/test_development_release.py scripts/test_egress_policy.py scripts/test_control_session.py scripts/test_managed_agent.py scripts/test_guest_time.py scripts/test_guest_ingress.py scripts/test_host_telemetry.py scripts/test_time_abi.py scripts/test_tool_versions.py -v + python -m unittest scripts/test_performance.py scripts/test_nvx_tools.py scripts/test_guest_binaries.py scripts/test_microvm_tests.py scripts/test_development_release.py scripts/test_egress_policy.py scripts/test_control_session.py scripts/test_managed_agent.py scripts/test_guest_time.py scripts/test_guest_ingress.py scripts/test_host_telemetry.py scripts/test_time_abi.py scripts/test_tool_versions.py -v if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python scripts\test_adversarial.py -v if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/doc/ci.md b/doc/ci.md index 58b24ba6d..071f5caf1 100644 --- a/doc/ci.md +++ b/doc/ci.md @@ -572,6 +572,17 @@ validates bundled artifact staging with a fixture package. Both actions set `NVX_BUNDLE=skip` for `--all-features` builds, so that `bundled` stages nothing instead of downloading the pinned release. +On Linux, the `nvx-rs` job also lints and tests the +[guest binaries](../nvx-rs/README.md#guest-binaries) for +`x86_64-unknown-linux-musl`, the target that guests run, and builds them with +the `guest` profile. The action delegates the musl lint and tests to +[`nvx.py check-guest-binaries`](usage.md#check-guest-binaries), the fresh, +path-remapped build, static-ELF check, and digest manifest to +[`nvx.py build-guest-binaries`](usage.md#build-guest-binaries), +and the prebuilt-binary integration run to +`nvx.py test-guest-binaries`. The guests ship the C programs in +`guest/common/` until their build switches to these binaries. + The development release job depends on `aci-edge-sandboxes` and `nvx-rs` and requires their combined Linux/Windows results to be successful; failed, cancelled, or skipped crate checks cannot publish a release. diff --git a/doc/contribute.md b/doc/contribute.md index 10245d41c..1836ada94 100644 --- a/doc/contribute.md +++ b/doc/contribute.md @@ -74,7 +74,7 @@ the Python and shell checks. | Guest and setup shell scripts | ShellCheck, and shfmt with four-space indentation and indented `case` branches. Scripts are POSIX `sh` unless check-quality checks them as Bash. | [check-quality](../.github/actions/check-quality/action.yml) | | `scripts/setup/setup-windows-whp.ps1` | PowerShell syntax | [check-quality](../.github/actions/check-quality/action.yml) | | `aci_edge_sandboxes/` | rustfmt, Clippy and rustdoc with warnings denied, tests, and the minimum supported Rust version; see [Rust crates](ci.md#rust-crates) | [check-aci-edge-sandboxes](../.github/actions/check-aci-edge-sandboxes/action.yml) | -| `nvx-rs/` | rustfmt, Clippy and rustdoc with warnings denied, tests including a bundled-release fixture, the minimum supported Rust version, and a macOS check; see [Rust crates](ci.md#rust-crates) | [check-nvx-rs](../.github/actions/check-nvx-rs/action.yml) | +| `nvx-rs/` | rustfmt, Clippy and rustdoc with warnings denied, tests including a bundled-release fixture, the minimum supported Rust version, a macOS check, and, through `nvx.py`, the musl lint, tests, build, digest manifest, and static-ELF check of the guest binaries; see [Rust crates](ci.md#rust-crates) | [check-nvx-rs](../.github/actions/check-nvx-rs/action.yml) | | NVX CLI | Compiles `scripts/`, runs every `scripts/test_*.py` module, and checks command help, on Linux and Windows | [validate-nvx](../.github/actions/validate-nvx/action.yml) | check-quality names each shell script that it checks, and a CLI test fails diff --git a/doc/project-structure.md b/doc/project-structure.md index f97d4acf8..bbfb149d4 100644 --- a/doc/project-structure.md +++ b/doc/project-structure.md @@ -103,8 +103,9 @@ nvx/ | | |-- net/ CIDR parsing and subtraction, and hostname and guest-network forms | | |-- host/ Artifacts, launch, policy lowering, sessions, and VM instances | | |-- testing/ Fake OpenVMM (feature `testing`) -| | `-- bin/ nvx-fake-openvmm test double -| |-- tests/ Fake-OpenVMM instance lifecycle tests +| | |-- guest/ Programs that run in Linux guests (feature `guest`) +| | `-- bin/ nvx-fake-openvmm test double and the guest binaries +| |-- tests/ Fake-OpenVMM instance lifecycle and guest-binary tests | |-- build.rs Stages the bundled artifacts (feature `bundled`) | `-- artifacts.json Release package pinned for the bundled artifacts |-- openvmm/ OpenVMM Git submodule @@ -132,6 +133,7 @@ nvx/ | | |-- development_release.py Resumable development release publication | | |-- doctor.py Time ABI host qualification | | |-- egress_policy.py Egress policy compiler for OpenVMM's rule grammar +| | |-- elf.py Static-executable check of ELF binaries | | |-- guests.py Typed guest descriptors | | |-- host_telemetry.py Benchmark host provenance and Windows capture storage telemetry | | |-- host_time_probe.rs Host TSC probe that doctor builds and runs @@ -266,7 +268,9 @@ The Rust crate `nvx-rs`, whose library `nvx` holds the NVX mechanics: the authenticated control protocol of the guest agent, artifact discovery and bundled-release staging, OpenVMM's command line, the lowering of network and filesystem policies, process and platform primitives for Linux and Windows -hosts, the supervision of VM instances, and a fake OpenVMM for tests. It is a +hosts, the supervision of VM instances, and a fake OpenVMM for tests. Its +`guest` feature adds the programs that will replace the C programs of +`guest/common/`, built as static musl executables. It is a standalone crate with its own `Cargo.lock`, like `aci_edge_sandboxes`, and builds independently of the OpenVMM submodule. See its [README](../nvx-rs/README.md). diff --git a/doc/usage.md b/doc/usage.md index 7fc4af44d..1dd02842a 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -24,6 +24,9 @@ python3 scripts/nvx.py performance gate --help | --- | --- | | `init` | Initialize the OpenVMM submodule and its nested submodules. | | `build-guest` | Build the Linux kernel and selected guest artifacts. | +| `check-guest-binaries` | Lint and test the Rust guest executables for musl. | +| `build-guest-binaries` | Build and digest the pinned Rust executables for Linux guests. | +| `test-guest-binaries` | Run the musl integration tests against prebuilt Rust guest executables. | | `build-kernel` | Build the pinned and patched Linux kernel natively. | | `build-initramfs` | Build the selected Alpine, Ubuntu, or Azure Linux initramfs. | | `build-distro-layer` | Build a deterministic Ubuntu EROFS distro layer. | @@ -34,6 +37,7 @@ python3 scripts/nvx.py performance gate --help | `setup-cross-os-cache` | Install GNU tar and zstd for GitHub Actions cross-OS caches. | | `classify-ci-changes` | Select the CI suites that a list of changed files runs. | | `check-required-ci` | Validate required GitHub Actions job results. | +| `check-static-elf` | Check that executables are static ELF files without an interpreter. | | `test-openvmm-unit` | Run the OpenVMM workspace unit and documentation tests. | | `test-openvmm` | Run OpenVMM Petri VMM tests. | | `test-microvm` | Run NVX Linux and device correctness tests through OpenVMM. | @@ -121,6 +125,57 @@ variables. CI supplies the `true` or `false` suite flags that enabled by the event, repository, and suite flags, and `skipped` for jobs that are not enabled; mismatches are reported as errors and cause a nonzero exit. +### `check-static-elf` + +```text +python3 scripts/nvx.py check-static-elf PATH [PATH ...] +``` + +Prints each executable's ELF type, size, and linkage, and fails unless every +one is a 64-bit little-endian ELF executable without a program interpreter +(`PT_INTERP`) or needed libraries (`DT_NEEDED`), so that it runs in a guest +without a dynamic loader. A static position-independent executable passes. The +`nvx-rs` CI job checks the [guest binaries](../nvx-rs/README.md#guest-binaries) +with it. + +### `check-guest-binaries` + +```text +python3 scripts/nvx.py check-guest-binaries [--toolchain TOOLCHAIN] +``` + +The Linux-only check command adds the `x86_64-unknown-linux-musl` target to the +Rust toolchain that `--toolchain` names, by default the exact +`rust-toolchain.toml` pin. It then runs Clippy with warnings denied on every +target of the `guest` feature, and the feature's musl tests, whose static test +executables run on the host. It ignores an inherited `NVX_GUEST_BINARIES`, so +that the integration tests run the binaries that Cargo builds from the +checkout. The `nvx-rs` CI job runs it with the toolchain that its action +selects, before it builds the binaries. + +### `build-guest-binaries` + +```text +python3 scripts/nvx.py build-guest-binaries --output DIRECTORY +``` + +The Linux-only build command checks the exact `rust-toolchain.toml` pin and +builds the six PR 1 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. + +### `test-guest-binaries` + +```text +python3 scripts/nvx.py test-guest-binaries --binaries DIRECTORY +``` + +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. + See [Setup](setup.md) for host prerequisites. ### `materialize-kernel-provenance-inputs` diff --git a/nvx-rs/README.md b/nvx-rs/README.md index b7d98a064..a777c9440 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -170,14 +170,12 @@ executables as `/sbin/nvx-*`. Each binary's command line, exit statuses, and byte output are compatibility contracts; the [`nvx::guest`](src/guest/mod.rs) documentation describes each program. Guests run them as static `x86_64-unknown-linux-musl` executables. On -Linux, with the musl target and a C compiler driver, `cc`, Cargo builds them -into `target/x86_64-unknown-linux-musl/guest/`: +Linux, with the musl target and a C compiler driver, `cc`, `nvx.py` builds them: ```bash -cd nvx-rs rustup target add x86_64-unknown-linux-musl -cargo build --locked --profile guest --target x86_64-unknown-linux-musl \ - --no-default-features --features guest --bins +python3 scripts/nvx.py build-guest-binaries \ + --output build/nvx-rs-guest-binaries ``` ## Testing @@ -189,10 +187,21 @@ 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`. The +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: + +```bash +python3 ../scripts/nvx.py check-guest-binaries +python3 ../scripts/nvx.py test-guest-binaries \ + --binaries ../build/nvx-rs-guest-binaries +``` + +The [`check-nvx-rs`](../.github/actions/check-nvx-rs/action.yml) action runs rustfmt, Clippy with warnings denied for every feature and for the library without default features, the tests, a synthetic-release `bundled` test, rustdoc with warnings denied, the minimum supported Rust version (1.89), and a -macOS check. `python3 scripts/nvx.py test-aci-edge-sandboxes --backend kvm` +macOS check. On Linux, it also lints, builds, and tests the guest binaries +through `nvx.py`. `python3 scripts/nvx.py test-aci-edge-sandboxes --backend kvm` exercises the crate on a real hypervisor through `aci_edge_sandboxes`. diff --git a/scripts/nvx.py b/scripts/nvx.py index 62aaf5607..d224354a4 100644 --- a/scripts/nvx.py +++ b/scripts/nvx.py @@ -79,6 +79,12 @@ from nvx_tools.doctor import configure_parser as configure_doctor_parser from nvx_tools.doctor import host_cpu_signature from nvx_tools.egress_policy import compile_policy_file +from nvx_tools.elf import check_static_executables +from nvx_tools.guest_binaries import ( + build_guest_binaries, + check_guest_binaries, + test_guest_binaries, +) from nvx_tools.guests import GUEST_NAMES, guest_descriptor from nvx_tools.microvm_tests import configure_parser as configure_microvm_test_parser from nvx_tools.performance import configure_parser as configure_performance_parser @@ -295,6 +301,28 @@ def command_build_guest(args: argparse.Namespace) -> None: build_guest(_build_config(args)) +def command_check_guest_binaries(args: argparse.Namespace) -> None: + check_guest_binaries(args.toolchain) + + +def command_build_guest_binaries(args: argparse.Namespace) -> None: + result = build_guest_binaries(args.output) + print( + json.dumps( + { + "source_sha256": result.source_sha256, + "binaries": result.binaries, + }, + indent=2, + sort_keys=True, + ) + ) + + +def command_test_guest_binaries(args: argparse.Namespace) -> None: + test_guest_binaries(args.binaries) + + def command_build_kernel(args: argparse.Namespace) -> None: build_kernel( KernelBuildConfig.debug_variant() @@ -379,6 +407,10 @@ def command_check_required_ci(args: argparse.Namespace) -> None: raise ScriptError(f"{len(failures)} required CI job result(s) did not match") +def command_check_static_elf(args: argparse.Namespace) -> None: + check_static_executables(args.paths) + + def command_test_openvmm(args: argparse.Namespace) -> None: run_openvmm_tests(args.backend) @@ -939,6 +971,30 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: _add_guest_options(guest, allow_all=True) guest.set_defaults(handler=command_build_guest) + guest_binary_checks = subparsers.add_parser( + "check-guest-binaries", + help="lint and test the Rust guest binaries for musl", + ) + guest_binary_checks.add_argument( + "--toolchain", + help="Rust toolchain to use (default: the rust-toolchain.toml pin)", + ) + guest_binary_checks.set_defaults(handler=command_check_guest_binaries) + + guest_binaries = subparsers.add_parser( + "build-guest-binaries", + help="build the pinned Rust executables for Linux guests", + ) + guest_binaries.add_argument("--output", type=Path, required=True) + guest_binaries.set_defaults(handler=command_build_guest_binaries) + + guest_binary_tests = subparsers.add_parser( + "test-guest-binaries", + help="test prebuilt Rust guest executables", + ) + guest_binary_tests.add_argument("--binaries", type=Path, required=True) + guest_binary_tests.set_defaults(handler=command_test_guest_binaries) + kernel = subparsers.add_parser( "build-kernel", help="fetch, patch, and build the pinned kernel natively on Linux", @@ -1074,6 +1130,13 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: ) required_ci.set_defaults(handler=command_check_required_ci) + static_elf = subparsers.add_parser( + "check-static-elf", + help="check that executables are static ELF files without an interpreter", + ) + static_elf.add_argument("paths", nargs="+", type=Path, metavar="PATH") + static_elf.set_defaults(handler=command_check_static_elf) + openvmm_unit_tests = subparsers.add_parser( "test-openvmm-unit", help="run OpenVMM unit and documentation tests", diff --git a/scripts/nvx_tools/build_constants.py b/scripts/nvx_tools/build_constants.py index dca473bc2..3933f24b4 100644 --- a/scripts/nvx_tools/build_constants.py +++ b/scripts/nvx_tools/build_constants.py @@ -153,6 +153,39 @@ class OpenVMMBuildConstants: CONTROL_CONTRACT_REVISION: Final = "nvx-microvm-v2-control-v2" +class GuestRustBuildConstants: + """Pinned inputs and outputs of the Rust guest-binary build.""" + + DIRECTORY: Final = BuildConstants.REPO_ROOT / "nvx-rs" + MANIFEST: Final = DIRECTORY / "Cargo.toml" + LOCKFILE: Final = DIRECTORY / "Cargo.lock" + BUILD_SCRIPT: Final = DIRECTORY / "build.rs" + SOURCE_DIRECTORIES: Final = (DIRECTORY / "src",) + BUILD_RECIPE_FILES: Final = ( + BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "build_constants.py", + BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "guest_binaries.py", + ) + TOOLCHAIN_FILE: Final = BuildConstants.REPO_ROOT / "rust-toolchain.toml" + TOOLCHAIN: Final = OpenVMMBuildConstants.RUST_TOOLCHAIN + TARGET: Final = OpenVMMBuildConstants.MUSL_RUST_TARGET + PROFILE: Final = "guest" + FEATURE: Final = "guest" + BINARIES_VARIABLE: Final = "NVX_GUEST_BINARIES" + SOURCE_DIGEST_FORMAT: Final = 1 + SOURCE_DIGEST_DOMAIN: Final = "nvx-rust-guest-binaries" + BUILD_MANIFEST_NAME: Final = "manifest.json" + BUILD_MANIFEST_FORMAT: Final = 1 + BUILD_MANIFEST_DOMAIN: Final = "nvx-rust-guest-binary-build" + BINARIES: Final = ( + "nvx-console-pending", + "nvx-mmio-write", + "nvx-port-io", + "nvx-reseed", + "nvx-exit", + "nvx-device-io", + ) + + class AlpineBuildConstants: GUEST_NAME: Final = "alpine" GUEST_DIRECTORY: Final = Path("guest") / GUEST_NAME diff --git a/scripts/nvx_tools/elf.py b/scripts/nvx_tools/elf.py new file mode 100644 index 000000000..1835f39a3 --- /dev/null +++ b/scripts/nvx_tools/elf.py @@ -0,0 +1,128 @@ +"""Check that executables are static ELF files that need no program interpreter. + +The guest binaries run in initramfs images without a dynamic loader, so each +must be a 64-bit little-endian ELF executable without a ``PT_INTERP`` program +header and without ``DT_NEEDED`` entries. A static position-independent +executable has a ``PT_DYNAMIC`` segment for its own relocations, which is +allowed. +""" + +from __future__ import annotations + +import struct +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +from .common import ScriptError + +_ELF_MAGIC = b"\x7fELF" +_ELFCLASS64 = 2 +_ELFDATA2LSB = 1 +_EXECUTABLE_TYPES = {2: "EXEC", 3: "DYN"} +_PT_DYNAMIC = 2 +_PT_INTERP = 3 +_PN_XNUM = 0xFFFF +_DT_NULL = 0 +_DT_NEEDED = 1 +_HEADER = struct.Struct("<16sHHIQQQIHHHHHH") +_PROGRAM_HEADER = struct.Struct(" bool: + """Whether the executable runs without a dynamic loader or libraries.""" + return self.interpreter is None and self.needed == 0 + + def describe(self) -> str: + linkage = "static-pie" if self.dynamic else "static" + if not self.static: + details: list[str] = [] + if self.interpreter is not None: + details.append(f"interpreter {self.interpreter}") + if self.needed: + details.append(f"{self.needed} needed libraries") + linkage = "dynamic: " + ", ".join(details) + return f"{self.path}: ELF {self.elf_type}, {self.size} bytes, {linkage}" + + +def read_elf_executable(path: Path) -> ElfExecutable: + """Read the type and dynamic linkage of the ELF executable at ``path``.""" + data = path.read_bytes() + try: + return _parse(path, data) + except struct.error as error: + raise ValueError(f"{path} is a truncated ELF file") from error + + +def _parse(path: Path, data: bytes) -> ElfExecutable: + if data[:4] != _ELF_MAGIC: + raise ValueError(f"{path} is not an ELF file") + header = _HEADER.unpack_from(data) + identity: bytes = header[0] + if identity[4] != _ELFCLASS64 or identity[5] != _ELFDATA2LSB: + raise ValueError(f"{path} is not a 64-bit little-endian ELF file") + elf_type = _EXECUTABLE_TYPES.get(header[1]) + if elf_type is None: + raise ValueError(f"{path} is not an ELF executable (type {header[1]})") + program_offset: int = header[5] + program_entry_size: int = header[9] + program_count: int = header[10] + if program_count == _PN_XNUM: + raise ValueError(f"{path} uses extended program header numbering") + if program_count and program_entry_size < _PROGRAM_HEADER.size: + raise ValueError(f"{path} has malformed program headers") + interpreter = None + needed = 0 + dynamic = False + for index in range(program_count): + segment_type, _, offset, _, _, file_size, _, _ = _PROGRAM_HEADER.unpack_from( + data, program_offset + index * program_entry_size + ) + segment = data[offset : offset + file_size] + if len(segment) != file_size: + raise ValueError(f"{path} is a truncated ELF file") + if segment_type == _PT_INTERP: + interpreter = segment.split(b"\0", 1)[0].decode("utf-8", "replace") + elif segment_type == _PT_DYNAMIC: + dynamic = True + for tag, _ in _DYNAMIC_ENTRY.iter_unpack( + segment[: len(segment) - len(segment) % _DYNAMIC_ENTRY.size] + ): + if tag == _DT_NULL: + break + if tag == _DT_NEEDED: + needed += 1 + return ElfExecutable( + path=path, + size=len(data), + elf_type=elf_type, + interpreter=interpreter, + needed=needed, + dynamic=dynamic, + ) + + +def check_static_executables(paths: Sequence[Path]) -> list[ElfExecutable]: + """Describe each executable, and fail unless every one is static.""" + executables = [read_elf_executable(path) for path in paths] + for executable in executables: + print(executable.describe()) + dynamic = [ + str(executable.path) for executable in executables if not executable.static + ] + if dynamic: + raise ScriptError("not static executables: " + ", ".join(dynamic)) + return executables diff --git a/scripts/nvx_tools/guest_binaries.py b/scripts/nvx_tools/guest_binaries.py new file mode 100644 index 000000000..3b218e024 --- /dev/null +++ b/scripts/nvx_tools/guest_binaries.py @@ -0,0 +1,358 @@ +"""Check, build, identify, and test the Rust executables for Linux guests.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +from .build_constants import BuildConstants, GuestRustBuildConstants +from .common import ScriptError, require_file, require_tool, run_checked, sha256_file +from .elf import check_static_executables + + +@dataclass(frozen=True) +class GuestBinaryBuildResult: + """Source identity and executable identities of one fresh guest build.""" + + source_sha256: str + binaries: dict[str, str] + + +def guest_binary_source_files() -> tuple[Path, ...]: + """Return the complete ordered input set of the PR 1 guest executables.""" + files = { + GuestRustBuildConstants.MANIFEST, + GuestRustBuildConstants.LOCKFILE, + GuestRustBuildConstants.BUILD_SCRIPT, + GuestRustBuildConstants.TOOLCHAIN_FILE, + *GuestRustBuildConstants.BUILD_RECIPE_FILES, + } + for directory in GuestRustBuildConstants.SOURCE_DIRECTORIES: + if not directory.is_dir(): + raise ScriptError(f"nvx-rs source directory not found: {directory}") + files.update( + path + for path in directory.rglob("*") + if path.is_file() and "target" not in path.relative_to(directory).parts + ) + return tuple( + sorted( + (require_file(path, "Rust guest binary source input") for path in files), + key=lambda path: path.relative_to(BuildConstants.REPO_ROOT).as_posix(), + ) + ) + + +def guest_binary_source_sha256() -> str: + """Hash the paths and contents that determine every Rust guest executable.""" + inventory = { + "domain": GuestRustBuildConstants.SOURCE_DIGEST_DOMAIN, + "format": GuestRustBuildConstants.SOURCE_DIGEST_FORMAT, + "files": [ + { + "path": path.relative_to(BuildConstants.REPO_ROOT).as_posix(), + "sha256": sha256_file(path), + } + for path in guest_binary_source_files() + ], + } + encoded = json.dumps(inventory, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def pinned_guest_rust_toolchain() -> str: + """Read and validate the repository's exact Rust release pin.""" + contents = require_file( + GuestRustBuildConstants.TOOLCHAIN_FILE, "Rust toolchain pin" + ).read_text(encoding="utf-8") + matches = re.findall(r'^channel = "([^"]+)"$', contents, re.MULTILINE) + if len(matches) != 1: + raise ScriptError( + f"{GuestRustBuildConstants.TOOLCHAIN_FILE} must define one toolchain channel" + ) + [toolchain] = matches + if toolchain != GuestRustBuildConstants.TOOLCHAIN: + raise ScriptError( + "Rust guest build toolchain mismatch: " + f"build constants select {GuestRustBuildConstants.TOOLCHAIN}, " + f"but rust-toolchain.toml selects {toolchain}" + ) + if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", toolchain) is None: + raise ScriptError(f"Rust guest build toolchain is not exact: {toolchain}") + return toolchain + + +def _cargo_environment(target_directory: Path) -> dict[str, str]: + """Return a deterministic Cargo environment for one fresh target tree.""" + environment = os.environ.copy() + cargo_home = Path( + environment.get("CARGO_HOME", str(Path.home() / ".cargo")) + ).expanduser() + if not cargo_home.is_absolute(): + cargo_home = (GuestRustBuildConstants.DIRECTORY / cargo_home).resolve() + else: + cargo_home = cargo_home.resolve() + remap_flags = ( + f"--remap-path-prefix={BuildConstants.REPO_ROOT.resolve()}=/nvx-source", + f"--remap-path-prefix={cargo_home}=/cargo-home", + "-C", + "link-arg=-Wl,--build-id=none", + ) + environment.pop("RUSTFLAGS", None) + for name in tuple(environment): + if name.startswith("CARGO_PROFILE_"): + environment.pop(name) + environment["CARGO_ENCODED_RUSTFLAGS"] = "\x1f".join(remap_flags) + environment["CARGO_INCREMENTAL"] = "0" + environment["CARGO_TARGET_DIR"] = str(target_directory) + environment["SOURCE_DATE_EPOCH"] = "0" + return environment + + +def _guest_cargo_arguments() -> list[str]: + """Return the Cargo arguments that select the canonical guest build.""" + return [ + "--locked", + "--profile", + GuestRustBuildConstants.PROFILE, + "--target", + GuestRustBuildConstants.TARGET, + "--no-default-features", + "--features", + GuestRustBuildConstants.FEATURE, + ] + + +def _manifest(result: GuestBinaryBuildResult) -> dict[str, object]: + """Return the stable on-disk manifest for a completed build.""" + return { + "domain": GuestRustBuildConstants.BUILD_MANIFEST_DOMAIN, + "format": GuestRustBuildConstants.BUILD_MANIFEST_FORMAT, + "toolchain": GuestRustBuildConstants.TOOLCHAIN, + "target": GuestRustBuildConstants.TARGET, + "profile": GuestRustBuildConstants.PROFILE, + "source_sha256": result.source_sha256, + "binaries": result.binaries, + } + + +def _validate_output_path(output: Path) -> None: + """Reject output paths that overlap source or hold unrelated files.""" + crate = GuestRustBuildConstants.DIRECTORY.resolve() + toolchain = GuestRustBuildConstants.TOOLCHAIN_FILE.resolve() + if ( + output == crate + or output.is_relative_to(crate) + or crate.is_relative_to(output) + or toolchain == output + or toolchain.is_relative_to(output) + ): + raise ScriptError(f"guest binary output overlaps source inputs: {output}") + if output.exists(): + _validate_owned_output(output) + + +def _validate_owned_output(output: Path) -> None: + """Require an existing output to be a complete build owned by this tool.""" + if not output.is_dir(): + raise ScriptError( + f"guest binary output exists and is not a directory: {output}" + ) + expected = { + GuestRustBuildConstants.BUILD_MANIFEST_NAME, + *GuestRustBuildConstants.BINARIES, + } + actual = {entry.name for entry in output.iterdir()} + if actual != expected: + raise ScriptError(f"guest binary output is not owned by this builder: {output}") + manifest_path = output / GuestRustBuildConstants.BUILD_MANIFEST_NAME + try: + loaded: object = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise ScriptError( + f"guest binary output has an invalid build manifest: {manifest_path}" + ) from error + if not isinstance(loaded, dict): + raise ScriptError( + f"guest binary output has an invalid build manifest: {manifest_path}" + ) + manifest = cast(dict[str, object], loaded) + if ( + manifest.get("domain") != GuestRustBuildConstants.BUILD_MANIFEST_DOMAIN + or manifest.get("format") != GuestRustBuildConstants.BUILD_MANIFEST_FORMAT + ): + raise ScriptError( + f"guest binary output has an unrecognized build manifest: {manifest_path}" + ) + for binary in GuestRustBuildConstants.BINARIES: + path = output / binary + if not path.is_file() or path.is_symlink(): + raise ScriptError(f"guest binary output has an invalid executable: {path}") + + +def _install_output(staging: Path, output: Path) -> None: + """Install staging without recursively deleting an existing directory.""" + if not output.exists(): + staging.replace(output) + return + _validate_owned_output(output) + for binary in GuestRustBuildConstants.BINARIES: + (staging / binary).replace(output / binary) + (staging / GuestRustBuildConstants.BUILD_MANIFEST_NAME).replace( + output / GuestRustBuildConstants.BUILD_MANIFEST_NAME + ) + staging.rmdir() + + +def check_guest_binaries(toolchain: str | None = None) -> None: + """Lint and test the guest binaries for musl. + + ``toolchain`` names the Rust toolchain, by default the exact repository pin. + The tests run static musl executables on the host. + """ + if sys.platform != "linux": + raise ScriptError("Rust guest binaries can be checked only on Linux") + selected = toolchain or pinned_guest_rust_toolchain() + rustup = require_tool("rustup") + cargo = require_tool("cargo") + target = GuestRustBuildConstants.TARGET + guest = ( + "--locked", + "--target", + target, + "--no-default-features", + "--features", + GuestRustBuildConstants.FEATURE, + ) + commands = ( + ("clippy", *guest, "--all-targets", "--", "-D", "warnings"), + ("test", *guest), + ) + # The integration tests must run the binaries that Cargo builds from this + # checkout rather than a directory that the caller selected. + environment = os.environ.copy() + environment.pop(GuestRustBuildConstants.BINARIES_VARIABLE, None) + run_checked( + [rustup, "target", "add", "--toolchain", selected, target], + cwd=GuestRustBuildConstants.DIRECTORY, + env=environment, + ) + for command in commands: + run_checked( + [cargo, f"+{selected}", *command], + cwd=GuestRustBuildConstants.DIRECTORY, + env=environment, + ) + + +def build_guest_binaries(output: Path) -> GuestBinaryBuildResult: + """Build, statically check, digest, and stage a fresh set of executables.""" + if sys.platform != "linux": + raise ScriptError("Rust guest binaries can be built only on Linux") + if not output.name: + raise ScriptError("guest binary output must name a directory") + output = output.resolve() + _validate_output_path(output) + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix=f".{output.name}.work-", + dir=output.parent, + ignore_cleanup_errors=True, + ) as temporary: + work = Path(temporary) + target = work / "target" + staging = work / "output" + target.mkdir(parents=True) + staging.mkdir() + + source_sha256 = guest_binary_source_sha256() + toolchain = pinned_guest_rust_toolchain() + cargo = require_tool("cargo") + environment = _cargo_environment(target) + command: list[str | os.PathLike[str]] = [ + cargo, + f"+{toolchain}", + "build", + *_guest_cargo_arguments(), + ] + for binary in GuestRustBuildConstants.BINARIES: + command.extend(("--bin", binary)) + run_checked( + command, + cwd=GuestRustBuildConstants.DIRECTORY, + env=environment, + ) + + built_directory = ( + target / GuestRustBuildConstants.TARGET / GuestRustBuildConstants.PROFILE + ) + staged_paths: list[Path] = [] + for binary in GuestRustBuildConstants.BINARIES: + source = require_file( + built_directory / binary, f"built Rust guest binary {binary}" + ) + destination = staging / binary + shutil.copyfile(source, destination) + destination.chmod(0o755) + staged_paths.append(destination) + check_static_executables(staged_paths) + if guest_binary_source_sha256() != source_sha256: + raise ScriptError( + "Rust guest binary source inputs changed during the build" + ) + result = GuestBinaryBuildResult( + source_sha256=source_sha256, + binaries={ + binary: sha256_file(staging / binary) + for binary in GuestRustBuildConstants.BINARIES + }, + ) + (staging / GuestRustBuildConstants.BUILD_MANIFEST_NAME).write_text( + json.dumps(_manifest(result), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + newline="\n", + ) + _install_output(staging, output) + return result + + +def test_guest_binaries(binaries: Path) -> None: + """Run the musl integration tests against a staged guest-profile build.""" + if sys.platform != "linux": + raise ScriptError("Rust guest binaries can be tested only on Linux") + binaries = binaries.resolve() + if not binaries.is_dir(): + raise ScriptError(f"guest binary directory not found: {binaries}") + for binary in GuestRustBuildConstants.BINARIES: + require_file(binaries / binary, f"Rust guest binary {binary}") + require_file( + binaries / GuestRustBuildConstants.BUILD_MANIFEST_NAME, + "Rust guest binary build manifest", + ) + environment = os.environ.copy() + environment[GuestRustBuildConstants.BINARIES_VARIABLE] = str(binaries) + run_checked( + [ + require_tool("cargo"), + f"+{pinned_guest_rust_toolchain()}", + "test", + "--locked", + "--target", + GuestRustBuildConstants.TARGET, + "--no-default-features", + "--features", + GuestRustBuildConstants.FEATURE, + "--test", + "guest_tools", + ], + cwd=GuestRustBuildConstants.DIRECTORY, + env=environment, + ) diff --git a/scripts/test_guest_binaries.py b/scripts/test_guest_binaries.py new file mode 100644 index 000000000..3dcd940e9 --- /dev/null +++ b/scripts/test_guest_binaries.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +# pyright: reportPrivateUsage=false + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from typing import cast +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent)) +import nvx # noqa: E402 +from nvx_tools import guest_binaries # noqa: E402 +from nvx_tools.build_constants import ( # noqa: E402 + BuildConstants, + GuestRustBuildConstants, +) + + +class GuestBinaryBuildTests(unittest.TestCase): + def test_source_identity_covers_the_crate_and_the_toolchain(self): + relative = { + path.relative_to(BuildConstants.REPO_ROOT).as_posix() + for path in guest_binaries.guest_binary_source_files() + } + self.assertIn("rust-toolchain.toml", relative) + self.assertIn("nvx-rs/Cargo.toml", relative) + self.assertIn("nvx-rs/Cargo.lock", relative) + self.assertIn("nvx-rs/build.rs", relative) + self.assertIn("scripts/nvx_tools/build_constants.py", relative) + self.assertIn("scripts/nvx_tools/guest_binaries.py", relative) + self.assertIn("nvx-rs/src/guest/exit.rs", relative) + self.assertIn("nvx-rs/src/guest/device_io.rs", relative) + self.assertFalse(any("/target/" in f"/{path}/" for path in relative)) + self.assertRegex(guest_binaries.guest_binary_source_sha256(), r"^[0-9a-f]{64}$") + self.assertEqual( + guest_binaries.pinned_guest_rust_toolchain(), + GuestRustBuildConstants.TOOLCHAIN, + ) + + def test_build_uses_fresh_remapped_target_and_writes_manifest(self): + with tempfile.TemporaryDirectory( + prefix=".test-guest-binaries-", + dir=BuildConstants.REPO_ROOT, + ) as temporary: + root = Path(temporary) + crate = root / "nvx-rs" + source = crate / "src" + tools = root / "scripts" / "nvx_tools" + source.mkdir(parents=True) + tools.mkdir(parents=True) + recipe_constants = tools / "build_constants.py" + recipe_builder = tools / "guest_binaries.py" + files = { + crate / "Cargo.toml": '[package]\nname = "fixture"\n', + crate / "Cargo.lock": "# lock\n", + crate / "build.rs": "fn main() {}\n", + source / "lib.rs": "pub fn fixture() {}\n", + recipe_constants: "PROFILE = 'guest'\n", + recipe_builder: "def build(): pass\n", + root / "rust-toolchain.toml": ( + f'[toolchain]\nchannel = "{GuestRustBuildConstants.TOOLCHAIN}"\n' + ), + } + for path, contents in files.items(): + path.write_text(contents, encoding="utf-8") + output = root / "artifacts" + stale = root / ".artifacts.work" / "stale" + stale.parent.mkdir() + stale.write_text("stale", encoding="utf-8") + commands: list[list[str]] = [] + environments: list[dict[str, str]] = [] + + def run_cargo( + command: list[str | os.PathLike[str]], + *, + cwd: Path, + env: dict[str, str], + ) -> None: + rendered = [os.fspath(part) for part in command] + commands.append(rendered) + environments.append(dict(env)) + self.assertEqual(cwd, crate) + self.assertTrue(stale.exists()) + destination = ( + Path(env["CARGO_TARGET_DIR"]) + / GuestRustBuildConstants.TARGET + / GuestRustBuildConstants.PROFILE + ) + destination.mkdir(parents=True, exist_ok=True) + names = [ + rendered[index + 1] + for index, value in enumerate(rendered) + if value == "--bin" + ] + for name in names: + (destination / name).write_bytes(name.encode()) + + cargo_home = root / "cargo-home" + with ( + patch.object(guest_binaries.sys, "platform", "linux"), + patch.object(BuildConstants, "REPO_ROOT", root), + patch.object(GuestRustBuildConstants, "DIRECTORY", crate), + patch.object(GuestRustBuildConstants, "MANIFEST", crate / "Cargo.toml"), + patch.object(GuestRustBuildConstants, "LOCKFILE", crate / "Cargo.lock"), + patch.object( + GuestRustBuildConstants, "BUILD_SCRIPT", crate / "build.rs" + ), + patch.object( + GuestRustBuildConstants, + "SOURCE_DIRECTORIES", + (source,), + ), + patch.object( + GuestRustBuildConstants, + "BUILD_RECIPE_FILES", + (recipe_constants, recipe_builder), + ), + patch.object( + GuestRustBuildConstants, + "TOOLCHAIN_FILE", + root / "rust-toolchain.toml", + ), + patch.object(guest_binaries, "require_tool", return_value="cargo"), + patch.object(guest_binaries, "run_checked", side_effect=run_cargo), + patch.object( + guest_binaries, "check_static_executables" + ) as check_static, + patch.dict( + os.environ, + { + "CARGO_HOME": str(cargo_home), + "RUSTFLAGS": "host flags", + "CARGO_PROFILE_GUEST_OPT_LEVEL": "0", + "CARGO_PROFILE_GUEST_STRIP": "none", + "CARGO_PROFILE_RELEASE_LTO": "false", + }, + ), + ): + first_source = guest_binaries.guest_binary_source_sha256() + recipe_constants.write_text( + "PROFILE = 'guest'\nLTO = True\n", encoding="utf-8" + ) + self.assertNotEqual( + guest_binaries.guest_binary_source_sha256(), first_source + ) + result = guest_binaries.build_guest_binaries(output) + + # One Cargo build produces every binary from the canonical recipe. + self.assertEqual(len(commands), 1) + [command] = commands + self.assertEqual(command[:3], ["cargo", "+1.95.0", "build"]) + self.assertEqual( + [ + command[index + 1] + for index, value in enumerate(command) + if value == "--bin" + ], + list(GuestRustBuildConstants.BINARIES), + ) + self.assertIn("--locked", command) + self.assertIn(GuestRustBuildConstants.TARGET, command) + self.assertIn(GuestRustBuildConstants.PROFILE, command) + self.assertIn("--no-default-features", command) + self.assertIn(GuestRustBuildConstants.FEATURE, command) + self.assertNotIn("--", command) + for environment in environments: + self.assertEqual(environment["CARGO_INCREMENTAL"], "0") + self.assertEqual(environment["SOURCE_DATE_EPOCH"], "0") + self.assertNotIn("RUSTFLAGS", environment) + self.assertFalse( + any(name.startswith("CARGO_PROFILE_") for name in environment) + ) + encoded = environment["CARGO_ENCODED_RUSTFLAGS"].split("\x1f") + self.assertIn( + f"--remap-path-prefix={root.resolve()}=/nvx-source", encoded + ) + self.assertIn( + f"--remap-path-prefix={cargo_home.resolve()}=/cargo-home", + encoded, + ) + self.assertIn("link-arg=-Wl,--build-id=none", encoded) + self.assertEqual( + [path.name for path in check_static.call_args.args[0]], + list(GuestRustBuildConstants.BINARIES), + ) + self.assertEqual( + set(result.binaries), set(GuestRustBuildConstants.BINARIES) + ) + manifest = json.loads( + (output / GuestRustBuildConstants.BUILD_MANIFEST_NAME).read_text( + encoding="utf-8" + ) + ) + self.assertEqual(manifest["source_sha256"], result.source_sha256) + self.assertEqual(manifest["binaries"], result.binaries) + self.assertEqual( + manifest["domain"], + GuestRustBuildConstants.BUILD_MANIFEST_DOMAIN, + ) + self.assertTrue(stale.exists()) + self.assertFalse(any(root.glob(".artifacts.work-*"))) + + def test_output_must_not_overlap_sources_or_their_ancestors(self): + with tempfile.TemporaryDirectory( + prefix=".test-guest-binary-source-", + dir=BuildConstants.REPO_ROOT, + ) as temporary: + root = Path(temporary) + crate = root / "nvx-rs" + source = crate / "src" + source.mkdir(parents=True) + sentinel = source / "uncommitted.rs" + sentinel.write_text("preserve", encoding="utf-8") + toolchain = root / "rust-toolchain.toml" + toolchain.write_text('[toolchain]\nchannel = "1.95.0"\n', encoding="utf-8") + with ( + patch.object(GuestRustBuildConstants, "DIRECTORY", crate), + patch.object(GuestRustBuildConstants, "TOOLCHAIN_FILE", toolchain), + ): + for output in (root, crate, source / "output"): + with ( + self.subTest(output=output), + self.assertRaisesRegex( + guest_binaries.ScriptError, "overlaps source" + ), + ): + guest_binaries._validate_output_path(output.resolve()) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve") + + def test_unrelated_existing_output_is_never_removed(self): + with tempfile.TemporaryDirectory( + prefix=".test-guest-binary-unrelated-", + dir=BuildConstants.REPO_ROOT, + ) as temporary: + root = Path(temporary) + crate = root / "nvx-rs" + crate.mkdir() + toolchain = root / "rust-toolchain.toml" + toolchain.write_text('[toolchain]\nchannel = "1.95.0"\n', encoding="utf-8") + output = root / "output" + output.mkdir() + sentinel = output / "uncommitted.txt" + sentinel.write_text("preserve", encoding="utf-8") + with ( + patch.object(GuestRustBuildConstants, "DIRECTORY", crate), + patch.object(GuestRustBuildConstants, "TOOLCHAIN_FILE", toolchain), + self.assertRaisesRegex( + guest_binaries.ScriptError, "not owned by this builder" + ), + ): + guest_binaries._validate_output_path(output.resolve()) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve") + + def test_initialization_failure_removes_allocated_work_directory(self): + with tempfile.TemporaryDirectory( + prefix=".test-guest-binary-initialization-", + dir=BuildConstants.REPO_ROOT, + ) as temporary: + root = Path(temporary) + output = root / "output" + with ( + patch.object(guest_binaries.sys, "platform", "linux"), + patch.object( + guest_binaries, + "guest_binary_source_sha256", + side_effect=guest_binaries.ScriptError("source failure"), + ), + self.assertRaisesRegex(guest_binaries.ScriptError, "source failure"), + ): + guest_binaries.build_guest_binaries(output) + self.assertFalse(any(root.glob(".output.work-*"))) + + def test_test_command_uses_the_pinned_musl_configuration(self): + with tempfile.TemporaryDirectory( + prefix=".test-guest-binary-run-", + dir=BuildConstants.REPO_ROOT, + ) as temporary: + directory = Path(temporary) + for binary in GuestRustBuildConstants.BINARIES: + (directory / binary).write_bytes(b"binary") + (directory / GuestRustBuildConstants.BUILD_MANIFEST_NAME).write_text( + "{}\n", encoding="utf-8" + ) + with ( + patch.object(guest_binaries.sys, "platform", "linux"), + patch.object(guest_binaries, "require_tool", return_value="cargo"), + patch.object(guest_binaries, "run_checked") as run, + ): + guest_binaries.test_guest_binaries(directory) + raw_command = cast(list[str | os.PathLike[str]], run.call_args.args[0]) + command = [os.fspath(part) for part in raw_command] + self.assertEqual(command[:3], ["cargo", "+1.95.0", "test"]) + self.assertIn("--locked", command) + self.assertIn(GuestRustBuildConstants.TARGET, command) + self.assertIn("--no-default-features", command) + self.assertIn(GuestRustBuildConstants.FEATURE, command) + self.assertEqual( + run.call_args.kwargs["env"]["NVX_GUEST_BINARIES"], + str(directory.resolve()), + ) + + +def _tool(name: str) -> str: + """Stand for require_tool by returning the bare tool name.""" + return name + + +class GuestBinaryCheckTests(unittest.TestCase): + def _run_check( + self, toolchain: str | None = None + ) -> list[tuple[list[str], Path, dict[str, str]]]: + """Run the check with an inherited binaries directory, without Cargo.""" + with ( + patch.object(guest_binaries.sys, "platform", "linux"), + patch.object(guest_binaries, "require_tool", side_effect=_tool), + patch.object(guest_binaries, "run_checked") as run, + patch.dict( + os.environ, + {GuestRustBuildConstants.BINARIES_VARIABLE: "/elsewhere"}, + ), + ): + guest_binaries.check_guest_binaries(toolchain) + calls: list[tuple[list[str], Path, dict[str, str]]] = [] + for call in run.call_args_list: + command = cast(list[str | os.PathLike[str]], call.args[0]) + calls.append( + ( + [os.fspath(part) for part in command], + cast(Path, call.kwargs["cwd"]), + cast(dict[str, str], call.kwargs["env"]), + ) + ) + return calls + + def test_check_lints_and_tests_the_guest_feature_for_musl(self): + calls = self._run_check() + toolchain = GuestRustBuildConstants.TOOLCHAIN + target = GuestRustBuildConstants.TARGET + cargo = ["cargo", f"+{toolchain}"] + guest = [ + "--locked", + "--target", + target, + "--no-default-features", + "--features", + "guest", + ] + self.assertEqual( + [command for command, _, _ in calls], + [ + ["rustup", "target", "add", "--toolchain", toolchain, target], + [*cargo, "clippy", *guest, "--all-targets", "--", "-D", "warnings"], + [*cargo, "test", *guest], + ], + ) + for _, cwd, environment in calls: + self.assertEqual(cwd, GuestRustBuildConstants.DIRECTORY) + # The integration tests run the binaries built from the checkout. + self.assertNotIn(GuestRustBuildConstants.BINARIES_VARIABLE, environment) + + def test_check_uses_the_selected_toolchain_or_the_pin(self): + pin = GuestRustBuildConstants.TOOLCHAIN + for requested, expected in ((None, pin), ("", pin), ("1.96.0", "1.96.0")): + with self.subTest(requested=requested): + commands = [command for command, _, _ in self._run_check(requested)] + self.assertEqual(commands[0][3:5], ["--toolchain", expected]) + self.assertEqual( + {tuple(command[:2]) for command in commands[1:]}, + {("cargo", f"+{expected}")}, + ) + + def test_check_runs_only_on_linux(self): + with ( + patch.object(guest_binaries.sys, "platform", "win32"), + patch.object(guest_binaries, "run_checked") as run, + self.assertRaisesRegex(guest_binaries.ScriptError, "only on Linux"), + ): + guest_binaries.check_guest_binaries() + run.assert_not_called() + + +class GuestBinaryCliTests(unittest.TestCase): + def test_check_command_passes_the_selected_toolchain(self): + for argv, toolchain in ( + (["check-guest-binaries"], None), + (["check-guest-binaries", "--toolchain", "1.96.0"], "1.96.0"), + ): + with self.subTest(argv=argv): + args = nvx.parse_args(argv) + with patch.object(nvx, "check_guest_binaries") as check: + args.handler(args) + check.assert_called_once_with(toolchain) + + def test_build_command_reports_digests(self): + args = nvx.parse_args( + ["build-guest-binaries", "--output", "build/guest-binaries"] + ) + result = guest_binaries.GuestBinaryBuildResult( + source_sha256="a" * 64, + binaries={"nvx-exit": "b" * 64}, + ) + stdout = io.StringIO() + with ( + patch.object(nvx, "build_guest_binaries", return_value=result) as build, + contextlib.redirect_stdout(stdout), + ): + args.handler(args) + build.assert_called_once_with(Path("build/guest-binaries")) + self.assertEqual( + json.loads(stdout.getvalue()), + { + "source_sha256": result.source_sha256, + "binaries": result.binaries, + }, + ) + + def test_test_command_uses_the_selected_directory(self): + args = nvx.parse_args( + ["test-guest-binaries", "--binaries", "build/guest-binaries"] + ) + with patch.object(nvx, "test_guest_binaries") as test: + args.handler(args) + test.assert_called_once_with(Path("build/guest-binaries")) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index 055a3b0b6..78d32c36a 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -49,6 +49,7 @@ collect_ubuntu_sources, common, control_session, + elf, guests, release, sandbox, @@ -60,6 +61,7 @@ AzureLinuxBuildConstants, BuildConstants, DockerBuildConstants, + GuestRustBuildConstants, InitramfsBuildConstants, KernelBuildConstants, OpenVMMBuildConstants, @@ -3029,6 +3031,60 @@ def test_crate_checks_run_on_every_os(self): self.assertIn(f"uses: ./.github/actions/{action}", definition) self.assertEqual(ci.REQUIRED_CI_RESULT_ENVIRONMENTS["nvx-rs"], "NVX_RS_RESULT") + def test_nvx_rs_builds_and_checks_every_guest_binary_for_musl(self): + crate = BuildConstants.REPO_ROOT / "nvx-rs" + action = ( + BuildConstants.REPO_ROOT + / ".github" + / "actions" + / "check-nvx-rs" + / "action.yml" + ).read_text(encoding="utf-8") + manifest = (crate / "Cargo.toml").read_text(encoding="utf-8") + guest_binaries = set( + re.findall( + r'^\[\[bin\]\]\nname = "([^"]+)"\npath = "[^"]+"\n' + r'required-features = \["guest"\]$', + manifest, + re.MULTILINE, + ) + ) + self.assertIn("nvx-exit", guest_binaries) + lint_and_test = "Lint and test the guest binaries" + build_and_check = "Build and check the guest binaries" + check = _composite_action_step(action, lint_and_test) + build = _composite_action_step(action, build_and_check) + for step in (check, build): + self.assertIn("if: runner.os == 'Linux'", step) + # nvx.py owns the Cargo and rustup commands, which developers reuse. + self.assertNotIn("cargo ", step) + self.assertNotIn("rustup", step) + self.assertIn( + "python3 scripts/nvx.py check-guest-binaries " + '--toolchain "${{ steps.toolchains.outputs.toolchain }}"', + " ".join(check.split()), + ) + # The check adds the musl target that the build needs. + self.assertLess( + action.index(f"- name: {lint_and_test}"), + action.index(f"- name: {build_and_check}"), + ) + self.assertEqual(set(GuestRustBuildConstants.BINARIES), guest_binaries) + for name in guest_binaries: + module = name.removeprefix("nvx-").replace("-", "_") + root = (crate / "src" / "bin" / f"{name}.rs").read_text(encoding="utf-8") + with self.subTest(binary=name): + # Every guest binary runs the main of its nvx::guest module. + self.assertIn(f"nvx::guest::{module}::main()", root) + self.assertIn( + 'python3 ../scripts/nvx.py build-guest-binaries --output "${directory}"', + build, + ) + self.assertIn( + 'python3 ../scripts/nvx.py test-guest-binaries --binaries "${directory}"', + build, + ) + def test_required_ci_result_policy(self): always_successful = { "quality", @@ -10890,6 +10946,160 @@ def test_malformed_task_count_refuses_further_exec_until_verified_empty(self): self.assertEqual(self._check("settle")[0], 0) +def _elf_file( + segments: tuple[tuple[int, bytes], ...] = ((1, b"code"),), + *, + elf_type: int = 2, + elf_class: int = 2, +) -> bytes: + """Returns a minimal ELF file with one program header for each segment.""" + header_size, entry_size = 64, 56 + payload_offset = header_size + entry_size * len(segments) + program_headers = b"" + payloads = b"" + for segment_type, payload in segments: + program_headers += struct.pack( + " bytes: + return b"".join(struct.pack(" Path: + path = self.root / name + path.write_bytes(data) + return path + + def test_static_and_static_pie_executables_pass(self): + executable = elf.read_elf_executable(self._write("exit", _elf_file())) + self.assertTrue(executable.static) + self.assertEqual( + ( + executable.elf_type, + executable.interpreter, + executable.needed, + executable.dynamic, + ), + ("EXEC", None, 0, False), + ) + self.assertEqual( + executable.describe(), + f"{executable.path}: ELF EXEC, {executable.size} bytes, static", + ) + # A static PIE relocates itself through its dynamic section; entries + # after DT_NULL do not count. + dynamic = _dynamic_entries((7, 0x1000), (0, 0), (1, 1)) + pie = elf.read_elf_executable( + self._write("pie", _elf_file(((1, b"code"), (2, dynamic)), elf_type=3)) + ) + self.assertTrue(pie.static) + self.assertEqual((pie.elf_type, pie.dynamic), ("DYN", True)) + self.assertTrue(pie.describe().endswith(" bytes, static-pie")) + + def test_an_interpreter_or_a_needed_library_fails(self): + dynamic = elf.read_elf_executable( + self._write( + "dynamic", + _elf_file(((1, b"code"), self.INTERPRETER, self.NEEDED), elf_type=3), + ) + ) + self.assertFalse(dynamic.static) + self.assertEqual( + (dynamic.interpreter, dynamic.needed), ("/lib/ld-musl-x86_64.so.1", 1) + ) + self.assertIn( + "dynamic: interpreter /lib/ld-musl-x86_64.so.1, 1 needed libraries", + dynamic.describe(), + ) + needed = elf.read_elf_executable( + self._write("needed", _elf_file(((1, b"code"), self.NEEDED), elf_type=3)) + ) + self.assertFalse(needed.static) + + def test_rejects_files_that_are_not_64_bit_executables(self): + truncated_segment = _elf_file(((1, b"code"), self.INTERPRETER))[:-4] + for name, data in ( + ("script", b"#!/bin/sh\nexit 0\n"), + ("elf32", _elf_file(elf_class=1)), + ("object", _elf_file(elf_type=1)), + ("header", _elf_file()[:40]), + ("segment", truncated_segment), + ): + with self.subTest(name=name), self.assertRaises(ValueError): + elf.read_elf_executable(self._write(name, data)) + + def test_check_lists_every_executable_and_fails_on_dynamic_ones(self): + static = self._write("static", _elf_file()) + dynamic = self._write("dynamic", _elf_file(((1, b"code"), self.INTERPRETER))) + output = io.StringIO() + with ( + contextlib.redirect_stdout(output), + self.assertRaisesRegex( + common.ScriptError, "not static executables: .*dynamic" + ), + ): + elf.check_static_executables([static, dynamic]) + self.assertIn(f"{static}: ELF EXEC", output.getvalue()) + self.assertIn(f"{dynamic}: ELF EXEC", output.getvalue()) + + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(nvx.main(["check-static-elf", str(static)]), 0) + self.assertEqual( + output.getvalue(), + f"{static}: ELF EXEC, {static.stat().st_size} bytes, static\n", + ) + with ( + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()) as error, + ): + self.assertEqual( + nvx.main(["check-static-elf", str(static), str(dynamic)]), 1 + ) + self.assertIn("not static executables", error.getvalue()) + + class GuestExitTests(unittest.TestCase): """nvx-exit's arguments, and its single one-byte write at port 0x604."""