lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit d00b35c0e3d84b87f140ac2fb3080be96c409dc9
parent 7538aebccf4f868fb6ac57f42e5e9a813624b11b
Author: triesap <tyson@radroots.org>
Date:   Sat,  5 Sep 2026 02:15:17 +0000

xtask: bound hermetic subprocess execution

Diffstat:
MCargo.lock | 1+
Acontracts/architecture/decisions/services_hardening_bounded_process.v1.json | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/Cargo.toml | 1+
Atools/xtask/src/bounded_process.rs | 1326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/main.rs | 6++++++
Atools/xtask/tests/services_hardening_bounded_process_decision.rs | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1550 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -6106,6 +6106,7 @@ dependencies = [ "radroots_identity_bindings", "radroots_protocol", "radroots_replica_schema_bindings", + "rustix 1.1.4", "semver", "serde", "serde_json", diff --git a/contracts/architecture/decisions/services_hardening_bounded_process.v1.json b/contracts/architecture/decisions/services_hardening_bounded_process.v1.json @@ -0,0 +1,89 @@ +{ + "schema": "radroots.services-hardening.bounded-process-decisions.v1", + "contract_version": 1, + "decision_state": "active", + "owner": "tools/xtask", + "self_test_command": "cargo xtask bounded-process-self-test", + "platform_scope": { + "implementation": [ + "macos_aarch64", + "linux_x86_64" + ], + "source_gate": [ + "macos_aarch64" + ], + "cross_platform_promotion_owner": "step-297" + }, + "process_model": { + "child_process_group": "new_group_with_child_as_leader", + "stdin": "closed_devnull", + "stdout": "concurrently_drained_live_byte_cap", + "stderr": "concurrently_drained_live_byte_cap", + "deadline_clock": "monotonic", + "normal_leader_exit": "clean_remaining_process_group", + "failure_cleanup": "term_group_bounded_grace_then_kill_group_and_bounded_reap", + "unsupported_platform": "fail_closed" + }, + "hard_maximums": { + "deadline_seconds": 86400, + "termination_grace_seconds": 5, + "stdout_bytes": 67108864, + "stderr_bytes": 67108864, + "environment_entries": 64, + "environment_name_bytes": 128, + "environment_value_bytes": 65536 + }, + "environment": { + "inheritance": "replace_with_explicit_allowlist", + "ambient_snapshot": false, + "duplicate_name": "reject", + "invalid_name_or_nul": "reject", + "forbidden_names": [ + "CARGO_ENCODED_RUSTFLAGS", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "LD_LIBRARY_PATH", + "LD_PRELOAD", + "NIX_CONFIG", + "NIX_PATH", + "NIXPKGS_ALLOW_BROKEN", + "NIXPKGS_ALLOW_UNFREE", + "RUSTFLAGS" + ], + "forbidden_name_patterns": [ + "*CREDENTIAL*", + "*KEY*", + "*PASSWORD*", + "*SECRET*", + "*TOKEN*" + ], + "equivalent_build_controls": [ + "CARGO_BUILD_RUSTFLAGS", + "CARGO_TARGET_*_RUSTFLAGS", + "DYLD_*", + "LD_AUDIT", + "LD_DEBUG", + "LD_PROFILE", + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "RUSTDOCFLAGS" + ] + }, + "diagnostic_safety": { + "program_argv_cwd": "redacted", + "environment_values": "redacted", + "captured_stream_bytes": "redacted", + "operating_system_error_text": "redacted", + "source_error_chain": "absent" + }, + "required_vectors": [ + "timeout", + "orphan_child", + "stdout_cap", + "stderr_cap", + "closed_stdin", + "inherited_build_environment", + "loader_injection", + "redaction" + ] +} diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml @@ -29,6 +29,7 @@ radroots_identity = { workspace = true } radroots_identity_bindings = { workspace = true } radroots_protocol = { workspace = true, features = ["std"] } radroots_replica_schema_bindings = { workspace = true } +rustix = { workspace = true, features = ["event"] } semver = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/tools/xtask/src/bounded_process.rs b/tools/xtask/src/bounded_process.rs @@ -0,0 +1,1326 @@ +//! Hermetic, resource-bounded subprocess execution for xtask. +//! +//! This module owns command construction so callers cannot bypass replacement +//! environment, closed-stdin, process-group, deadline, or stream-cap policy. + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::io; +use std::path::PathBuf; +use std::process::ExitStatus; +use std::time::Duration; + +const DEFAULT_DEADLINE: Duration = Duration::from_secs(60); +const DEFAULT_STREAM_LIMIT: usize = 1024 * 1024; +const DEFAULT_TERMINATION_GRACE: Duration = Duration::from_millis(250); +const MAX_DEADLINE: Duration = Duration::from_secs(86_400); +const MAX_TERMINATION_GRACE: Duration = Duration::from_secs(5); +const MAX_STREAM_LIMIT: usize = 67_108_864; +const MAX_ENVIRONMENT_ENTRIES: usize = 64; +const MAX_ENVIRONMENT_NAME_BYTES: usize = 128; +const MAX_ENVIRONMENT_VALUE_BYTES: usize = 65_536; + +const FORBIDDEN_ENVIRONMENT_NAMES: [&str; 10] = [ + "CARGO_ENCODED_RUSTFLAGS", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "LD_LIBRARY_PATH", + "LD_PRELOAD", + "NIX_CONFIG", + "NIX_PATH", + "NIXPKGS_ALLOW_BROKEN", + "NIXPKGS_ALLOW_UNFREE", + "RUSTFLAGS", +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EnvironmentRejection { + DuplicateName, + ForbiddenControl, + InvalidNameOrNul, + SensitiveName, + TooManyEntries, + ValueTooLarge, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum OutputLimitBreach { + Stdout, + Stderr, + Both, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProcessFailureKind { + CleanupIncomplete, + DeadlineExceeded, + EnvironmentRejected(EnvironmentRejection), + InvalidConfiguration, + OrphanedDescendants, + OutputLimitExceeded, + PipeSetup, + Poll, + Read, + Signal, + Spawn, + #[cfg_attr(unix, allow(dead_code))] + UnsupportedPlatform, + Wait, +} + +/// A deliberately redacted process failure. +pub(crate) struct ProcessError { + kind: ProcessFailureKind, + io_kind: Option<io::ErrorKind>, + output_limit: Option<OutputLimitBreach>, +} + +impl ProcessError { + fn new(kind: ProcessFailureKind) -> Self { + Self { + kind, + io_kind: None, + output_limit: None, + } + } + + fn from_io(kind: ProcessFailureKind, error: &io::Error) -> Self { + Self { + kind, + io_kind: Some(error.kind()), + output_limit: None, + } + } + + #[cfg(unix)] + fn from_errno(kind: ProcessFailureKind, error: rustix::io::Errno) -> Self { + Self { + kind, + io_kind: Some(error.kind()), + output_limit: None, + } + } + + fn output_limit(breach: OutputLimitBreach) -> Self { + Self { + kind: ProcessFailureKind::OutputLimitExceeded, + io_kind: None, + output_limit: Some(breach), + } + } + + pub(crate) fn kind(&self) -> ProcessFailureKind { + self.kind + } + + pub(crate) fn output_limit_breach(&self) -> Option<OutputLimitBreach> { + self.output_limit + } +} + +impl fmt::Debug for ProcessError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProcessError") + .field("kind", &self.kind) + .field("io_kind", &self.io_kind) + .field("output_limit", &self.output_limit) + .finish() + } +} + +impl fmt::Display for ProcessError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self.kind { + ProcessFailureKind::CleanupIncomplete => { + "bounded process cleanup did not reach a verified terminal state" + } + ProcessFailureKind::DeadlineExceeded => "bounded process deadline exceeded", + ProcessFailureKind::EnvironmentRejected(_) => { + "bounded process replacement environment was rejected" + } + ProcessFailureKind::InvalidConfiguration => { + "bounded process configuration was rejected" + } + ProcessFailureKind::OrphanedDescendants => { + "bounded process leader exited while descendants remained" + } + ProcessFailureKind::OutputLimitExceeded => "bounded process output limit exceeded", + ProcessFailureKind::PipeSetup => "bounded process pipe setup failed", + ProcessFailureKind::Poll => "bounded process polling failed", + ProcessFailureKind::Read => "bounded process output read failed", + ProcessFailureKind::Signal => "bounded process group signaling failed", + ProcessFailureKind::Spawn => "bounded process spawn failed", + ProcessFailureKind::UnsupportedPlatform => { + "bounded process groups are unsupported on this platform" + } + ProcessFailureKind::Wait => "bounded process wait failed", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for ProcessError {} + +/// An explicit replacement environment. No ambient snapshot is ever taken. +#[derive(Clone, Default)] +pub(crate) struct ReplacementEnvironment { + entries: BTreeMap<OsString, OsString>, +} + +impl ReplacementEnvironment { + pub(crate) fn insert( + &mut self, + name: impl Into<OsString>, + value: impl Into<OsString>, + ) -> Result<(), ProcessError> { + let name = name.into(); + let value = value.into(); + let name_text = name + .to_str() + .ok_or_else(|| environment_error(EnvironmentRejection::InvalidNameOrNul))?; + if let Some(rejection) = environment_rejection(name_text) { + return Err(environment_error(rejection)); + } + if os_value_contains_nul(&value) { + return Err(environment_error(EnvironmentRejection::InvalidNameOrNul)); + } + if os_value_byte_len(&value) > MAX_ENVIRONMENT_VALUE_BYTES { + return Err(environment_error(EnvironmentRejection::ValueTooLarge)); + } + if self.entries.contains_key(&name) { + return Err(environment_error(EnvironmentRejection::DuplicateName)); + } + if self.entries.len() == MAX_ENVIRONMENT_ENTRIES { + return Err(environment_error(EnvironmentRejection::TooManyEntries)); + } + self.entries.insert(name, value); + Ok(()) + } +} + +impl fmt::Debug for ReplacementEnvironment { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ReplacementEnvironment") + .field("entry_count", &self.entries.len()) + .field("values", &"<redacted>") + .finish() + } +} + +fn environment_error(rejection: EnvironmentRejection) -> ProcessError { + ProcessError::new(ProcessFailureKind::EnvironmentRejected(rejection)) +} + +fn environment_rejection(name: &str) -> Option<EnvironmentRejection> { + if name.is_empty() + || name.len() > MAX_ENVIRONMENT_NAME_BYTES + || name.as_bytes().contains(&0) + || !name.is_ascii() + || !name + .bytes() + .next() + .is_some_and(|byte| byte == b'_' || byte.is_ascii_uppercase()) + || !name + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_uppercase() || byte.is_ascii_digit()) + { + return Some(EnvironmentRejection::InvalidNameOrNul); + } + if ["CREDENTIAL", "KEY", "PASSWORD", "SECRET", "TOKEN"] + .iter() + .any(|pattern| name.contains(pattern)) + { + return Some(EnvironmentRejection::SensitiveName); + } + if FORBIDDEN_ENVIRONMENT_NAMES.contains(&name) + || name == "CARGO_BUILD_RUSTFLAGS" + || (name.starts_with("CARGO_TARGET_") && name.ends_with("_RUSTFLAGS")) + || name.starts_with("DYLD_") + || matches!( + name, + "LD_AUDIT" + | "LD_DEBUG" + | "LD_PROFILE" + | "RUSTC_WRAPPER" + | "RUSTC_WORKSPACE_WRAPPER" + | "RUSTDOCFLAGS" + ) + { + return Some(EnvironmentRejection::ForbiddenControl); + } + None +} + +#[cfg(unix)] +fn os_value_byte_len(value: &OsStr) -> usize { + use std::os::unix::ffi::OsStrExt; + value.as_bytes().len() +} + +#[cfg(unix)] +fn os_value_contains_nul(value: &OsStr) -> bool { + use std::os::unix::ffi::OsStrExt; + value.as_bytes().contains(&0) +} + +#[cfg(windows)] +fn os_value_byte_len(value: &OsStr) -> usize { + use std::os::windows::ffi::OsStrExt; + value.encode_wide().count().saturating_mul(2) +} + +#[cfg(windows)] +fn os_value_contains_nul(value: &OsStr) -> bool { + use std::os::windows::ffi::OsStrExt; + value.encode_wide().any(|unit| unit == 0) +} + +#[cfg(not(any(unix, windows)))] +fn os_value_byte_len(value: &OsStr) -> usize { + value.to_string_lossy().len() +} + +#[cfg(not(any(unix, windows)))] +fn os_value_contains_nul(value: &OsStr) -> bool { + value.to_string_lossy().contains('\0') +} + +pub(crate) struct ProcessRequest { + program: OsString, + arguments: Vec<OsString>, + current_dir: Option<PathBuf>, + environment: ReplacementEnvironment, + deadline: Duration, + stdout_limit: usize, + stderr_limit: usize, + termination_grace: Duration, +} + +impl ProcessRequest { + pub(crate) fn new(program: impl Into<OsString>) -> Self { + Self { + program: program.into(), + arguments: Vec::new(), + current_dir: None, + environment: ReplacementEnvironment::default(), + deadline: DEFAULT_DEADLINE, + stdout_limit: DEFAULT_STREAM_LIMIT, + stderr_limit: DEFAULT_STREAM_LIMIT, + termination_grace: DEFAULT_TERMINATION_GRACE, + } + } + + pub(crate) fn arg(mut self, argument: impl Into<OsString>) -> Self { + self.arguments.push(argument.into()); + self + } + + pub(crate) fn current_dir(mut self, current_dir: impl Into<PathBuf>) -> Self { + self.current_dir = Some(current_dir.into()); + self + } + + pub(crate) fn environment(mut self, environment: ReplacementEnvironment) -> Self { + self.environment = environment; + self + } + + pub(crate) fn deadline(mut self, deadline: Duration) -> Self { + self.deadline = deadline; + self + } + + pub(crate) fn output_limits(mut self, stdout_limit: usize, stderr_limit: usize) -> Self { + self.stdout_limit = stdout_limit; + self.stderr_limit = stderr_limit; + self + } + + pub(crate) fn termination_grace(mut self, termination_grace: Duration) -> Self { + self.termination_grace = termination_grace; + self + } + + fn validate(&self) -> Result<(), ProcessError> { + if self.program.is_empty() + || self.deadline.is_zero() + || self.deadline > MAX_DEADLINE + || self.stdout_limit > MAX_STREAM_LIMIT + || self.stderr_limit > MAX_STREAM_LIMIT + || self.termination_grace > MAX_TERMINATION_GRACE + { + return Err(ProcessError::new(ProcessFailureKind::InvalidConfiguration)); + } + Ok(()) + } +} + +impl fmt::Debug for ProcessRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProcessRequest") + .field("program", &"<redacted>") + .field("argument_count", &self.arguments.len()) + .field( + "current_dir", + &self.current_dir.as_ref().map(|_| "<redacted>"), + ) + .field("environment", &self.environment) + .field("deadline", &self.deadline) + .field("stdout_limit", &self.stdout_limit) + .field("stderr_limit", &self.stderr_limit) + .field("termination_grace", &self.termination_grace) + .finish() + } +} + +pub(crate) struct ProcessOutput { + status: ExitStatus, + stdout: Vec<u8>, + stderr: Vec<u8>, +} + +impl ProcessOutput { + pub(crate) fn status(&self) -> ExitStatus { + self.status + } + + pub(crate) fn stdout(&self) -> &[u8] { + &self.stdout + } + + pub(crate) fn stderr(&self) -> &[u8] { + &self.stderr + } +} + +impl fmt::Debug for ProcessOutput { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProcessOutput") + .field("success", &self.status.success()) + .field("code", &self.status.code()) + .field("stdout_bytes", &self.stdout.len()) + .field("stderr_bytes", &self.stderr.len()) + .field("contents", &"<redacted>") + .finish() + } +} + +pub(crate) fn run(request: &ProcessRequest) -> Result<ProcessOutput, ProcessError> { + request.validate()?; + #[cfg(unix)] + { + unix::run(request) + } + #[cfg(not(unix))] + { + let _ = request; + Err(ProcessError::new(ProcessFailureKind::UnsupportedPlatform)) + } +} + +#[cfg(unix)] +mod unix { + use super::{ + OutputLimitBreach, ProcessError, ProcessFailureKind, ProcessOutput, ProcessRequest, + }; + use rustix::event::{PollFd, PollFlags, Timespec, poll}; + use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl}; + use rustix::io::Errno; + use rustix::process::{Pid, Signal, kill_process_group, test_kill_process_group}; + use std::io::{self, Read}; + use std::os::unix::process::CommandExt; + use std::process::{Child, ChildStderr, ChildStdout, Command, ExitStatus, Stdio}; + use std::thread; + use std::time::{Duration, Instant}; + + const POLL_QUANTUM: Duration = Duration::from_millis(10); + const FORCE_KILL_GRACE: Duration = Duration::from_secs(1); + const MAX_DRAIN_BYTES_PER_PASS: usize = 64 * 1024; + + struct Capture { + bytes: Vec<u8>, + limit: usize, + exceeded: bool, + } + + impl Capture { + fn new(limit: usize) -> Self { + Self { + bytes: Vec::new(), + limit, + exceeded: false, + } + } + + fn record(&mut self, chunk: &[u8]) -> bool { + let was_exceeded = self.exceeded; + let remaining = self.limit.saturating_sub(self.bytes.len()); + let retained = remaining.min(chunk.len()); + self.bytes.extend_from_slice(&chunk[..retained]); + if retained < chunk.len() { + self.exceeded = true; + } + !was_exceeded && self.exceeded + } + } + + enum StopCause { + Deadline, + Error(ProcessError), + OrphanedDescendants, + OutputLimit, + RetainedPipes, + } + + pub(super) fn run(request: &ProcessRequest) -> Result<ProcessOutput, ProcessError> { + let deadline = Instant::now() + .checked_add(request.deadline) + .ok_or_else(|| ProcessError::new(ProcessFailureKind::InvalidConfiguration))?; + + let mut command = Command::new(&request.program); + command + .args(&request.arguments) + .env_clear() + .envs(&request.environment.entries) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0); + if let Some(current_dir) = &request.current_dir { + command.current_dir(current_dir); + } + + let mut child = command + .spawn() + .map_err(|error| ProcessError::from_io(ProcessFailureKind::Spawn, &error))?; + let process_group = Pid::from_child(&child); + let mut stdout = child.stdout.take(); + let mut stderr = child.stderr.take(); + let mut stdout_capture = Capture::new(request.stdout_limit); + let mut stderr_capture = Capture::new(request.stderr_limit); + let mut status = None; + + if stdout.is_none() || stderr.is_none() { + let original = ProcessError::new(ProcessFailureKind::PipeSetup); + return Err(cleanup_or_original( + original, + &mut child, + process_group, + &mut status, + &mut stdout, + &mut stderr, + &mut stdout_capture, + &mut stderr_capture, + request.termination_grace, + )); + } + if let Err(original) = configure_nonblocking(stdout.as_ref(), stderr.as_ref()) { + return Err(cleanup_or_original( + original, + &mut child, + process_group, + &mut status, + &mut stdout, + &mut stderr, + &mut stdout_capture, + &mut stderr_capture, + request.termination_grace, + )); + } + + let stop = loop { + if let Err(error) = drain_streams( + &mut stdout, + &mut stderr, + &mut stdout_capture, + &mut stderr_capture, + ) { + break StopCause::Error(error); + } + if stdout_capture.exceeded || stderr_capture.exceeded { + break StopCause::OutputLimit; + } + + match child.try_wait() { + Ok(Some(exit_status)) => status = Some(exit_status), + Ok(None) => {} + Err(error) => { + break StopCause::Error(ProcessError::from_io( + ProcessFailureKind::Wait, + &error, + )); + } + } + + if status.is_some() { + match process_group_is_alive(process_group) { + Ok(true) => break StopCause::OrphanedDescendants, + Ok(false) if stdout.is_none() && stderr.is_none() => { + let Some(exit_status) = status else { + unreachable!("status was checked above"); + }; + return Ok(ProcessOutput { + status: exit_status, + stdout: stdout_capture.bytes, + stderr: stderr_capture.bytes, + }); + } + Ok(false) => {} + Err(error) => break StopCause::Error(error), + } + } + + let now = Instant::now(); + if now >= deadline { + if status.is_some() { + break StopCause::RetainedPipes; + } + break StopCause::Deadline; + } + let wait = deadline.saturating_duration_since(now).min(POLL_QUANTUM); + if let Err(error) = poll_streams(stdout.as_ref(), stderr.as_ref(), wait) { + break StopCause::Error(error); + } + }; + + let original = match stop { + StopCause::Deadline => ProcessError::new(ProcessFailureKind::DeadlineExceeded), + StopCause::Error(error) => error, + StopCause::OrphanedDescendants => { + ProcessError::new(ProcessFailureKind::OrphanedDescendants) + } + StopCause::OutputLimit => ProcessError::output_limit(limit_breach( + stdout_capture.exceeded, + stderr_capture.exceeded, + )), + StopCause::RetainedPipes => ProcessError::new(ProcessFailureKind::CleanupIncomplete), + }; + let mut error = cleanup_or_original( + original, + &mut child, + process_group, + &mut status, + &mut stdout, + &mut stderr, + &mut stdout_capture, + &mut stderr_capture, + request.termination_grace, + ); + if error.kind == ProcessFailureKind::OutputLimitExceeded { + error.output_limit = Some(limit_breach( + stdout_capture.exceeded, + stderr_capture.exceeded, + )); + } + Err(error) + } + + fn configure_nonblocking( + stdout: Option<&ChildStdout>, + stderr: Option<&ChildStderr>, + ) -> Result<(), ProcessError> { + let stdout = stdout.ok_or_else(|| ProcessError::new(ProcessFailureKind::PipeSetup))?; + let stderr = stderr.ok_or_else(|| ProcessError::new(ProcessFailureKind::PipeSetup))?; + set_nonblocking(stdout)?; + set_nonblocking(stderr) + } + + fn set_nonblocking(fd: impl rustix::fd::AsFd) -> Result<(), ProcessError> { + let flags = fcntl_getfl(&fd) + .map_err(|error| ProcessError::from_errno(ProcessFailureKind::PipeSetup, error))?; + fcntl_setfl(fd, flags | OFlags::NONBLOCK) + .map_err(|error| ProcessError::from_errno(ProcessFailureKind::PipeSetup, error)) + } + + fn drain_streams( + stdout: &mut Option<ChildStdout>, + stderr: &mut Option<ChildStderr>, + stdout_capture: &mut Capture, + stderr_capture: &mut Capture, + ) -> Result<(), ProcessError> { + drain_stream(stdout, stdout_capture)?; + drain_stream(stderr, stderr_capture) + } + + fn drain_stream<Reader: Read>( + stream: &mut Option<Reader>, + capture: &mut Capture, + ) -> Result<(), ProcessError> { + let Some(reader) = stream.as_mut() else { + return Ok(()); + }; + let mut buffer = [0_u8; 8192]; + let mut reached_eof = false; + let mut failure = None; + let mut bytes_read = 0_usize; + loop { + match reader.read(&mut buffer) { + Ok(0) => { + reached_eof = true; + break; + } + Ok(count) => { + bytes_read = bytes_read.saturating_add(count); + if capture.record(&buffer[..count]) || bytes_read >= MAX_DRAIN_BYTES_PER_PASS { + break; + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => { + failure = Some(ProcessError::from_io(ProcessFailureKind::Read, &error)); + break; + } + } + } + if reached_eof || failure.is_some() { + *stream = None; + } + match failure { + Some(error) => Err(error), + None => Ok(()), + } + } + + fn poll_streams( + stdout: Option<&ChildStdout>, + stderr: Option<&ChildStderr>, + timeout: Duration, + ) -> Result<(), ProcessError> { + let timeout = Timespec::try_from(timeout) + .map_err(|_| ProcessError::new(ProcessFailureKind::InvalidConfiguration))?; + let result = match (stdout, stderr) { + (Some(stdout), Some(stderr)) => { + let mut poll_fds = [ + PollFd::new(stdout, PollFlags::IN), + PollFd::new(stderr, PollFlags::IN), + ]; + poll(&mut poll_fds, Some(&timeout)) + } + (Some(stdout), None) => { + let mut poll_fds = [PollFd::new(stdout, PollFlags::IN)]; + poll(&mut poll_fds, Some(&timeout)) + } + (None, Some(stderr)) => { + let mut poll_fds = [PollFd::new(stderr, PollFlags::IN)]; + poll(&mut poll_fds, Some(&timeout)) + } + (None, None) => { + let mut poll_fds = []; + poll(&mut poll_fds, Some(&timeout)) + } + }; + match result { + Ok(_) | Err(Errno::INTR) => Ok(()), + Err(error) => Err(ProcessError::from_errno(ProcessFailureKind::Poll, error)), + } + } + + fn process_group_is_alive(process_group: Pid) -> Result<bool, ProcessError> { + match test_kill_process_group(process_group) { + Ok(()) | Err(Errno::PERM) => Ok(true), + Err(Errno::SRCH) => Ok(false), + Err(error) => Err(ProcessError::from_errno(ProcessFailureKind::Signal, error)), + } + } + + fn signal_process_group(process_group: Pid, signal: Signal) -> Result<(), ProcessError> { + match kill_process_group(process_group, signal) { + // macOS may report EPERM while the just-exited leader is being + // reaped. Cleanup still proves the group absent below; a group + // that remains present is a hard CleanupIncomplete failure. + Ok(()) | Err(Errno::SRCH | Errno::PERM) => Ok(()), + Err(error) => Err(ProcessError::from_errno(ProcessFailureKind::Signal, error)), + } + } + + #[allow(clippy::too_many_arguments)] + fn cleanup_or_original( + original: ProcessError, + child: &mut Child, + process_group: Pid, + status: &mut Option<ExitStatus>, + stdout: &mut Option<ChildStdout>, + stderr: &mut Option<ChildStderr>, + stdout_capture: &mut Capture, + stderr_capture: &mut Capture, + termination_grace: Duration, + ) -> ProcessError { + match cleanup( + child, + process_group, + status, + stdout, + stderr, + stdout_capture, + stderr_capture, + termination_grace, + ) { + Ok(()) => original, + Err(error) => error, + } + } + + #[allow(clippy::too_many_arguments)] + fn cleanup( + child: &mut Child, + process_group: Pid, + status: &mut Option<ExitStatus>, + stdout: &mut Option<ChildStdout>, + stderr: &mut Option<ChildStderr>, + stdout_capture: &mut Capture, + stderr_capture: &mut Capture, + termination_grace: Duration, + ) -> Result<(), ProcessError> { + let mut first_error = signal_process_group(process_group, Signal::TERM).err(); + let term_deadline = Instant::now() + .checked_add(termination_grace) + .unwrap_or_else(Instant::now); + pump_cleanup_until( + term_deadline, + child, + process_group, + status, + stdout, + stderr, + stdout_capture, + stderr_capture, + &mut first_error, + ); + + let group_alive = process_group_is_alive(process_group).unwrap_or(true); + if group_alive || status.is_none() { + remember_first( + &mut first_error, + signal_process_group(process_group, Signal::KILL).err(), + ); + if status.is_none() { + let _ = child.kill(); + } + } + let kill_deadline = Instant::now() + .checked_add(FORCE_KILL_GRACE) + .unwrap_or_else(Instant::now); + pump_cleanup_until( + kill_deadline, + child, + process_group, + status, + stdout, + stderr, + stdout_capture, + stderr_capture, + &mut first_error, + ); + + let final_group_alive = match process_group_is_alive(process_group) { + Ok(alive) => alive, + Err(error) => { + remember_first(&mut first_error, Some(error)); + true + } + }; + if status.is_none() || final_group_alive || stdout.is_some() || stderr.is_some() { + *stdout = None; + *stderr = None; + return Err(ProcessError::new(ProcessFailureKind::CleanupIncomplete)); + } + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + + #[allow(clippy::too_many_arguments)] + fn pump_cleanup_until( + deadline: Instant, + child: &mut Child, + process_group: Pid, + status: &mut Option<ExitStatus>, + stdout: &mut Option<ChildStdout>, + stderr: &mut Option<ChildStderr>, + stdout_capture: &mut Capture, + stderr_capture: &mut Capture, + first_error: &mut Option<ProcessError>, + ) { + loop { + if let Err(error) = drain_streams(stdout, stderr, stdout_capture, stderr_capture) { + remember_first(first_error, Some(error)); + } + if status.is_none() { + match child.try_wait() { + Ok(Some(exit_status)) => *status = Some(exit_status), + Ok(None) => {} + Err(error) => remember_first( + first_error, + Some(ProcessError::from_io(ProcessFailureKind::Wait, &error)), + ), + } + } + let group_alive = match process_group_is_alive(process_group) { + Ok(alive) => alive, + Err(error) => { + remember_first(first_error, Some(error)); + true + } + }; + if status.is_some() && !group_alive && stdout.is_none() && stderr.is_none() { + return; + } + + let now = Instant::now(); + if now >= deadline { + return; + } + let wait = deadline.saturating_duration_since(now).min(POLL_QUANTUM); + if let Err(error) = poll_streams(stdout.as_ref(), stderr.as_ref(), wait) { + remember_first(first_error, Some(error)); + thread::sleep(wait); + } + } + } + + fn remember_first(slot: &mut Option<ProcessError>, candidate: Option<ProcessError>) { + if slot.is_none() { + *slot = candidate; + } + } + + fn limit_breach(stdout_exceeded: bool, stderr_exceeded: bool) -> OutputLimitBreach { + match (stdout_exceeded, stderr_exceeded) { + (true, true) => OutputLimitBreach::Both, + (true, false) => OutputLimitBreach::Stdout, + (false, true) => OutputLimitBreach::Stderr, + (false, false) => unreachable!("output stop requires an exceeded stream"), + } + } +} + +pub(crate) fn self_test() -> Result<(), String> { + #[cfg(unix)] + { + self_test_suite()?; + println!("bounded process self-test: ok"); + Ok(()) + } + #[cfg(not(unix))] + { + Err(ProcessError::new(ProcessFailureKind::UnsupportedPlatform).to_string()) + } +} + +#[cfg(unix)] +fn self_test_suite() -> Result<(), String> { + verify_replacement_environment()?; + verify_closed_stdin()?; + verify_environment_rejections()?; + verify_deadline_tree_kill()?; + verify_orphan_kill()?; + verify_dual_output_caps()?; + verify_continuous_output_is_bounded()?; + verify_redaction()?; + Ok(()) +} + +#[cfg(unix)] +fn verify_replacement_environment() -> Result<(), String> { + let mut environment = ReplacementEnvironment::default(); + environment + .insert("RSHR_VISIBLE", "replacement-only") + .map_err(|error| error.to_string())?; + let output = run(&ProcessRequest::new("/usr/bin/env") + .environment(environment) + .deadline(Duration::from_secs(2)) + .output_limits(4096, 4096)) + .map_err(|error| error.to_string())?; + if !output.status().success() + || output.stdout() != b"RSHR_VISIBLE=replacement-only\n" + || !output.stderr().is_empty() + { + return Err("replacement environment self-test failed".to_owned()); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_closed_stdin() -> Result<(), String> { + let working_directory = + tempfile::tempdir().map_err(|_| "create self-test directory".to_owned())?; + let output = run(&ProcessRequest::new("/bin/sh") + .arg("-c") + .arg("if IFS= read -r value; then exit 92; fi; printf 'stdin-closed'") + .current_dir(working_directory.path()) + .deadline(Duration::from_secs(2))) + .map_err(|error| error.to_string())?; + if !output.status().success() || output.stdout() != b"stdin-closed" { + return Err("closed stdin self-test failed".to_owned()); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_environment_rejections() -> Result<(), String> { + for name in FORBIDDEN_ENVIRONMENT_NAMES { + expect_environment_rejection(name, EnvironmentRejection::ForbiddenControl)?; + } + for name in [ + "CARGO_BUILD_RUSTFLAGS", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS", + "DYLD_FRAMEWORK_PATH", + "LD_AUDIT", + "LD_DEBUG", + "LD_PROFILE", + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "RUSTDOCFLAGS", + ] { + expect_environment_rejection(name, EnvironmentRejection::ForbiddenControl)?; + } + for name in [ + "CREDENTIAL", + "APIKEY", + "PASSWORD", + "SECRET", + "TOKEN", + "RSHR_CREDENTIAL_FILE", + "RSHR_KEY_ID", + "RSHR_PASSWORD_FILE", + "RSHR_SECRET_VALUE", + "RSHR_TOKEN_FILE", + ] { + expect_environment_rejection(name, EnvironmentRejection::SensitiveName)?; + } + Ok(()) +} + +#[cfg(unix)] +fn expect_environment_rejection(name: &str, expected: EnvironmentRejection) -> Result<(), String> { + let mut environment = ReplacementEnvironment::default(); + let error = environment + .insert(name, "redacted-value") + .expect_err("forbidden environment entry must fail closed"); + if error.kind() != ProcessFailureKind::EnvironmentRejected(expected) { + return Err("environment rejection self-test failed".to_owned()); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_deadline_tree_kill() -> Result<(), String> { + let pid_file = + tempfile::NamedTempFile::new().map_err(|_| "create self-test pid file".to_owned())?; + let request = ProcessRequest::new("/bin/sh") + .arg("-c") + .arg( + "trap 'kill \"$child\" 2>/dev/null; wait \"$child\" 2>/dev/null; exit 143' TERM; \ + /bin/sleep 30 & child=$!; printf '%s\n' \"$child\" > \"$1\"; wait \"$child\"", + ) + .arg("rshr-bounded-process") + .arg(pid_file.path()) + .deadline(Duration::from_millis(750)) + .termination_grace(Duration::from_millis(100)); + let error = run(&request).expect_err("deadline self-test must time out"); + if error.kind() != ProcessFailureKind::DeadlineExceeded { + return Err("deadline classification self-test failed".to_owned()); + } + verify_recorded_process_gone(pid_file.path()) +} + +#[cfg(unix)] +fn verify_orphan_kill() -> Result<(), String> { + let pid_file = + tempfile::NamedTempFile::new().map_err(|_| "create self-test pid file".to_owned())?; + let request = ProcessRequest::new("/bin/sh") + .arg("-c") + .arg("/bin/sleep 30 & child=$!; printf '%s\n' \"$child\" > \"$1\"; exit 0") + .arg("rshr-bounded-process") + .arg(pid_file.path()) + .deadline(Duration::from_secs(2)) + .termination_grace(Duration::from_millis(100)); + let error = run(&request).expect_err("orphan self-test must fail closed"); + if error.kind() != ProcessFailureKind::OrphanedDescendants { + return Err("orphan classification self-test failed".to_owned()); + } + verify_recorded_process_gone(pid_file.path()) +} + +#[cfg(unix)] +fn verify_recorded_process_gone(path: &std::path::Path) -> Result<(), String> { + use rustix::io::Errno; + use rustix::process::{Pid, test_kill_process}; + use std::fs; + use std::thread; + use std::time::Instant; + + let raw = fs::read_to_string(path).map_err(|_| "read self-test pid file".to_owned())?; + let pid_number = raw + .trim() + .parse::<i32>() + .map_err(|_| "parse self-test pid".to_owned())?; + let pid = + Pid::from_raw(pid_number).ok_or_else(|| "self-test pid must be positive".to_owned())?; + let deadline = Instant::now() + .checked_add(Duration::from_secs(2)) + .ok_or_else(|| "self-test pid deadline overflow".to_owned())?; + loop { + match test_kill_process(pid) { + Err(Errno::SRCH) => return Ok(()), + Ok(()) | Err(Errno::PERM) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(10)); + } + _ => return Err("bounded process descendant survived cleanup".to_owned()), + } + } +} + +#[cfg(unix)] +fn verify_dual_output_caps() -> Result<(), String> { + let stdout_error = run(&ProcessRequest::new("/bin/sh") + .arg("-c") + .arg("printf 'stdout-over-cap'") + .deadline(Duration::from_secs(2)) + .output_limits(4, 4096)) + .expect_err("stdout cap self-test must fail closed"); + if stdout_error.output_limit_breach() != Some(OutputLimitBreach::Stdout) { + return Err(format!("stdout cap self-test failed: {stdout_error:?}")); + } + + let stderr_error = run(&ProcessRequest::new("/bin/sh") + .arg("-c") + .arg("printf 'stderr-over-cap' >&2") + .deadline(Duration::from_secs(2)) + .output_limits(4096, 4)) + .expect_err("stderr cap self-test must fail closed"); + if stderr_error.output_limit_breach() != Some(OutputLimitBreach::Stderr) { + return Err(format!("stderr cap self-test failed: {stderr_error:?}")); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_continuous_output_is_bounded() -> Result<(), String> { + let error = run(&ProcessRequest::new("/bin/sh") + .arg("-c") + .arg("while :; do printf 'continuous-output'; done") + .deadline(Duration::from_secs(2)) + .output_limits(4096, 4096) + .termination_grace(Duration::from_millis(100))) + .expect_err("continuous output must hit its live cap"); + if error.output_limit_breach() != Some(OutputLimitBreach::Stdout) { + return Err("continuous output cap self-test failed".to_owned()); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_redaction() -> Result<(), String> { + const ARGUMENT_MARKER: &str = "argument-marker-must-not-leak"; + const CURRENT_DIR_MARKER: &str = "/cwd-marker-must-not-leak"; + const ENVIRONMENT_MARKER: &str = "environment-marker-must-not-leak"; + const PROGRAM_MARKER: &str = "/program-marker-must-not-leak"; + + let mut environment = ReplacementEnvironment::default(); + environment + .insert("RSHR_VISIBLE", ENVIRONMENT_MARKER) + .map_err(|error| error.to_string())?; + let diagnostics_request = ProcessRequest::new(PROGRAM_MARKER) + .arg(ARGUMENT_MARKER) + .current_dir(CURRENT_DIR_MARKER) + .environment(environment.clone()); + let request_debug = format!("{diagnostics_request:?}"); + if [ + ARGUMENT_MARKER, + CURRENT_DIR_MARKER, + ENVIRONMENT_MARKER, + PROGRAM_MARKER, + ] + .iter() + .any(|marker| request_debug.contains(marker)) + { + return Err("request redaction self-test failed".to_owned()); + } + let spawn_error = run(&diagnostics_request) + .expect_err("missing redaction fixture program must fail to spawn"); + let spawn_debug = format!("{spawn_error:?}"); + let spawn_display = spawn_error.to_string(); + if [ + ARGUMENT_MARKER, + CURRENT_DIR_MARKER, + ENVIRONMENT_MARKER, + PROGRAM_MARKER, + ] + .iter() + .any(|marker| spawn_debug.contains(marker) || spawn_display.contains(marker)) + { + return Err("spawn error redaction self-test failed".to_owned()); + } + + let request = ProcessRequest::new("/bin/sh") + .arg("-c") + .arg("printf '%s' \"$RSHR_VISIBLE\"; printf '%s' \"$1\" >&2") + .arg("rshr-bounded-process") + .arg(ARGUMENT_MARKER) + .environment(environment) + .deadline(Duration::from_secs(2)); + + let output = run(&request).map_err(|error| error.to_string())?; + if output.stdout() != ENVIRONMENT_MARKER.as_bytes() + || output.stderr() != ARGUMENT_MARKER.as_bytes() + { + return Err("redaction fixture output self-test failed".to_owned()); + } + let output_debug = format!("{output:?}"); + if output_debug.contains(ARGUMENT_MARKER) || output_debug.contains(ENVIRONMENT_MARKER) { + return Err("output redaction self-test failed".to_owned()); + } + + let capped = run(&ProcessRequest::new("/bin/sh") + .arg("-c") + .arg(format!("printf '{ARGUMENT_MARKER}'")) + .output_limits(1, 1)) + .expect_err("redaction cap fixture must fail closed"); + let error_debug = format!("{capped:?}"); + let error_display = capped.to_string(); + if error_debug.contains(ARGUMENT_MARKER) || error_display.contains(ARGUMENT_MARKER) { + return Err("error redaction self-test failed".to_owned()); + } + Ok(()) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn replacement_environment_is_exact_and_stdin_is_closed() { + verify_replacement_environment().expect("replacement environment must be exact"); + verify_closed_stdin().expect("stdin must be closed"); + } + + #[test] + fn build_loader_and_sensitive_environment_controls_are_rejected() { + verify_environment_rejections().expect("environment controls must fail closed"); + } + + #[test] + fn deadline_kills_the_process_tree() { + verify_deadline_tree_kill().expect("deadline must kill the process group"); + } + + #[test] + fn leader_exit_kills_orphaned_descendants() { + verify_orphan_kill().expect("orphaned process group must be killed"); + } + + #[test] + fn stdout_and_stderr_have_independent_caps() { + verify_dual_output_caps().expect("both output streams must be capped"); + verify_continuous_output_is_bounded().expect("continuous output must remain bounded"); + } + + #[test] + fn request_output_and_error_diagnostics_are_redacted() { + verify_redaction().expect("bounded process diagnostics must be redacted"); + } + + #[test] + fn environment_boundaries_fail_closed() { + use std::os::unix::ffi::OsStringExt; + + let mut environment = ReplacementEnvironment::default(); + environment + .insert("RSHR_VISIBLE", "first") + .expect("first entry must be accepted"); + let duplicate = environment + .insert("RSHR_VISIBLE", "second") + .expect_err("duplicate must fail"); + assert_eq!( + duplicate.kind(), + ProcessFailureKind::EnvironmentRejected(EnvironmentRejection::DuplicateName) + ); + + let mut nul_environment = ReplacementEnvironment::default(); + let nul = nul_environment + .insert("RSHR_VISIBLE", OsString::from_vec(b"value\0tail".to_vec())) + .expect_err("NUL value must fail"); + assert_eq!( + nul.kind(), + ProcessFailureKind::EnvironmentRejected(EnvironmentRejection::InvalidNameOrNul) + ); + + let mut full_environment = ReplacementEnvironment::default(); + for index in 0..MAX_ENVIRONMENT_ENTRIES { + full_environment + .insert(format!("RSHR_ENTRY_{index}"), "value") + .expect("entry within cardinality bound must pass"); + } + let too_many = full_environment + .insert("RSHR_ENTRY_OVER_LIMIT", "value") + .expect_err("entry above cardinality bound must fail"); + assert_eq!( + too_many.kind(), + ProcessFailureKind::EnvironmentRejected(EnvironmentRejection::TooManyEntries) + ); + + let mut large_environment = ReplacementEnvironment::default(); + let too_large = large_environment + .insert( + "RSHR_VISIBLE", + OsString::from_vec(vec![b'x'; MAX_ENVIRONMENT_VALUE_BYTES + 1]), + ) + .expect_err("value above byte bound must fail"); + assert_eq!( + too_large.kind(), + ProcessFailureKind::EnvironmentRejected(EnvironmentRejection::ValueTooLarge) + ); + } + + #[test] + fn hard_configuration_maximums_fail_before_spawn() { + for request in [ + ProcessRequest::new("/usr/bin/true").deadline(MAX_DEADLINE + Duration::from_secs(1)), + ProcessRequest::new("/usr/bin/true") + .output_limits(MAX_STREAM_LIMIT + 1, MAX_STREAM_LIMIT), + ProcessRequest::new("/usr/bin/true") + .output_limits(MAX_STREAM_LIMIT, MAX_STREAM_LIMIT + 1), + ProcessRequest::new("/usr/bin/true") + .termination_grace(MAX_TERMINATION_GRACE + Duration::from_millis(1)), + ] { + let error = run(&request).expect_err("configuration above hard maximum must fail"); + assert_eq!(error.kind(), ProcessFailureKind::InvalidConfiguration); + } + } + + #[test] + fn exact_and_zero_stream_caps_have_byte_precise_semantics() { + let exact = run(&ProcessRequest::new("/bin/sh") + .arg("-c") + .arg("printf '1234'; printf 'abcd' >&2") + .output_limits(4, 4)) + .expect("exact cap must pass"); + assert_eq!(exact.stdout(), b"1234"); + assert_eq!(exact.stderr(), b"abcd"); + + let empty = run(&ProcessRequest::new("/usr/bin/true") + .output_limits(0, 0) + .deadline(Duration::from_secs(2))) + .expect("empty streams must fit zero caps"); + assert!(empty.stdout().is_empty()); + assert!(empty.stderr().is_empty()); + + let over_zero = run(&ProcessRequest::new("/bin/sh") + .arg("-c") + .arg("printf x") + .output_limits(0, 0)) + .expect_err("first byte above zero cap must fail"); + assert_eq!( + over_zero.output_limit_breach(), + Some(OutputLimitBreach::Stdout) + ); + } +} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -11,6 +11,7 @@ mod api_qualification; #[cfg_attr(coverage_nightly, coverage(off))] mod architecture; +mod bounded_process; #[cfg_attr(coverage_nightly, coverage(off))] mod build_control; mod build_output; @@ -65,6 +66,7 @@ enum XtaskCommand { Architecture, ArchitectureCi, ArchitectureSourceExportCi, + BoundedProcessSelfTest, CheckApiBoundaries, CheckDependencyBoundaries, Check { @@ -299,6 +301,7 @@ fn usage() { eprintln!(" cargo xtask architecture"); eprintln!(" cargo xtask architecture-ci"); eprintln!(" cargo xtask architecture-source-export-ci"); + eprintln!(" cargo xtask bounded-process-self-test"); eprintln!(" cargo xtask check-api-boundaries"); eprintln!(" cargo xtask check-dependency-boundaries"); eprintln!(" cargo xtask check --group <group> [--operation check|test|clippy] [--execute]"); @@ -478,6 +481,7 @@ fn run(args: &[String]) -> Result<(), String> { architecture::validate_ci(&workspace_root())?; validate_contract() } + XtaskCommand::BoundedProcessSelfTest => bounded_process::self_test(), XtaskCommand::CheckApiBoundaries => { architecture::validate_api_boundaries(&workspace_root()) } @@ -686,6 +690,8 @@ mod tests { #[test] fn typed_build_control_cli_requires_explicit_modes_and_known_values() { + assert!(Cli::try_parse_from(["xtask", "bounded-process-self-test"]).is_ok()); + let source_args = [ "xtask", "source", diff --git a/tools/xtask/tests/services_hardening_bounded_process_decision.rs b/tools/xtask/tests/services_hardening_bounded_process_decision.rs @@ -0,0 +1,127 @@ +#![forbid(unsafe_code)] + +use serde_json::Value; + +const DECISION: &str = include_str!( + "../../../contracts/architecture/decisions/services_hardening_bounded_process.v1.json" +); + +fn decision() -> Value { + serde_json::from_str(DECISION).expect("bounded-process decision must be valid JSON") +} + +#[test] +fn process_and_resource_bounds_are_exact() { + let value = decision(); + assert_eq!( + value["schema"], + "radroots.services-hardening.bounded-process-decisions.v1" + ); + assert_eq!(value["contract_version"], 1); + assert_eq!(value["decision_state"], "active"); + assert_eq!(value["owner"], "tools/xtask"); + assert_eq!( + value["self_test_command"], + "cargo xtask bounded-process-self-test" + ); + assert_eq!( + value["platform_scope"], + serde_json::json!({ + "implementation": ["macos_aarch64", "linux_x86_64"], + "source_gate": ["macos_aarch64"], + "cross_platform_promotion_owner": "step-297" + }) + ); + assert_eq!( + value["process_model"], + serde_json::json!({ + "child_process_group": "new_group_with_child_as_leader", + "stdin": "closed_devnull", + "stdout": "concurrently_drained_live_byte_cap", + "stderr": "concurrently_drained_live_byte_cap", + "deadline_clock": "monotonic", + "normal_leader_exit": "clean_remaining_process_group", + "failure_cleanup": "term_group_bounded_grace_then_kill_group_and_bounded_reap", + "unsupported_platform": "fail_closed" + }) + ); + assert_eq!( + value["hard_maximums"], + serde_json::json!({ + "deadline_seconds": 86_400, + "termination_grace_seconds": 5, + "stdout_bytes": 67_108_864, + "stderr_bytes": 67_108_864, + "environment_entries": 64, + "environment_name_bytes": 128, + "environment_value_bytes": 65_536 + }) + ); +} + +#[test] +fn environment_and_diagnostics_fail_closed() { + let value = decision(); + assert_eq!( + value["environment"], + serde_json::json!({ + "inheritance": "replace_with_explicit_allowlist", + "ambient_snapshot": false, + "duplicate_name": "reject", + "invalid_name_or_nul": "reject", + "forbidden_names": [ + "CARGO_ENCODED_RUSTFLAGS", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "LD_LIBRARY_PATH", + "LD_PRELOAD", + "NIX_CONFIG", + "NIX_PATH", + "NIXPKGS_ALLOW_BROKEN", + "NIXPKGS_ALLOW_UNFREE", + "RUSTFLAGS" + ], + "forbidden_name_patterns": [ + "*CREDENTIAL*", + "*KEY*", + "*PASSWORD*", + "*SECRET*", + "*TOKEN*" + ], + "equivalent_build_controls": [ + "CARGO_BUILD_RUSTFLAGS", + "CARGO_TARGET_*_RUSTFLAGS", + "DYLD_*", + "LD_AUDIT", + "LD_DEBUG", + "LD_PROFILE", + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "RUSTDOCFLAGS" + ] + }) + ); + assert_eq!( + value["diagnostic_safety"], + serde_json::json!({ + "program_argv_cwd": "redacted", + "environment_values": "redacted", + "captured_stream_bytes": "redacted", + "operating_system_error_text": "redacted", + "source_error_chain": "absent" + }) + ); + assert_eq!( + value["required_vectors"], + serde_json::json!([ + "timeout", + "orphan_child", + "stdout_cap", + "stderr_cap", + "closed_stdin", + "inherited_build_environment", + "loader_injection", + "redaction" + ]) + ); +}