From 3077e33f964c60a1f1856926de15ea5dba6c434e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sun, 11 Oct 2026 22:52:29 -0500 Subject: [PATCH 1/4] fix(interpreter): charge input budget for numbered input redirects Each N $f`, `> out-*`, /// `> a-{1,2}`), or one with a quoted expansion. Plain words skip the /// field pass. +/// Numbered-fd table with replace semantics (input-redirect budget fix): re-redirecting +/// an fd drops the superseded buffer instead of accumulating clones. +fn replace_own_fd(own_fds: &mut Vec<(i32, crate::StreamData)>, fd: i32, data: crate::StreamData) { + if let Some(slot) = own_fds.iter_mut().find(|(existing, _)| *existing == fd) { + *slot = (fd, data); + } else { + own_fds.push((fd, data)); + } +} + fn redirect_target_may_split(redirect: &Redirect) -> bool { if !matches!( redirect.kind, @@ -229,6 +239,21 @@ impl Interpreter { self.diag(format!("{path}: {reason}\n")) } + /// Charging helper for numbered input redirects (input-redirect budget fix). Every + /// retained buffer counts against the aggregate input budget and holds + /// a live-bytes lease while the redirect list is processed. Charges run + /// before the clone/allocation they cover; re-redirecting the same fd + /// replaces the superseded entry instead of piling clones. + fn charge_input_retention( + &self, + leases: &mut Vec, + len: usize, + ) -> crate::error::Result<()> { + self.execution_budget.consume_input(len)?; + leases.push(self.execution_budget.lease_bytes(len)?); + Ok(()) + } + /// Process input redirections (< file, <<< string). `demand` is how /// much the command reads, for stdin taken from a coproc's pipe. pub(super) async fn process_input_redirections( @@ -241,6 +266,9 @@ impl Interpreter { let mut stdin = existing_stdin; // Readable fds this command's own redirects open (`4< = Vec::new(); + // Live-bytes leases for buffers retained by numbered + // input redirects, held until the redirect list is fully processed. + let mut input_leases: Vec = Vec::new(); for redirect in redirects { // Fd N >= 1 for an input-side redirect (`3f`). @@ -271,7 +299,10 @@ impl Interpreter { } }; match high_fd { - Some(fd) => own_fds.push((fd, content)), + Some(fd) => { + self.charge_input_retention(&mut input_leases, content.len())?; + replace_own_fd(&mut own_fds, fd, content); + } None => stdin = Some(content), } } @@ -300,7 +331,8 @@ impl Interpreter { Ok(content) => { let content: crate::StreamData = content.into(); if let Some(fd) = high_fd { - own_fds.push((fd, content.clone())); + self.charge_input_retention(&mut input_leases, content.len())?; + replace_own_fd(&mut own_fds, fd, content.clone()); } // WTF: on a compound `3 own_fds.push((fd, content)), + Some(fd) => { + self.charge_input_retention(&mut input_leases, content.len())?; + replace_own_fd(&mut own_fds, fd, content); + } None => stdin = Some(content), } } @@ -340,7 +375,10 @@ impl Interpreter { let content: crate::StreamData = self.expand_word(&redirect.target).await?.into(); match high_fd { - Some(fd) => own_fds.push((fd, content)), + Some(fd) => { + self.charge_input_retention(&mut input_leases, content.len())?; + replace_own_fd(&mut own_fds, fd, content); + } None => stdin = Some(content), } } @@ -360,11 +398,13 @@ impl Interpreter { let Ok(src) = target.parse::() else { continue; }; - // `<&4` after this command's own `4< own_fds.push((fd, content)), + Some(fd) => replace_own_fd(&mut own_fds, fd, content), None => stdin = Some(content), } continue; diff --git a/crates/bashkit/tests/integration/input_redirect_budget_tests.rs b/crates/bashkit/tests/integration/input_redirect_budget_tests.rs new file mode 100644 index 000000000..c082f1319 --- /dev/null +++ b/crates/bashkit/tests/integration/input_redirect_budget_tests.rs @@ -0,0 +1,75 @@ +//! Repeated numbered input redirects must charge the input budget: +//! +//! Each `N Date: Sun, 11 Oct 2026 23:44:46 -0500 Subject: [PATCH 2/4] fix(interpreter): bind input-retention leases to fd entries, count both buffers StreamData retains bytes plus decoded text; charge retained_len so the live/aggregate admission covers the full backing store. Store each live-bytes lease with its fd entry so replacing a superseded descriptor releases the old lease while aggregate consumption stays cumulative. Tests: exact 63/64 and 71/72 boundaries, live release on replace (2x transient), cumulative-across-replace, all reachable retaining branches, 1024x /dev/zero shape, session recovery, dup-chain parity, 1MiB heredoc bound. --- crates/bashkit/src/interpreter/redirection.rs | 64 ++++--- crates/bashkit/src/stream.rs | 6 + .../input_redirect_budget_tests.rs | 173 +++++++++++++++++- 3 files changed, 212 insertions(+), 31 deletions(-) diff --git a/crates/bashkit/src/interpreter/redirection.rs b/crates/bashkit/src/interpreter/redirection.rs index 42aa6a6b9..30dbb4bbe 100644 --- a/crates/bashkit/src/interpreter/redirection.rs +++ b/crates/bashkit/src/interpreter/redirection.rs @@ -25,16 +25,6 @@ use crate::error::io_error_reason; /// expansion could turn into other than one word (`> $f`, `> out-*`, /// `> a-{1,2}`), or one with a quoted expansion. Plain words skip the /// field pass. -/// Numbered-fd table with replace semantics (input-redirect budget fix): re-redirecting -/// an fd drops the superseded buffer instead of accumulating clones. -fn replace_own_fd(own_fds: &mut Vec<(i32, crate::StreamData)>, fd: i32, data: crate::StreamData) { - if let Some(slot) = own_fds.iter_mut().find(|(existing, _)| *existing == fd) { - *slot = (fd, data); - } else { - own_fds.push((fd, data)); - } -} - fn redirect_target_may_split(redirect: &Redirect) -> bool { if !matches!( redirect.kind, @@ -66,6 +56,24 @@ fn redirect_target_may_split(redirect: &Redirect) -> bool { }) } +/// Numbered-fd table with replace semantics: re-redirecting an fd drops the +/// superseded buffer — and its live-bytes lease — instead of piling clones. +/// Aggregate input consumption stays cumulative. +type OwnFdEntry = (i32, crate::StreamData, crate::limits::ExecutionBudgetLease); + +fn replace_own_fd( + own_fds: &mut Vec, + fd: i32, + data: crate::StreamData, + lease: crate::limits::ExecutionBudgetLease, +) { + if let Some(slot) = own_fds.iter_mut().find(|(existing, _, _)| *existing == fd) { + *slot = (fd, data, lease); + } else { + own_fds.push((fd, data, lease)); + } +} + /// `N<= 3: a readable fd for the command's body. fn high_fd_here(redirect: &Redirect) -> bool { matches!( @@ -246,12 +254,10 @@ impl Interpreter { /// replaces the superseded entry instead of piling clones. fn charge_input_retention( &self, - leases: &mut Vec, len: usize, - ) -> crate::error::Result<()> { + ) -> crate::error::Result { self.execution_budget.consume_input(len)?; - leases.push(self.execution_budget.lease_bytes(len)?); - Ok(()) + Ok(self.execution_budget.lease_bytes(len)?) } /// Process input redirections (< file, <<< string). `demand` is how @@ -265,10 +271,7 @@ impl Interpreter { ) -> Result> { let mut stdin = existing_stdin; // Readable fds this command's own redirects open (`4< = Vec::new(); - // Live-bytes leases for buffers retained by numbered - // input redirects, held until the redirect list is fully processed. - let mut input_leases: Vec = Vec::new(); + let mut own_fds: Vec = Vec::new(); for redirect in redirects { // Fd N >= 1 for an input-side redirect (`3f`). @@ -300,8 +303,8 @@ impl Interpreter { }; match high_fd { Some(fd) => { - self.charge_input_retention(&mut input_leases, content.len())?; - replace_own_fd(&mut own_fds, fd, content); + let lease = self.charge_input_retention(content.retained_len())?; + replace_own_fd(&mut own_fds, fd, content, lease); } None => stdin = Some(content), } @@ -331,8 +334,9 @@ impl Interpreter { Ok(content) => { let content: crate::StreamData = content.into(); if let Some(fd) = high_fd { - self.charge_input_retention(&mut input_leases, content.len())?; - replace_own_fd(&mut own_fds, fd, content.clone()); + let lease = + self.charge_input_retention(content.retained_len())?; + replace_own_fd(&mut own_fds, fd, content.clone(), lease); } // WTF: on a compound `3 { - self.charge_input_retention(&mut input_leases, content.len())?; - replace_own_fd(&mut own_fds, fd, content); + let lease = self.charge_input_retention(content.retained_len())?; + replace_own_fd(&mut own_fds, fd, content, lease); } None => stdin = Some(content), } @@ -376,8 +380,8 @@ impl Interpreter { self.expand_word(&redirect.target).await?.into(); match high_fd { Some(fd) => { - self.charge_input_retention(&mut input_leases, content.len())?; - replace_own_fd(&mut own_fds, fd, content); + let lease = self.charge_input_retention(content.retained_len())?; + replace_own_fd(&mut own_fds, fd, content, lease); } None => stdin = Some(content), } @@ -400,11 +404,13 @@ impl Interpreter { }; // `<&4` after this command's own `4< replace_own_fd(&mut own_fds, fd, content), + Some(fd) => replace_own_fd(&mut own_fds, fd, content, lease), None => stdin = Some(content), } continue; diff --git a/crates/bashkit/src/stream.rs b/crates/bashkit/src/stream.rs index e22fb3799..c33a92fb3 100644 --- a/crates/bashkit/src/stream.rs +++ b/crates/bashkit/src/stream.rs @@ -58,6 +58,12 @@ impl StreamData { pub fn len(&self) -> usize { self.bytes.len() } + + /// Live bytes retained by this buffer: the raw bytes plus the decoded + /// text kept alongside them. + pub(crate) fn retained_len(&self) -> usize { + self.bytes.len().saturating_add(self.text.len()) + } pub fn is_empty(&self) -> bool { self.bytes.is_empty() } diff --git a/crates/bashkit/tests/integration/input_redirect_budget_tests.rs b/crates/bashkit/tests/integration/input_redirect_budget_tests.rs index c082f1319..2391e7842 100644 --- a/crates/bashkit/tests/integration/input_redirect_budget_tests.rs +++ b/crates/bashkit/tests/integration/input_redirect_budget_tests.rs @@ -12,7 +12,7 @@ use std::path::Path; #[tokio::test] /// Repeated numbered input redirects exceed the aggregate input budget. async fn numbered_input_redirects_charge_aggregate_input_budget() { - let payload = vec![b'x'; 64]; + let payload = vec![b'x'; 32]; let mut bash = Bash::builder() .limits(ExecutionLimits::new().max_aggregate_input_bytes(100)) .build(); @@ -53,7 +53,7 @@ async fn superseded_numbered_input_fd_is_replaced() { #[tokio::test] /// Retained numbered-fd buffers hold live-bytes leases while processed. async fn numbered_input_redirects_hold_live_bytes_budget() { - let payload = vec![b'x'; 64]; + let payload = vec![b'x'; 32]; let mut bash = Bash::builder() .limits(ExecutionLimits::new().max_live_intermediate_bytes(100)) .build(); @@ -73,3 +73,172 @@ async fn numbered_input_redirects_hold_live_bytes_budget() { "expected live-bytes budget exhaustion, got {result:?}" ); } + +#[tokio::test] +/// Original attack shape: ':' with 1024 same-fd /dev/zero redirects trips +/// the aggregate budget on the first retained buffer instead of piling MiBs. +async fn original_shape_1024_dev_zero_trips_fast() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_aggregate_input_bytes(65_536)) + .build(); + let script = format!(":{}", " 3/big"]; + for script in cases { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_aggregate_input_bytes(31)) + .build(); + bash.fs() + .write_file(Path::new("/big"), b"sixteen-bytesxx") + .await + .unwrap(); + let result = bash.exec(script).await; + assert!( + matches!( + result, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget( + ExecutionBudgetExceeded::InputBytes { .. } + ))) + ), + "script {script:?} should trip the input budget, got {result:?}" + ); + } +} + +#[tokio::test] +/// The session stays usable after a budget error: unrelated commands run. +async fn session_recovers_after_budget_error() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_aggregate_input_bytes(100)) + .build(); + bash.fs() + .write_file(Path::new("/big"), &[b'x'; 32]) + .await + .unwrap(); + let tripped = bash.exec(": 3 Date: Mon, 12 Oct 2026 00:32:50 -0500 Subject: [PATCH 3/4] fix(interpreter): hand stdin-clone leases to caller, prove dup/overlap/child bounds The DupInput-into-stdin clone dropped its live lease while retained. The stdin slot now carries the clone lease and the setup struct hands it to callers, who hold it for stdin's lifetime; replacing stdin drops both. Tests: two-phase DupInput proof (source fits, target trips InputBytes), ReadWrite trip, stdin-clone 3x64B live overlap (192 ok / 191 LiveBytes) with recovery, bounded-child original 1024x/dev/zero denial naming aggregate input bytes with clean exit 1. --- crates/bashkit/src/interpreter/mod.rs | 19 +- crates/bashkit/src/interpreter/redirection.rs | 61 ++++- .../input_redirect_budget_tests.rs | 244 ++++++++++++++++-- 3 files changed, 282 insertions(+), 42 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 10fce58d5..d63d2f37c 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -9379,7 +9379,7 @@ impl Interpreter { } // Handle input redirections first - let stdin = match self + let stdin_setup = match self .process_input_redirections( stdin, &command.redirects, @@ -9394,6 +9394,10 @@ impl Interpreter { } Err(e) => return Err(e), }; + // Hold the stdin clone's live lease (if stdin is a budgeted + // clone minted above) for as long as `stdin` is retained below. + let _stdin_storage = stdin_setup.stdin_lease; + let stdin = stdin_setup.stdin; // `read -u FD` on a readable fd (`exec 3 return Err(e), }; + // Hold the stdin clone's live lease (if stdin is a budgeted + // clone minted above) for as long as `stdin` is retained below. + let _stdin_storage = stdin_setup.stdin_lease; + let stdin = stdin_setup.stdin; let prev_pipeline_stdin = if stdin.is_some() { let prev = self.pipeline_stdin.take(); self.pipeline_stdin = stdin; @@ -12974,8 +12982,11 @@ impl Interpreter { .await }; match read { - Ok(content) => { - if let Some(content) = content { + Ok(setup) => { + // Hold the stdin clone's live lease while the + // substitution output derived from it is live. + let _stdin_storage = setup.stdin_lease; + if let Some(content) = setup.stdin { // Account for the VFS-owned input while it and the // decoded substitution text are simultaneously live. let _content_lease = diff --git a/crates/bashkit/src/interpreter/redirection.rs b/crates/bashkit/src/interpreter/redirection.rs index 30dbb4bbe..46b4ca424 100644 --- a/crates/bashkit/src/interpreter/redirection.rs +++ b/crates/bashkit/src/interpreter/redirection.rs @@ -56,6 +56,33 @@ fn redirect_target_may_split(redirect: &Redirect) -> bool { }) } +/// Stdin slot: the buffer plus the live-bytes lease when the buffer is a +/// budgeted clone minted here. Assigning the slot drops any previous +/// buffer and its clone lease together. +type StdinSlot = Option<( + crate::StreamData, + Option, +)>; + +/// Assign the stdin slot, dropping any previous buffer and its clone lease +/// together. Fresh reads pass `lease: None`; budgeted clones pass the lease +/// from `charge_input_retention` so it lives as long as stdin does. +fn set_stdin_slot( + slot: &mut StdinSlot, + value: Option, + lease: Option, +) { + *slot = value.map(|data| (data, lease)); +} + +/// Result of input-redirection processing: the stdin buffer plus the +/// live-bytes lease for it when stdin is a budgeted clone minted here. +/// Callers hold `stdin_lease` for as long as they retain `stdin`. +pub(crate) struct StdinSetup { + pub stdin: Option, + pub stdin_lease: Option, +} + /// Numbered-fd table with replace semantics: re-redirecting an fd drops the /// superseded buffer — and its live-bytes lease — instead of piling clones. /// Aggregate input consumption stays cumulative. @@ -268,8 +295,11 @@ impl Interpreter { redirects: &[Redirect], demand: StdinDemand, high_fd_stdin: HighFdStdin, - ) -> Result> { - let mut stdin = existing_stdin; + ) -> crate::error::Result { + // Stdin slot: a budgeted clone's live lease travels with the buffer + // so replacing stdin drops both together; the surviving lease is + // handed to the caller with stdin. + let mut stdin: StdinSlot = existing_stdin.map(|data| (data, None)); // Readable fds this command's own redirects open (`4< = Vec::new(); @@ -306,7 +336,7 @@ impl Interpreter { let lease = self.charge_input_retention(content.retained_len())?; replace_own_fd(&mut own_fds, fd, content, lease); } - None => stdin = Some(content), + None => set_stdin_slot(&mut stdin, Some(content), None), } } RedirectKind::Input => { @@ -324,7 +354,7 @@ impl Interpreter { if self.fd_alias(&path) == Some(0) { // `< /dev/stdin` re-reads the current stdin: no-op. } else if is_dev_null(&path) { - stdin = Some(crate::StreamData::new()); // EOF + set_stdin_slot(&mut stdin, Some(crate::StreamData::new()), None); // EOF } else if !self.shell_features.has_file_redirects() { return Err(crate::error::Error::Execution( self.diag(format!("{}: filesystem redirection disabled", target_path)), @@ -350,7 +380,7 @@ impl Interpreter { (Some(_), HighFdStdin::Never) => false, }; if feeds { - stdin = Some(content); + set_stdin_slot(&mut stdin, Some(content), None); } } Err(e) => { @@ -370,7 +400,7 @@ impl Interpreter { let lease = self.charge_input_retention(content.retained_len())?; replace_own_fd(&mut own_fds, fd, content, lease); } - None => stdin = Some(content), + None => set_stdin_slot(&mut stdin, Some(content), None), } } RedirectKind::HereDoc | RedirectKind::HereDocStrip => { @@ -383,7 +413,7 @@ impl Interpreter { let lease = self.charge_input_retention(content.retained_len())?; replace_own_fd(&mut own_fds, fd, content, lease); } - None => stdin = Some(content), + None => set_stdin_slot(&mut stdin, Some(content), None), } } RedirectKind::DupInput => { @@ -411,7 +441,7 @@ impl Interpreter { let content = content.clone(); match high_fd { Some(fd) => replace_own_fd(&mut own_fds, fd, content, lease), - None => stdin = Some(content), + None => set_stdin_slot(&mut stdin, Some(content), Some(lease)), } continue; } @@ -437,11 +467,15 @@ impl Interpreter { text.push('\n'); } } - stdin = Some(text.into()); + set_stdin_slot(&mut stdin, Some(text.into()), None); } Some(coproc::InputFd::Pipe(end)) => { let pipe = Arc::clone(&end.0); - stdin = Some(Box::pin(coproc::read_coproc_input(pipe, demand)).await); + set_stdin_slot( + &mut stdin, + Some(Box::pin(coproc::read_coproc_input(pipe, demand)).await), + None, + ); } // `<&5` with fd 5 never opened (or closed): bash // reports it and does not run the command. @@ -462,7 +496,12 @@ impl Interpreter { } } - Ok(stdin) + let mut stdin_lease = None; + let stdin = stdin.map(|(data, lease)| { + stdin_lease = lease; + data + }); + Ok(StdinSetup { stdin, stdin_lease }) } /// Apply output redirections to command output diff --git a/crates/bashkit/tests/integration/input_redirect_budget_tests.rs b/crates/bashkit/tests/integration/input_redirect_budget_tests.rs index 2391e7842..1c1abd555 100644 --- a/crates/bashkit/tests/integration/input_redirect_budget_tests.rs +++ b/crates/bashkit/tests/integration/input_redirect_budget_tests.rs @@ -124,7 +124,9 @@ async fn both_backing_buffers_admitted() { #[tokio::test] /// Replacing a superseded same-fd entry releases its live lease: eight -/// same-fd repeats hold only one 64B lease under a 64B live cap. +/// same-fd repeats never hold more than two 64B buffers transiently (one +/// retained plus one incoming during replacement), so a 128B live cap +/// suffices where append-only leases would grow without bound. async fn superseded_entry_releases_live_lease() { let mut bash = Bash::builder() .limits(ExecutionLimits::new().max_live_intermediate_bytes(128_u64)) @@ -165,32 +167,71 @@ async fn cumulative_aggregate_survives_replace() { } #[tokio::test] -/// Every reachable cloning/retaining numbered-input branch charges: DupInput -/// clone and ReadWrite each trip a 31B aggregate cap (16B payload retains -/// 32B). (`3<<<` drops its fd in the parser and always feeds stdin, and -/// numbered heredoc bodies are parse-metered with no per-exec retention, so -/// those arms keep their charges as defense-in-depth.) -async fn all_numbered_input_branches_charge() { - let cases = [": 3/big"]; - for script in cases { - let mut bash = Bash::builder() - .limits(ExecutionLimits::new().max_aggregate_input_bytes(31)) - .build(); - bash.fs() - .write_file(Path::new("/big"), b"sixteen-bytesxx") - .await - .unwrap(); - let result = bash.exec(script).await; - assert!( - matches!( - result, - Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget( - ExecutionBudgetExceeded::InputBytes { .. } - ))) - ), - "script {script:?} should trip the input budget, got {result:?}" - ); - } +/// The DupInput target duplication itself is charged: with a 16B payload +/// (32B retained), a 47B aggregate cap admits the 15B script plus the source +/// fd (47B total) but the `4<&3` clone (79B) trips `InputBytes`. Controls +/// prove the script text and source fd fit on their own. +async fn dup_target_duplication_charges() { + // Control 1: source fd alone fits (7B script + 32B retained = 39B). + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_aggregate_input_bytes(39)) + .build(); + bash.fs() + .write_file(Path::new("/big"), b"sixteen-bytesxx") + .await + .unwrap(); + assert!(bash.exec(": 3/big").await; + assert!( + matches!( + result, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget( + ExecutionBudgetExceeded::InputBytes { .. } + ))) + ), + "ReadWrite retention should trip InputBytes, got {result:?}" + ); } #[tokio::test] @@ -242,3 +283,152 @@ async fn large_numbered_heredoc_stays_bounded() { let result = bash.exec(&script).await.unwrap(); assert_eq!(result.exit_code, 0); } + +#[tokio::test] +/// Stdin-clone live overlap: `cat 3 deadline { + child.kill().expect("kill overrun child"); + let _ = child.wait(); + use std::os::unix::process::ExitStatusExt; + return ( + std::process::ExitStatus::from_raw(9), + std::process::Output { + status: std::process::ExitStatus::from_raw(9), + stdout: Vec::new(), + stderr: b"wall-clock overrun".to_vec(), + }, + start.elapsed(), + true, + ); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + }) + .await + .expect("child harness join"); + let (status, output, elapsed, overran) = outcome; + assert!( + !overran, + "bounded child overran the wall clock; killed and reaped" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + status.code(), + Some(1), + "producer must fail cleanly (signal death = OOM, 0 = unbounded success): {status:?}" + ); + assert!( + stderr.contains("aggregate input bytes"), + "denial must name the aggregate input budget phase, got: {stderr:?}" + ); + assert!(output.stdout.len() < 4096, "producer output capped"); + assert!( + elapsed < std::time::Duration::from_secs(60), + "producer took {elapsed:?}" + ); +} From 6f46a4a4b3fc4482e0272d1a12231d6db7e8dd82 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 12 Oct 2026 01:05:59 -0500 Subject: [PATCH 4/4] fix(interpreter): isolate coverage artifacts from bounded-child file cap Under cargo-llvm-cov the spawned CLI inherits LLVM_PROFILE_FILE and flushes a multi-MiB profraw at exit, tripping the child's 1MiB -f cap (SIGXFSZ, wait status 153). Point the child's profile at a controlled temp sink (coverage stays enabled), raise -f to an explicit 128MiB artifact allowance with a size receipt plus cleanup, and stop equating signal death with OOM in assertions. --- .../input_redirect_budget_tests.rs | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/bashkit/tests/integration/input_redirect_budget_tests.rs b/crates/bashkit/tests/integration/input_redirect_budget_tests.rs index 1c1abd555..15f7a3182 100644 --- a/crates/bashkit/tests/integration/input_redirect_budget_tests.rs +++ b/crates/bashkit/tests/integration/input_redirect_budget_tests.rs @@ -360,6 +360,15 @@ async fn stdin_clone_trip_recovers() { #[tokio::test] async fn original_shape_bounded_child_default_limits() { let script = format!(":{}", " 3 deadline { child.kill().expect("kill overrun child"); let _ = child.wait(); - use std::os::unix::process::ExitStatusExt; - return ( - std::process::ExitStatus::from_raw(9), + break ( + { + use std::os::unix::process::ExitStatusExt; + std::process::ExitStatus::from_raw(9) + }, std::process::Output { - status: std::process::ExitStatus::from_raw(9), + status: { + use std::os::unix::process::ExitStatusExt; + std::process::ExitStatus::from_raw(9) + }, stdout: Vec::new(), stderr: b"wall-clock overrun".to_vec(), }, @@ -407,7 +431,19 @@ async fn original_shape_bounded_child_default_limits() { ); } std::thread::sleep(std::time::Duration::from_millis(50)); + }; + // Artifact receipt (when coverage writes one) plus finite cleanup. + const ARTIFACT_BOUND: u64 = 128 * 1024 * 1024; + if let Ok(meta) = std::fs::metadata(&profile) { + assert!( + meta.len() < ARTIFACT_BOUND, + "coverage artifact receipt: {} bytes exceeds {}", + meta.len(), + ARTIFACT_BOUND + ); } + let _ = std::fs::remove_dir_all(sink_dir); + (status, output, elapsed, overran) }) .await .expect("child harness join"); @@ -420,7 +456,9 @@ async fn original_shape_bounded_child_default_limits() { assert_eq!( status.code(), Some(1), - "producer must fail cleanly (signal death = OOM, 0 = unbounded success): {status:?}" + // Signal death here means OOM kill, an artifact-cap trip (SIGXFSZ), + // or a crash, never success; producer diagnostics included. + "producer must fail cleanly, got {status:?} with stderr {stderr:?}", ); assert!( stderr.contains("aggregate input bytes"),