lib

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

bounded_process.rs (49170B)


      1 //! Hermetic, resource-bounded subprocess execution for xtask.
      2 //!
      3 //! This module owns command construction so callers cannot bypass replacement
      4 //! environment, closed-stdin, process-group, deadline, or stream-cap policy.
      5 
      6 use std::collections::BTreeMap;
      7 use std::ffi::{OsStr, OsString};
      8 use std::fmt;
      9 use std::io;
     10 use std::path::PathBuf;
     11 use std::process::ExitStatus;
     12 use std::time::Duration;
     13 
     14 const DEFAULT_DEADLINE: Duration = Duration::from_secs(60);
     15 const DEFAULT_STREAM_LIMIT: usize = 1024 * 1024;
     16 const DEFAULT_TERMINATION_GRACE: Duration = Duration::from_millis(250);
     17 const MAX_DEADLINE: Duration = Duration::from_secs(86_400);
     18 const MAX_TERMINATION_GRACE: Duration = Duration::from_secs(5);
     19 const MAX_STREAM_LIMIT: usize = 67_108_864;
     20 const MAX_ENVIRONMENT_ENTRIES: usize = 64;
     21 const MAX_ENVIRONMENT_NAME_BYTES: usize = 128;
     22 const MAX_ENVIRONMENT_VALUE_BYTES: usize = 65_536;
     23 
     24 const FORBIDDEN_ENVIRONMENT_NAMES: [&str; 10] = [
     25     "CARGO_ENCODED_RUSTFLAGS",
     26     "DYLD_INSERT_LIBRARIES",
     27     "DYLD_LIBRARY_PATH",
     28     "LD_LIBRARY_PATH",
     29     "LD_PRELOAD",
     30     "NIX_CONFIG",
     31     "NIX_PATH",
     32     "NIXPKGS_ALLOW_BROKEN",
     33     "NIXPKGS_ALLOW_UNFREE",
     34     "RUSTFLAGS",
     35 ];
     36 
     37 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     38 pub(crate) enum EnvironmentRejection {
     39     DuplicateName,
     40     ForbiddenControl,
     41     InvalidNameOrNul,
     42     SensitiveName,
     43     TooManyEntries,
     44     ValueTooLarge,
     45 }
     46 
     47 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     48 pub(crate) enum OutputLimitBreach {
     49     Stdout,
     50     Stderr,
     51     Both,
     52 }
     53 
     54 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     55 pub(crate) enum ProcessFailureKind {
     56     CleanupIncomplete,
     57     DeadlineExceeded,
     58     EnvironmentRejected(EnvironmentRejection),
     59     InvalidConfiguration,
     60     OrphanedDescendants,
     61     OutputLimitExceeded,
     62     PipeSetup,
     63     Poll,
     64     Read,
     65     Signal,
     66     Spawn,
     67     #[cfg_attr(unix, allow(dead_code))]
     68     UnsupportedPlatform,
     69     Wait,
     70 }
     71 
     72 /// A deliberately redacted process failure.
     73 pub(crate) struct ProcessError {
     74     kind: ProcessFailureKind,
     75     io_kind: Option<io::ErrorKind>,
     76     output_limit: Option<OutputLimitBreach>,
     77 }
     78 
     79 impl ProcessError {
     80     fn new(kind: ProcessFailureKind) -> Self {
     81         Self {
     82             kind,
     83             io_kind: None,
     84             output_limit: None,
     85         }
     86     }
     87 
     88     fn from_io(kind: ProcessFailureKind, error: &io::Error) -> Self {
     89         Self {
     90             kind,
     91             io_kind: Some(error.kind()),
     92             output_limit: None,
     93         }
     94     }
     95 
     96     #[cfg(unix)]
     97     fn from_errno(kind: ProcessFailureKind, error: rustix::io::Errno) -> Self {
     98         Self {
     99             kind,
    100             io_kind: Some(error.kind()),
    101             output_limit: None,
    102         }
    103     }
    104 
    105     fn output_limit(breach: OutputLimitBreach) -> Self {
    106         Self {
    107             kind: ProcessFailureKind::OutputLimitExceeded,
    108             io_kind: None,
    109             output_limit: Some(breach),
    110         }
    111     }
    112 
    113     pub(crate) fn kind(&self) -> ProcessFailureKind {
    114         self.kind
    115     }
    116 
    117     pub(crate) fn output_limit_breach(&self) -> Option<OutputLimitBreach> {
    118         self.output_limit
    119     }
    120 }
    121 
    122 impl fmt::Debug for ProcessError {
    123     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    124         formatter
    125             .debug_struct("ProcessError")
    126             .field("kind", &self.kind)
    127             .field("io_kind", &self.io_kind)
    128             .field("output_limit", &self.output_limit)
    129             .finish()
    130     }
    131 }
    132 
    133 impl fmt::Display for ProcessError {
    134     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    135         let message = match self.kind {
    136             ProcessFailureKind::CleanupIncomplete => {
    137                 "bounded process cleanup did not reach a verified terminal state"
    138             }
    139             ProcessFailureKind::DeadlineExceeded => "bounded process deadline exceeded",
    140             ProcessFailureKind::EnvironmentRejected(_) => {
    141                 "bounded process replacement environment was rejected"
    142             }
    143             ProcessFailureKind::InvalidConfiguration => {
    144                 "bounded process configuration was rejected"
    145             }
    146             ProcessFailureKind::OrphanedDescendants => {
    147                 "bounded process leader exited while descendants remained"
    148             }
    149             ProcessFailureKind::OutputLimitExceeded => "bounded process output limit exceeded",
    150             ProcessFailureKind::PipeSetup => "bounded process pipe setup failed",
    151             ProcessFailureKind::Poll => "bounded process polling failed",
    152             ProcessFailureKind::Read => "bounded process output read failed",
    153             ProcessFailureKind::Signal => "bounded process group signaling failed",
    154             ProcessFailureKind::Spawn => "bounded process spawn failed",
    155             ProcessFailureKind::UnsupportedPlatform => {
    156                 "bounded process groups are unsupported on this platform"
    157             }
    158             ProcessFailureKind::Wait => "bounded process wait failed",
    159         };
    160         formatter.write_str(message)
    161     }
    162 }
    163 
    164 impl std::error::Error for ProcessError {}
    165 
    166 /// An explicit replacement environment. No ambient snapshot is ever taken.
    167 #[derive(Clone, Default)]
    168 pub(crate) struct ReplacementEnvironment {
    169     entries: BTreeMap<OsString, OsString>,
    170 }
    171 
    172 impl ReplacementEnvironment {
    173     pub(crate) fn insert(
    174         &mut self,
    175         name: impl Into<OsString>,
    176         value: impl Into<OsString>,
    177     ) -> Result<(), ProcessError> {
    178         let name = name.into();
    179         let value = value.into();
    180         let name_text = name
    181             .to_str()
    182             .ok_or_else(|| environment_error(EnvironmentRejection::InvalidNameOrNul))?;
    183         if let Some(rejection) = environment_rejection(name_text) {
    184             return Err(environment_error(rejection));
    185         }
    186         if os_value_contains_nul(&value) {
    187             return Err(environment_error(EnvironmentRejection::InvalidNameOrNul));
    188         }
    189         if os_value_byte_len(&value) > MAX_ENVIRONMENT_VALUE_BYTES {
    190             return Err(environment_error(EnvironmentRejection::ValueTooLarge));
    191         }
    192         if self.entries.contains_key(&name) {
    193             return Err(environment_error(EnvironmentRejection::DuplicateName));
    194         }
    195         if self.entries.len() == MAX_ENVIRONMENT_ENTRIES {
    196             return Err(environment_error(EnvironmentRejection::TooManyEntries));
    197         }
    198         self.entries.insert(name, value);
    199         Ok(())
    200     }
    201 }
    202 
    203 impl fmt::Debug for ReplacementEnvironment {
    204     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    205         formatter
    206             .debug_struct("ReplacementEnvironment")
    207             .field("entry_count", &self.entries.len())
    208             .field("values", &"<redacted>")
    209             .finish()
    210     }
    211 }
    212 
    213 fn environment_error(rejection: EnvironmentRejection) -> ProcessError {
    214     ProcessError::new(ProcessFailureKind::EnvironmentRejected(rejection))
    215 }
    216 
    217 fn environment_rejection(name: &str) -> Option<EnvironmentRejection> {
    218     if name.is_empty()
    219         || name.len() > MAX_ENVIRONMENT_NAME_BYTES
    220         || name.as_bytes().contains(&0)
    221         || !name.is_ascii()
    222         || !name
    223             .bytes()
    224             .next()
    225             .is_some_and(|byte| byte == b'_' || byte.is_ascii_uppercase())
    226         || !name
    227             .bytes()
    228             .all(|byte| byte == b'_' || byte.is_ascii_uppercase() || byte.is_ascii_digit())
    229     {
    230         return Some(EnvironmentRejection::InvalidNameOrNul);
    231     }
    232     if ["CREDENTIAL", "KEY", "PASSWORD", "SECRET", "TOKEN"]
    233         .iter()
    234         .any(|pattern| name.contains(pattern))
    235     {
    236         return Some(EnvironmentRejection::SensitiveName);
    237     }
    238     if FORBIDDEN_ENVIRONMENT_NAMES.contains(&name)
    239         || name == "CARGO_BUILD_RUSTFLAGS"
    240         || (name.starts_with("CARGO_TARGET_") && name.ends_with("_RUSTFLAGS"))
    241         || name.starts_with("DYLD_")
    242         || matches!(
    243             name,
    244             "LD_AUDIT"
    245                 | "LD_DEBUG"
    246                 | "LD_PROFILE"
    247                 | "RUSTC_WRAPPER"
    248                 | "RUSTC_WORKSPACE_WRAPPER"
    249                 | "RUSTDOCFLAGS"
    250         )
    251     {
    252         return Some(EnvironmentRejection::ForbiddenControl);
    253     }
    254     None
    255 }
    256 
    257 #[cfg(unix)]
    258 fn os_value_byte_len(value: &OsStr) -> usize {
    259     use std::os::unix::ffi::OsStrExt;
    260     value.as_bytes().len()
    261 }
    262 
    263 #[cfg(unix)]
    264 fn os_value_contains_nul(value: &OsStr) -> bool {
    265     use std::os::unix::ffi::OsStrExt;
    266     value.as_bytes().contains(&0)
    267 }
    268 
    269 #[cfg(windows)]
    270 fn os_value_byte_len(value: &OsStr) -> usize {
    271     use std::os::windows::ffi::OsStrExt;
    272     value.encode_wide().count().saturating_mul(2)
    273 }
    274 
    275 #[cfg(windows)]
    276 fn os_value_contains_nul(value: &OsStr) -> bool {
    277     use std::os::windows::ffi::OsStrExt;
    278     value.encode_wide().any(|unit| unit == 0)
    279 }
    280 
    281 #[cfg(not(any(unix, windows)))]
    282 fn os_value_byte_len(value: &OsStr) -> usize {
    283     value.to_string_lossy().len()
    284 }
    285 
    286 #[cfg(not(any(unix, windows)))]
    287 fn os_value_contains_nul(value: &OsStr) -> bool {
    288     value.to_string_lossy().contains('\0')
    289 }
    290 
    291 pub(crate) struct ProcessRequest {
    292     program: OsString,
    293     arguments: Vec<OsString>,
    294     current_dir: Option<PathBuf>,
    295     environment: ReplacementEnvironment,
    296     deadline: Duration,
    297     stdout_limit: usize,
    298     stderr_limit: usize,
    299     termination_grace: Duration,
    300 }
    301 
    302 impl ProcessRequest {
    303     pub(crate) fn new(program: impl Into<OsString>) -> Self {
    304         Self {
    305             program: program.into(),
    306             arguments: Vec::new(),
    307             current_dir: None,
    308             environment: ReplacementEnvironment::default(),
    309             deadline: DEFAULT_DEADLINE,
    310             stdout_limit: DEFAULT_STREAM_LIMIT,
    311             stderr_limit: DEFAULT_STREAM_LIMIT,
    312             termination_grace: DEFAULT_TERMINATION_GRACE,
    313         }
    314     }
    315 
    316     pub(crate) fn arg(mut self, argument: impl Into<OsString>) -> Self {
    317         self.arguments.push(argument.into());
    318         self
    319     }
    320 
    321     pub(crate) fn current_dir(mut self, current_dir: impl Into<PathBuf>) -> Self {
    322         self.current_dir = Some(current_dir.into());
    323         self
    324     }
    325 
    326     pub(crate) fn environment(mut self, environment: ReplacementEnvironment) -> Self {
    327         self.environment = environment;
    328         self
    329     }
    330 
    331     pub(crate) fn deadline(mut self, deadline: Duration) -> Self {
    332         self.deadline = deadline;
    333         self
    334     }
    335 
    336     pub(crate) fn output_limits(mut self, stdout_limit: usize, stderr_limit: usize) -> Self {
    337         self.stdout_limit = stdout_limit;
    338         self.stderr_limit = stderr_limit;
    339         self
    340     }
    341 
    342     pub(crate) fn termination_grace(mut self, termination_grace: Duration) -> Self {
    343         self.termination_grace = termination_grace;
    344         self
    345     }
    346 
    347     fn validate(&self) -> Result<(), ProcessError> {
    348         if self.program.is_empty()
    349             || self.deadline.is_zero()
    350             || self.deadline > MAX_DEADLINE
    351             || self.stdout_limit > MAX_STREAM_LIMIT
    352             || self.stderr_limit > MAX_STREAM_LIMIT
    353             || self.termination_grace > MAX_TERMINATION_GRACE
    354         {
    355             return Err(ProcessError::new(ProcessFailureKind::InvalidConfiguration));
    356         }
    357         Ok(())
    358     }
    359 }
    360 
    361 impl fmt::Debug for ProcessRequest {
    362     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    363         formatter
    364             .debug_struct("ProcessRequest")
    365             .field("program", &"<redacted>")
    366             .field("argument_count", &self.arguments.len())
    367             .field(
    368                 "current_dir",
    369                 &self.current_dir.as_ref().map(|_| "<redacted>"),
    370             )
    371             .field("environment", &self.environment)
    372             .field("deadline", &self.deadline)
    373             .field("stdout_limit", &self.stdout_limit)
    374             .field("stderr_limit", &self.stderr_limit)
    375             .field("termination_grace", &self.termination_grace)
    376             .finish()
    377     }
    378 }
    379 
    380 pub(crate) struct ProcessOutput {
    381     status: ExitStatus,
    382     stdout: Vec<u8>,
    383     stderr: Vec<u8>,
    384 }
    385 
    386 impl ProcessOutput {
    387     pub(crate) fn status(&self) -> ExitStatus {
    388         self.status
    389     }
    390 
    391     pub(crate) fn stdout(&self) -> &[u8] {
    392         &self.stdout
    393     }
    394 
    395     pub(crate) fn stderr(&self) -> &[u8] {
    396         &self.stderr
    397     }
    398 }
    399 
    400 impl fmt::Debug for ProcessOutput {
    401     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    402         formatter
    403             .debug_struct("ProcessOutput")
    404             .field("success", &self.status.success())
    405             .field("code", &self.status.code())
    406             .field("stdout_bytes", &self.stdout.len())
    407             .field("stderr_bytes", &self.stderr.len())
    408             .field("contents", &"<redacted>")
    409             .finish()
    410     }
    411 }
    412 
    413 pub(crate) fn run(request: &ProcessRequest) -> Result<ProcessOutput, ProcessError> {
    414     request.validate()?;
    415     #[cfg(unix)]
    416     {
    417         unix::run(request)
    418     }
    419     #[cfg(not(unix))]
    420     {
    421         let _ = request;
    422         Err(ProcessError::new(ProcessFailureKind::UnsupportedPlatform))
    423     }
    424 }
    425 
    426 #[cfg(unix)]
    427 mod unix {
    428     use super::{
    429         OutputLimitBreach, ProcessError, ProcessFailureKind, ProcessOutput, ProcessRequest,
    430     };
    431     use rustix::event::{PollFd, PollFlags, Timespec, poll};
    432     use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
    433     use rustix::io::Errno;
    434     use rustix::process::{Pid, Signal, kill_process_group, test_kill_process_group};
    435     use std::io::{self, Read};
    436     use std::os::unix::process::CommandExt;
    437     use std::process::{Child, ChildStderr, ChildStdout, Command, ExitStatus, Stdio};
    438     use std::thread;
    439     use std::time::{Duration, Instant};
    440 
    441     const POLL_QUANTUM: Duration = Duration::from_millis(10);
    442     const FORCE_KILL_GRACE: Duration = Duration::from_secs(1);
    443     const MAX_DRAIN_BYTES_PER_PASS: usize = 64 * 1024;
    444 
    445     struct Capture {
    446         bytes: Vec<u8>,
    447         limit: usize,
    448         exceeded: bool,
    449     }
    450 
    451     impl Capture {
    452         fn new(limit: usize) -> Self {
    453             Self {
    454                 bytes: Vec::new(),
    455                 limit,
    456                 exceeded: false,
    457             }
    458         }
    459 
    460         fn record(&mut self, chunk: &[u8]) -> bool {
    461             let was_exceeded = self.exceeded;
    462             let remaining = self.limit.saturating_sub(self.bytes.len());
    463             let retained = remaining.min(chunk.len());
    464             self.bytes.extend_from_slice(&chunk[..retained]);
    465             if retained < chunk.len() {
    466                 self.exceeded = true;
    467             }
    468             !was_exceeded && self.exceeded
    469         }
    470     }
    471 
    472     enum StopCause {
    473         Deadline,
    474         Error(ProcessError),
    475         OrphanedDescendants,
    476         OutputLimit,
    477         RetainedPipes,
    478     }
    479 
    480     pub(super) fn run(request: &ProcessRequest) -> Result<ProcessOutput, ProcessError> {
    481         let deadline = Instant::now()
    482             .checked_add(request.deadline)
    483             .ok_or_else(|| ProcessError::new(ProcessFailureKind::InvalidConfiguration))?;
    484 
    485         let mut command = Command::new(&request.program);
    486         command
    487             .args(&request.arguments)
    488             .env_clear()
    489             .envs(&request.environment.entries)
    490             .stdin(Stdio::null())
    491             .stdout(Stdio::piped())
    492             .stderr(Stdio::piped())
    493             .process_group(0);
    494         if let Some(current_dir) = &request.current_dir {
    495             command.current_dir(current_dir);
    496         }
    497 
    498         let mut child = command
    499             .spawn()
    500             .map_err(|error| ProcessError::from_io(ProcessFailureKind::Spawn, &error))?;
    501         let process_group = Pid::from_child(&child);
    502         let mut stdout = child.stdout.take();
    503         let mut stderr = child.stderr.take();
    504         let mut stdout_capture = Capture::new(request.stdout_limit);
    505         let mut stderr_capture = Capture::new(request.stderr_limit);
    506         let mut status = None;
    507 
    508         if stdout.is_none() || stderr.is_none() {
    509             let original = ProcessError::new(ProcessFailureKind::PipeSetup);
    510             return Err(cleanup_or_original(
    511                 original,
    512                 &mut child,
    513                 process_group,
    514                 &mut status,
    515                 &mut stdout,
    516                 &mut stderr,
    517                 &mut stdout_capture,
    518                 &mut stderr_capture,
    519                 request.termination_grace,
    520             ));
    521         }
    522         if let Err(original) = configure_nonblocking(stdout.as_ref(), stderr.as_ref()) {
    523             return Err(cleanup_or_original(
    524                 original,
    525                 &mut child,
    526                 process_group,
    527                 &mut status,
    528                 &mut stdout,
    529                 &mut stderr,
    530                 &mut stdout_capture,
    531                 &mut stderr_capture,
    532                 request.termination_grace,
    533             ));
    534         }
    535 
    536         let stop = loop {
    537             if let Err(error) = drain_streams(
    538                 &mut stdout,
    539                 &mut stderr,
    540                 &mut stdout_capture,
    541                 &mut stderr_capture,
    542             ) {
    543                 break StopCause::Error(error);
    544             }
    545             if stdout_capture.exceeded || stderr_capture.exceeded {
    546                 break StopCause::OutputLimit;
    547             }
    548 
    549             match child.try_wait() {
    550                 Ok(Some(exit_status)) => status = Some(exit_status),
    551                 Ok(None) => {}
    552                 Err(error) => {
    553                     break StopCause::Error(ProcessError::from_io(
    554                         ProcessFailureKind::Wait,
    555                         &error,
    556                     ));
    557                 }
    558             }
    559 
    560             if status.is_some() {
    561                 match process_group_is_alive(process_group) {
    562                     Ok(true) => break StopCause::OrphanedDescendants,
    563                     Ok(false) if stdout.is_none() && stderr.is_none() => {
    564                         let Some(exit_status) = status else {
    565                             unreachable!("status was checked above");
    566                         };
    567                         return Ok(ProcessOutput {
    568                             status: exit_status,
    569                             stdout: stdout_capture.bytes,
    570                             stderr: stderr_capture.bytes,
    571                         });
    572                     }
    573                     Ok(false) => {}
    574                     Err(error) => break StopCause::Error(error),
    575                 }
    576             }
    577 
    578             let now = Instant::now();
    579             if now >= deadline {
    580                 if status.is_some() {
    581                     break StopCause::RetainedPipes;
    582                 }
    583                 break StopCause::Deadline;
    584             }
    585             let wait = deadline.saturating_duration_since(now).min(POLL_QUANTUM);
    586             if let Err(error) = poll_streams(stdout.as_ref(), stderr.as_ref(), wait) {
    587                 break StopCause::Error(error);
    588             }
    589         };
    590 
    591         let original = match stop {
    592             StopCause::Deadline => ProcessError::new(ProcessFailureKind::DeadlineExceeded),
    593             StopCause::Error(error) => error,
    594             StopCause::OrphanedDescendants => {
    595                 ProcessError::new(ProcessFailureKind::OrphanedDescendants)
    596             }
    597             StopCause::OutputLimit => ProcessError::output_limit(limit_breach(
    598                 stdout_capture.exceeded,
    599                 stderr_capture.exceeded,
    600             )),
    601             StopCause::RetainedPipes => ProcessError::new(ProcessFailureKind::CleanupIncomplete),
    602         };
    603         let mut error = cleanup_or_original(
    604             original,
    605             &mut child,
    606             process_group,
    607             &mut status,
    608             &mut stdout,
    609             &mut stderr,
    610             &mut stdout_capture,
    611             &mut stderr_capture,
    612             request.termination_grace,
    613         );
    614         if error.kind == ProcessFailureKind::OutputLimitExceeded {
    615             error.output_limit = Some(limit_breach(
    616                 stdout_capture.exceeded,
    617                 stderr_capture.exceeded,
    618             ));
    619         }
    620         Err(error)
    621     }
    622 
    623     fn configure_nonblocking(
    624         stdout: Option<&ChildStdout>,
    625         stderr: Option<&ChildStderr>,
    626     ) -> Result<(), ProcessError> {
    627         let stdout = stdout.ok_or_else(|| ProcessError::new(ProcessFailureKind::PipeSetup))?;
    628         let stderr = stderr.ok_or_else(|| ProcessError::new(ProcessFailureKind::PipeSetup))?;
    629         set_nonblocking(stdout)?;
    630         set_nonblocking(stderr)
    631     }
    632 
    633     fn set_nonblocking(fd: impl rustix::fd::AsFd) -> Result<(), ProcessError> {
    634         let flags = fcntl_getfl(&fd)
    635             .map_err(|error| ProcessError::from_errno(ProcessFailureKind::PipeSetup, error))?;
    636         fcntl_setfl(fd, flags | OFlags::NONBLOCK)
    637             .map_err(|error| ProcessError::from_errno(ProcessFailureKind::PipeSetup, error))
    638     }
    639 
    640     fn drain_streams(
    641         stdout: &mut Option<ChildStdout>,
    642         stderr: &mut Option<ChildStderr>,
    643         stdout_capture: &mut Capture,
    644         stderr_capture: &mut Capture,
    645     ) -> Result<(), ProcessError> {
    646         drain_stream(stdout, stdout_capture)?;
    647         drain_stream(stderr, stderr_capture)
    648     }
    649 
    650     fn drain_stream<Reader: Read>(
    651         stream: &mut Option<Reader>,
    652         capture: &mut Capture,
    653     ) -> Result<(), ProcessError> {
    654         let Some(reader) = stream.as_mut() else {
    655             return Ok(());
    656         };
    657         let mut buffer = [0_u8; 8192];
    658         let mut reached_eof = false;
    659         let mut failure = None;
    660         let mut bytes_read = 0_usize;
    661         loop {
    662             match reader.read(&mut buffer) {
    663                 Ok(0) => {
    664                     reached_eof = true;
    665                     break;
    666                 }
    667                 Ok(count) => {
    668                     bytes_read = bytes_read.saturating_add(count);
    669                     if capture.record(&buffer[..count]) || bytes_read >= MAX_DRAIN_BYTES_PER_PASS {
    670                         break;
    671                     }
    672                 }
    673                 Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
    674                 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
    675                 Err(error) => {
    676                     failure = Some(ProcessError::from_io(ProcessFailureKind::Read, &error));
    677                     break;
    678                 }
    679             }
    680         }
    681         if reached_eof || failure.is_some() {
    682             *stream = None;
    683         }
    684         match failure {
    685             Some(error) => Err(error),
    686             None => Ok(()),
    687         }
    688     }
    689 
    690     fn poll_streams(
    691         stdout: Option<&ChildStdout>,
    692         stderr: Option<&ChildStderr>,
    693         timeout: Duration,
    694     ) -> Result<(), ProcessError> {
    695         let timeout = Timespec::try_from(timeout)
    696             .map_err(|_| ProcessError::new(ProcessFailureKind::InvalidConfiguration))?;
    697         let result = match (stdout, stderr) {
    698             (Some(stdout), Some(stderr)) => {
    699                 let mut poll_fds = [
    700                     PollFd::new(stdout, PollFlags::IN),
    701                     PollFd::new(stderr, PollFlags::IN),
    702                 ];
    703                 poll(&mut poll_fds, Some(&timeout))
    704             }
    705             (Some(stdout), None) => {
    706                 let mut poll_fds = [PollFd::new(stdout, PollFlags::IN)];
    707                 poll(&mut poll_fds, Some(&timeout))
    708             }
    709             (None, Some(stderr)) => {
    710                 let mut poll_fds = [PollFd::new(stderr, PollFlags::IN)];
    711                 poll(&mut poll_fds, Some(&timeout))
    712             }
    713             (None, None) => {
    714                 let mut poll_fds = [];
    715                 poll(&mut poll_fds, Some(&timeout))
    716             }
    717         };
    718         match result {
    719             Ok(_) | Err(Errno::INTR) => Ok(()),
    720             Err(error) => Err(ProcessError::from_errno(ProcessFailureKind::Poll, error)),
    721         }
    722     }
    723 
    724     fn process_group_is_alive(process_group: Pid) -> Result<bool, ProcessError> {
    725         match test_kill_process_group(process_group) {
    726             Ok(()) | Err(Errno::PERM) => Ok(true),
    727             Err(Errno::SRCH) => Ok(false),
    728             Err(error) => Err(ProcessError::from_errno(ProcessFailureKind::Signal, error)),
    729         }
    730     }
    731 
    732     fn signal_process_group(process_group: Pid, signal: Signal) -> Result<(), ProcessError> {
    733         match kill_process_group(process_group, signal) {
    734             // macOS may report EPERM while the just-exited leader is being
    735             // reaped. Cleanup still proves the group absent below; a group
    736             // that remains present is a hard CleanupIncomplete failure.
    737             Ok(()) | Err(Errno::SRCH | Errno::PERM) => Ok(()),
    738             Err(error) => Err(ProcessError::from_errno(ProcessFailureKind::Signal, error)),
    739         }
    740     }
    741 
    742     #[allow(clippy::too_many_arguments)]
    743     fn cleanup_or_original(
    744         original: ProcessError,
    745         child: &mut Child,
    746         process_group: Pid,
    747         status: &mut Option<ExitStatus>,
    748         stdout: &mut Option<ChildStdout>,
    749         stderr: &mut Option<ChildStderr>,
    750         stdout_capture: &mut Capture,
    751         stderr_capture: &mut Capture,
    752         termination_grace: Duration,
    753     ) -> ProcessError {
    754         match cleanup(
    755             child,
    756             process_group,
    757             status,
    758             stdout,
    759             stderr,
    760             stdout_capture,
    761             stderr_capture,
    762             termination_grace,
    763         ) {
    764             Ok(()) => original,
    765             Err(error) => error,
    766         }
    767     }
    768 
    769     #[allow(clippy::too_many_arguments)]
    770     fn cleanup(
    771         child: &mut Child,
    772         process_group: Pid,
    773         status: &mut Option<ExitStatus>,
    774         stdout: &mut Option<ChildStdout>,
    775         stderr: &mut Option<ChildStderr>,
    776         stdout_capture: &mut Capture,
    777         stderr_capture: &mut Capture,
    778         termination_grace: Duration,
    779     ) -> Result<(), ProcessError> {
    780         let mut first_error = signal_process_group(process_group, Signal::TERM).err();
    781         let term_deadline = Instant::now()
    782             .checked_add(termination_grace)
    783             .unwrap_or_else(Instant::now);
    784         pump_cleanup_until(
    785             term_deadline,
    786             child,
    787             process_group,
    788             status,
    789             stdout,
    790             stderr,
    791             stdout_capture,
    792             stderr_capture,
    793             &mut first_error,
    794         );
    795 
    796         let group_alive = process_group_is_alive(process_group).unwrap_or(true);
    797         if group_alive || status.is_none() {
    798             remember_first(
    799                 &mut first_error,
    800                 signal_process_group(process_group, Signal::KILL).err(),
    801             );
    802             if status.is_none() {
    803                 let _ = child.kill();
    804             }
    805         }
    806         let kill_deadline = Instant::now()
    807             .checked_add(FORCE_KILL_GRACE)
    808             .unwrap_or_else(Instant::now);
    809         pump_cleanup_until(
    810             kill_deadline,
    811             child,
    812             process_group,
    813             status,
    814             stdout,
    815             stderr,
    816             stdout_capture,
    817             stderr_capture,
    818             &mut first_error,
    819         );
    820 
    821         let final_group_alive = match process_group_is_alive(process_group) {
    822             Ok(alive) => alive,
    823             Err(error) => {
    824                 remember_first(&mut first_error, Some(error));
    825                 true
    826             }
    827         };
    828         if status.is_none() || final_group_alive || stdout.is_some() || stderr.is_some() {
    829             *stdout = None;
    830             *stderr = None;
    831             return Err(ProcessError::new(ProcessFailureKind::CleanupIncomplete));
    832         }
    833         match first_error {
    834             Some(error) => Err(error),
    835             None => Ok(()),
    836         }
    837     }
    838 
    839     #[allow(clippy::too_many_arguments)]
    840     fn pump_cleanup_until(
    841         deadline: Instant,
    842         child: &mut Child,
    843         process_group: Pid,
    844         status: &mut Option<ExitStatus>,
    845         stdout: &mut Option<ChildStdout>,
    846         stderr: &mut Option<ChildStderr>,
    847         stdout_capture: &mut Capture,
    848         stderr_capture: &mut Capture,
    849         first_error: &mut Option<ProcessError>,
    850     ) {
    851         loop {
    852             if let Err(error) = drain_streams(stdout, stderr, stdout_capture, stderr_capture) {
    853                 remember_first(first_error, Some(error));
    854             }
    855             if status.is_none() {
    856                 match child.try_wait() {
    857                     Ok(Some(exit_status)) => *status = Some(exit_status),
    858                     Ok(None) => {}
    859                     Err(error) => remember_first(
    860                         first_error,
    861                         Some(ProcessError::from_io(ProcessFailureKind::Wait, &error)),
    862                     ),
    863                 }
    864             }
    865             let group_alive = match process_group_is_alive(process_group) {
    866                 Ok(alive) => alive,
    867                 Err(error) => {
    868                     remember_first(first_error, Some(error));
    869                     true
    870                 }
    871             };
    872             if status.is_some() && !group_alive && stdout.is_none() && stderr.is_none() {
    873                 return;
    874             }
    875 
    876             let now = Instant::now();
    877             if now >= deadline {
    878                 return;
    879             }
    880             let wait = deadline.saturating_duration_since(now).min(POLL_QUANTUM);
    881             if let Err(error) = poll_streams(stdout.as_ref(), stderr.as_ref(), wait) {
    882                 remember_first(first_error, Some(error));
    883                 thread::sleep(wait);
    884             }
    885         }
    886     }
    887 
    888     fn remember_first(slot: &mut Option<ProcessError>, candidate: Option<ProcessError>) {
    889         if slot.is_none() {
    890             *slot = candidate;
    891         }
    892     }
    893 
    894     fn limit_breach(stdout_exceeded: bool, stderr_exceeded: bool) -> OutputLimitBreach {
    895         match (stdout_exceeded, stderr_exceeded) {
    896             (true, true) => OutputLimitBreach::Both,
    897             (true, false) => OutputLimitBreach::Stdout,
    898             (false, true) => OutputLimitBreach::Stderr,
    899             (false, false) => unreachable!("output stop requires an exceeded stream"),
    900         }
    901     }
    902 
    903     #[cfg(test)]
    904     mod stream_tests {
    905         use super::*;
    906         use std::collections::VecDeque;
    907 
    908         #[test]
    909         fn draining_preserves_interrupted_and_nonblocking_streams_but_closes_failures() {
    910             struct ScriptedReader(VecDeque<Result<Vec<u8>, io::ErrorKind>>);
    911             impl Read for ScriptedReader {
    912                 fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
    913                     match self.0.pop_front().unwrap_or(Ok(Vec::new())) {
    914                         Ok(bytes) => {
    915                             output[..bytes.len()].copy_from_slice(&bytes);
    916                             Ok(bytes.len())
    917                         }
    918                         Err(kind) => Err(io::Error::new(kind, "fixture read failure")),
    919                     }
    920                 }
    921             }
    922             let mut stream = Some(ScriptedReader(VecDeque::from([
    923                 Err(io::ErrorKind::Interrupted),
    924                 Ok(b"abc".to_vec()),
    925                 Err(io::ErrorKind::WouldBlock),
    926                 Err(io::ErrorKind::BrokenPipe),
    927             ])));
    928             let mut capture = Capture::new(8);
    929             drain_stream(&mut stream, &mut capture).unwrap();
    930             assert!(stream.is_some());
    931             assert_eq!(capture.bytes, b"abc");
    932             assert_eq!(
    933                 drain_stream(&mut stream, &mut capture).unwrap_err().kind(),
    934                 ProcessFailureKind::Read
    935             );
    936             assert!(stream.is_none());
    937             drain_stream(&mut stream, &mut capture).unwrap();
    938             assert_eq!(capture.bytes, b"abc");
    939         }
    940 
    941         #[test]
    942         fn draining_has_a_fairness_budget_and_retains_only_the_configured_prefix() {
    943             let mut stream = Some(io::Cursor::new(vec![b'x'; MAX_DRAIN_BYTES_PER_PASS + 1]));
    944             let mut capture = Capture::new(MAX_DRAIN_BYTES_PER_PASS * 2);
    945             drain_stream(&mut stream, &mut capture).unwrap();
    946             assert_eq!(capture.bytes.len(), MAX_DRAIN_BYTES_PER_PASS);
    947             assert!(stream.is_some());
    948             drain_stream(&mut stream, &mut capture).unwrap();
    949             assert!(stream.is_none());
    950             assert_eq!(capture.bytes.len(), MAX_DRAIN_BYTES_PER_PASS + 1);
    951             let mut stream = Some(io::Cursor::new(b"abcdef"));
    952             let mut capture = Capture::new(3);
    953             drain_stream(&mut stream, &mut capture).unwrap();
    954             assert!(capture.exceeded);
    955             assert_eq!(capture.bytes, b"abc");
    956             drain_stream(&mut stream, &mut capture).unwrap();
    957             assert!(stream.is_none());
    958             assert_eq!(capture.bytes, b"abc");
    959         }
    960 
    961         #[test]
    962         fn cleanup_keeps_the_original_error_when_later_cleanup_also_fails() {
    963             let original = ProcessError::new(ProcessFailureKind::Read);
    964             let mut first = None;
    965             remember_first(&mut first, None);
    966             assert!(first.is_none());
    967             remember_first(&mut first, Some(original));
    968             remember_first(
    969                 &mut first,
    970                 Some(ProcessError::new(ProcessFailureKind::InvalidConfiguration)),
    971             );
    972             assert_eq!(first.unwrap().kind(), ProcessFailureKind::Read);
    973         }
    974     }
    975 }
    976 
    977 pub(crate) fn self_test() -> Result<(), String> {
    978     #[cfg(unix)]
    979     {
    980         self_test_suite()?;
    981         println!("bounded process self-test: ok");
    982         Ok(())
    983     }
    984     #[cfg(not(unix))]
    985     {
    986         Err(ProcessError::new(ProcessFailureKind::UnsupportedPlatform).to_string())
    987     }
    988 }
    989 
    990 #[cfg(unix)]
    991 fn self_test_suite() -> Result<(), String> {
    992     verify_replacement_environment()?;
    993     verify_closed_stdin()?;
    994     verify_environment_rejections()?;
    995     verify_deadline_tree_kill()?;
    996     verify_orphan_kill()?;
    997     verify_dual_output_caps()?;
    998     verify_continuous_output_is_bounded()?;
    999     verify_redaction()?;
   1000     Ok(())
   1001 }
   1002 
   1003 #[cfg(unix)]
   1004 fn verify_replacement_environment() -> Result<(), String> {
   1005     let mut environment = ReplacementEnvironment::default();
   1006     environment
   1007         .insert("RSHR_VISIBLE", "replacement-only")
   1008         .map_err(|error| error.to_string())?;
   1009     let output = run(&ProcessRequest::new("/usr/bin/env")
   1010         .environment(environment)
   1011         .deadline(Duration::from_secs(2))
   1012         .output_limits(4096, 4096))
   1013     .map_err(|error| error.to_string())?;
   1014     if !output.status().success()
   1015         || output.stdout() != b"RSHR_VISIBLE=replacement-only\n"
   1016         || !output.stderr().is_empty()
   1017     {
   1018         return Err("replacement environment self-test failed".to_owned());
   1019     }
   1020     Ok(())
   1021 }
   1022 
   1023 #[cfg(unix)]
   1024 fn verify_closed_stdin() -> Result<(), String> {
   1025     let working_directory =
   1026         tempfile::tempdir().map_err(|_| "create self-test directory".to_owned())?;
   1027     let output = run(&ProcessRequest::new("/bin/sh")
   1028         .arg("-c")
   1029         .arg("if IFS= read -r value; then exit 92; fi; printf 'stdin-closed'")
   1030         .current_dir(working_directory.path())
   1031         .deadline(Duration::from_secs(2)))
   1032     .map_err(|error| error.to_string())?;
   1033     if !output.status().success() || output.stdout() != b"stdin-closed" {
   1034         return Err("closed stdin self-test failed".to_owned());
   1035     }
   1036     Ok(())
   1037 }
   1038 
   1039 #[cfg(unix)]
   1040 fn verify_environment_rejections() -> Result<(), String> {
   1041     for name in FORBIDDEN_ENVIRONMENT_NAMES {
   1042         expect_environment_rejection(name, EnvironmentRejection::ForbiddenControl)?;
   1043     }
   1044     for name in [
   1045         "CARGO_BUILD_RUSTFLAGS",
   1046         "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS",
   1047         "DYLD_FRAMEWORK_PATH",
   1048         "LD_AUDIT",
   1049         "LD_DEBUG",
   1050         "LD_PROFILE",
   1051         "RUSTC_WRAPPER",
   1052         "RUSTC_WORKSPACE_WRAPPER",
   1053         "RUSTDOCFLAGS",
   1054     ] {
   1055         expect_environment_rejection(name, EnvironmentRejection::ForbiddenControl)?;
   1056     }
   1057     for name in [
   1058         "CREDENTIAL",
   1059         "APIKEY",
   1060         "PASSWORD",
   1061         "SECRET",
   1062         "TOKEN",
   1063         "RSHR_CREDENTIAL_FILE",
   1064         "RSHR_KEY_ID",
   1065         "RSHR_PASSWORD_FILE",
   1066         "RSHR_SECRET_VALUE",
   1067         "RSHR_TOKEN_FILE",
   1068     ] {
   1069         expect_environment_rejection(name, EnvironmentRejection::SensitiveName)?;
   1070     }
   1071     Ok(())
   1072 }
   1073 
   1074 #[cfg(unix)]
   1075 fn expect_environment_rejection(name: &str, expected: EnvironmentRejection) -> Result<(), String> {
   1076     let mut environment = ReplacementEnvironment::default();
   1077     let error = environment
   1078         .insert(name, "redacted-value")
   1079         .expect_err("forbidden environment entry must fail closed");
   1080     if error.kind() != ProcessFailureKind::EnvironmentRejected(expected) {
   1081         return Err("environment rejection self-test failed".to_owned());
   1082     }
   1083     Ok(())
   1084 }
   1085 
   1086 #[cfg(unix)]
   1087 fn verify_deadline_tree_kill() -> Result<(), String> {
   1088     let pid_file =
   1089         tempfile::NamedTempFile::new().map_err(|_| "create self-test pid file".to_owned())?;
   1090     let request = ProcessRequest::new("/bin/sh")
   1091         .arg("-c")
   1092         .arg(
   1093             "trap 'kill \"$child\" 2>/dev/null; wait \"$child\" 2>/dev/null; exit 143' TERM; \
   1094              /bin/sleep 30 & child=$!; printf '%s\n' \"$child\" > \"$1\"; wait \"$child\"",
   1095         )
   1096         .arg("rshr-bounded-process")
   1097         .arg(pid_file.path())
   1098         .deadline(Duration::from_millis(750))
   1099         .termination_grace(Duration::from_millis(100));
   1100     let error = run(&request).expect_err("deadline self-test must time out");
   1101     if error.kind() != ProcessFailureKind::DeadlineExceeded {
   1102         return Err("deadline classification self-test failed".to_owned());
   1103     }
   1104     verify_recorded_process_gone(pid_file.path())
   1105 }
   1106 
   1107 #[cfg(unix)]
   1108 fn verify_orphan_kill() -> Result<(), String> {
   1109     let pid_file =
   1110         tempfile::NamedTempFile::new().map_err(|_| "create self-test pid file".to_owned())?;
   1111     let request = ProcessRequest::new("/bin/sh")
   1112         .arg("-c")
   1113         .arg("/bin/sleep 30 & child=$!; printf '%s\n' \"$child\" > \"$1\"; exit 0")
   1114         .arg("rshr-bounded-process")
   1115         .arg(pid_file.path())
   1116         .deadline(Duration::from_secs(2))
   1117         .termination_grace(Duration::from_millis(100));
   1118     let error = run(&request).expect_err("orphan self-test must fail closed");
   1119     if error.kind() != ProcessFailureKind::OrphanedDescendants {
   1120         return Err("orphan classification self-test failed".to_owned());
   1121     }
   1122     verify_recorded_process_gone(pid_file.path())
   1123 }
   1124 
   1125 #[cfg(unix)]
   1126 fn verify_recorded_process_gone(path: &std::path::Path) -> Result<(), String> {
   1127     use rustix::io::Errno;
   1128     use rustix::process::{Pid, test_kill_process};
   1129     use std::fs;
   1130     use std::thread;
   1131     use std::time::Instant;
   1132 
   1133     let raw = fs::read_to_string(path).map_err(|_| "read self-test pid file".to_owned())?;
   1134     let pid_number = raw
   1135         .trim()
   1136         .parse::<i32>()
   1137         .map_err(|_| "parse self-test pid".to_owned())?;
   1138     let pid =
   1139         Pid::from_raw(pid_number).ok_or_else(|| "self-test pid must be positive".to_owned())?;
   1140     let deadline = Instant::now()
   1141         .checked_add(Duration::from_secs(2))
   1142         .ok_or_else(|| "self-test pid deadline overflow".to_owned())?;
   1143     loop {
   1144         match test_kill_process(pid) {
   1145             Err(Errno::SRCH) => return Ok(()),
   1146             Ok(()) | Err(Errno::PERM) if Instant::now() < deadline => {
   1147                 thread::sleep(Duration::from_millis(10));
   1148             }
   1149             _ => return Err("bounded process descendant survived cleanup".to_owned()),
   1150         }
   1151     }
   1152 }
   1153 
   1154 #[cfg(unix)]
   1155 fn verify_dual_output_caps() -> Result<(), String> {
   1156     let stdout_error = run(&ProcessRequest::new("/bin/sh")
   1157         .arg("-c")
   1158         .arg("printf 'stdout-over-cap'")
   1159         .deadline(Duration::from_secs(2))
   1160         .output_limits(4, 4096))
   1161     .expect_err("stdout cap self-test must fail closed");
   1162     if stdout_error.output_limit_breach() != Some(OutputLimitBreach::Stdout) {
   1163         return Err(format!("stdout cap self-test failed: {stdout_error:?}"));
   1164     }
   1165 
   1166     let stderr_error = run(&ProcessRequest::new("/bin/sh")
   1167         .arg("-c")
   1168         .arg("printf 'stderr-over-cap' >&2")
   1169         .deadline(Duration::from_secs(2))
   1170         .output_limits(4096, 4))
   1171     .expect_err("stderr cap self-test must fail closed");
   1172     if stderr_error.output_limit_breach() != Some(OutputLimitBreach::Stderr) {
   1173         return Err(format!("stderr cap self-test failed: {stderr_error:?}"));
   1174     }
   1175     Ok(())
   1176 }
   1177 
   1178 #[cfg(unix)]
   1179 fn verify_continuous_output_is_bounded() -> Result<(), String> {
   1180     let error = run(&ProcessRequest::new("/bin/sh")
   1181         .arg("-c")
   1182         .arg("while :; do printf 'continuous-output'; done")
   1183         .deadline(Duration::from_secs(2))
   1184         .output_limits(4096, 4096)
   1185         .termination_grace(Duration::from_millis(100)))
   1186     .expect_err("continuous output must hit its live cap");
   1187     if error.output_limit_breach() != Some(OutputLimitBreach::Stdout) {
   1188         return Err("continuous output cap self-test failed".to_owned());
   1189     }
   1190     Ok(())
   1191 }
   1192 
   1193 #[cfg(unix)]
   1194 fn verify_redaction() -> Result<(), String> {
   1195     const ARGUMENT_MARKER: &str = "argument-marker-must-not-leak";
   1196     const CURRENT_DIR_MARKER: &str = "/cwd-marker-must-not-leak";
   1197     const ENVIRONMENT_MARKER: &str = "environment-marker-must-not-leak";
   1198     const PROGRAM_MARKER: &str = "/program-marker-must-not-leak";
   1199 
   1200     let mut environment = ReplacementEnvironment::default();
   1201     environment
   1202         .insert("RSHR_VISIBLE", ENVIRONMENT_MARKER)
   1203         .map_err(|error| error.to_string())?;
   1204     let diagnostics_request = ProcessRequest::new(PROGRAM_MARKER)
   1205         .arg(ARGUMENT_MARKER)
   1206         .current_dir(CURRENT_DIR_MARKER)
   1207         .environment(environment.clone());
   1208     let request_debug = format!("{diagnostics_request:?}");
   1209     if [
   1210         ARGUMENT_MARKER,
   1211         CURRENT_DIR_MARKER,
   1212         ENVIRONMENT_MARKER,
   1213         PROGRAM_MARKER,
   1214     ]
   1215     .iter()
   1216     .any(|marker| request_debug.contains(marker))
   1217     {
   1218         return Err("request redaction self-test failed".to_owned());
   1219     }
   1220     let spawn_error = run(&diagnostics_request)
   1221         .expect_err("missing redaction fixture program must fail to spawn");
   1222     let spawn_debug = format!("{spawn_error:?}");
   1223     let spawn_display = spawn_error.to_string();
   1224     if [
   1225         ARGUMENT_MARKER,
   1226         CURRENT_DIR_MARKER,
   1227         ENVIRONMENT_MARKER,
   1228         PROGRAM_MARKER,
   1229     ]
   1230     .iter()
   1231     .any(|marker| spawn_debug.contains(marker) || spawn_display.contains(marker))
   1232     {
   1233         return Err("spawn error redaction self-test failed".to_owned());
   1234     }
   1235 
   1236     let request = ProcessRequest::new("/bin/sh")
   1237         .arg("-c")
   1238         .arg("printf '%s' \"$RSHR_VISIBLE\"; printf '%s' \"$1\" >&2")
   1239         .arg("rshr-bounded-process")
   1240         .arg(ARGUMENT_MARKER)
   1241         .environment(environment)
   1242         .deadline(Duration::from_secs(2));
   1243 
   1244     let output = run(&request).map_err(|error| error.to_string())?;
   1245     if output.stdout() != ENVIRONMENT_MARKER.as_bytes()
   1246         || output.stderr() != ARGUMENT_MARKER.as_bytes()
   1247     {
   1248         return Err("redaction fixture output self-test failed".to_owned());
   1249     }
   1250     let output_debug = format!("{output:?}");
   1251     if output_debug.contains(ARGUMENT_MARKER) || output_debug.contains(ENVIRONMENT_MARKER) {
   1252         return Err("output redaction self-test failed".to_owned());
   1253     }
   1254 
   1255     let capped = run(&ProcessRequest::new("/bin/sh")
   1256         .arg("-c")
   1257         .arg(format!("printf '{ARGUMENT_MARKER}'"))
   1258         .output_limits(1, 1))
   1259     .expect_err("redaction cap fixture must fail closed");
   1260     let error_debug = format!("{capped:?}");
   1261     let error_display = capped.to_string();
   1262     if error_debug.contains(ARGUMENT_MARKER) || error_display.contains(ARGUMENT_MARKER) {
   1263         return Err("error redaction self-test failed".to_owned());
   1264     }
   1265     Ok(())
   1266 }
   1267 
   1268 #[cfg(all(test, unix))]
   1269 mod tests {
   1270     use super::*;
   1271 
   1272     #[test]
   1273     fn replacement_environment_is_exact_and_stdin_is_closed() {
   1274         verify_replacement_environment().expect("replacement environment must be exact");
   1275         verify_closed_stdin().expect("stdin must be closed");
   1276     }
   1277 
   1278     #[test]
   1279     fn build_loader_and_sensitive_environment_controls_are_rejected() {
   1280         verify_environment_rejections().expect("environment controls must fail closed");
   1281     }
   1282 
   1283     #[test]
   1284     fn deadline_kills_the_process_tree() {
   1285         verify_deadline_tree_kill().expect("deadline must kill the process group");
   1286     }
   1287 
   1288     #[test]
   1289     fn leader_exit_kills_orphaned_descendants() {
   1290         verify_orphan_kill().expect("orphaned process group must be killed");
   1291     }
   1292 
   1293     #[test]
   1294     fn stdout_and_stderr_have_independent_caps() {
   1295         verify_dual_output_caps().expect("both output streams must be capped");
   1296         verify_continuous_output_is_bounded().expect("continuous output must remain bounded");
   1297     }
   1298 
   1299     #[test]
   1300     fn request_output_and_error_diagnostics_are_redacted() {
   1301         verify_redaction().expect("bounded process diagnostics must be redacted");
   1302     }
   1303 
   1304     #[test]
   1305     fn environment_boundaries_fail_closed() {
   1306         use std::os::unix::ffi::OsStringExt;
   1307 
   1308         for name in [
   1309             "".to_owned(),
   1310             "A".repeat(MAX_ENVIRONMENT_NAME_BYTES + 1),
   1311             "A\0B".to_owned(),
   1312             "É".to_owned(),
   1313             "1INVALID".to_owned(),
   1314             "BAD-NAME".to_owned(),
   1315         ] {
   1316             assert_eq!(
   1317                 environment_rejection(&name),
   1318                 Some(EnvironmentRejection::InvalidNameOrNul)
   1319             );
   1320         }
   1321         assert_eq!(
   1322             environment_rejection("CARGO_TARGET_FIXTURE_RUSTFLAGS"),
   1323             Some(EnvironmentRejection::ForbiddenControl)
   1324         );
   1325         assert_eq!(environment_rejection("CARGO_TARGET_FIXTURE"), None);
   1326         assert_eq!(environment_rejection("_FIXTURE_1"), None);
   1327 
   1328         let mut environment = ReplacementEnvironment::default();
   1329         environment
   1330             .insert("RSHR_VISIBLE", "first")
   1331             .expect("first entry must be accepted");
   1332         let duplicate = environment
   1333             .insert("RSHR_VISIBLE", "second")
   1334             .expect_err("duplicate must fail");
   1335         assert_eq!(
   1336             duplicate.kind(),
   1337             ProcessFailureKind::EnvironmentRejected(EnvironmentRejection::DuplicateName)
   1338         );
   1339 
   1340         let mut nul_environment = ReplacementEnvironment::default();
   1341         let nul = nul_environment
   1342             .insert("RSHR_VISIBLE", OsString::from_vec(b"value\0tail".to_vec()))
   1343             .expect_err("NUL value must fail");
   1344         assert_eq!(
   1345             nul.kind(),
   1346             ProcessFailureKind::EnvironmentRejected(EnvironmentRejection::InvalidNameOrNul)
   1347         );
   1348 
   1349         let mut full_environment = ReplacementEnvironment::default();
   1350         for index in 0..MAX_ENVIRONMENT_ENTRIES {
   1351             full_environment
   1352                 .insert(format!("RSHR_ENTRY_{index}"), "value")
   1353                 .expect("entry within cardinality bound must pass");
   1354         }
   1355         let too_many = full_environment
   1356             .insert("RSHR_ENTRY_OVER_LIMIT", "value")
   1357             .expect_err("entry above cardinality bound must fail");
   1358         assert_eq!(
   1359             too_many.kind(),
   1360             ProcessFailureKind::EnvironmentRejected(EnvironmentRejection::TooManyEntries)
   1361         );
   1362 
   1363         let mut large_environment = ReplacementEnvironment::default();
   1364         let too_large = large_environment
   1365             .insert(
   1366                 "RSHR_VISIBLE",
   1367                 OsString::from_vec(vec![b'x'; MAX_ENVIRONMENT_VALUE_BYTES + 1]),
   1368             )
   1369             .expect_err("value above byte bound must fail");
   1370         assert_eq!(
   1371             too_large.kind(),
   1372             ProcessFailureKind::EnvironmentRejected(EnvironmentRejection::ValueTooLarge)
   1373         );
   1374     }
   1375 
   1376     #[test]
   1377     fn hard_configuration_maximums_fail_before_spawn() {
   1378         for request in [
   1379             ProcessRequest::new(""),
   1380             ProcessRequest::new("/usr/bin/true").deadline(Duration::ZERO),
   1381             ProcessRequest::new("/usr/bin/true").deadline(MAX_DEADLINE + Duration::from_secs(1)),
   1382             ProcessRequest::new("/usr/bin/true")
   1383                 .output_limits(MAX_STREAM_LIMIT + 1, MAX_STREAM_LIMIT),
   1384             ProcessRequest::new("/usr/bin/true")
   1385                 .output_limits(MAX_STREAM_LIMIT, MAX_STREAM_LIMIT + 1),
   1386             ProcessRequest::new("/usr/bin/true")
   1387                 .termination_grace(MAX_TERMINATION_GRACE + Duration::from_millis(1)),
   1388         ] {
   1389             let error = run(&request).expect_err("configuration above hard maximum must fail");
   1390             assert_eq!(error.kind(), ProcessFailureKind::InvalidConfiguration);
   1391         }
   1392     }
   1393 
   1394     #[test]
   1395     fn exact_and_zero_stream_caps_have_byte_precise_semantics() {
   1396         let exact = run(&ProcessRequest::new("/bin/sh")
   1397             .arg("-c")
   1398             .arg("printf '1234'; printf 'abcd' >&2")
   1399             .output_limits(4, 4))
   1400         .expect("exact cap must pass");
   1401         assert_eq!(exact.stdout(), b"1234");
   1402         assert_eq!(exact.stderr(), b"abcd");
   1403 
   1404         let empty = run(&ProcessRequest::new("/usr/bin/true")
   1405             .output_limits(0, 0)
   1406             .deadline(Duration::from_secs(2)))
   1407         .expect("empty streams must fit zero caps");
   1408         assert!(empty.stdout().is_empty());
   1409         assert!(empty.stderr().is_empty());
   1410 
   1411         let over_zero = run(&ProcessRequest::new("/bin/sh")
   1412             .arg("-c")
   1413             .arg("printf x")
   1414             .output_limits(0, 0))
   1415         .expect_err("first byte above zero cap must fail");
   1416         assert_eq!(
   1417             over_zero.output_limit_breach(),
   1418             Some(OutputLimitBreach::Stdout)
   1419         );
   1420     }
   1421 }