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