diff --git a/crates/bashkit/src/builtins/fmt/linebreak.rs b/crates/bashkit/src/builtins/fmt/linebreak.rs index 5a603c68f..ccd4eaf79 100644 --- a/crates/bashkit/src/builtins/fmt/linebreak.rs +++ b/crates/bashkit/src/builtins/fmt/linebreak.rs @@ -1,15 +1,16 @@ // Vendored from uutils/coreutils (MIT, Copyright (c) uutils developers), // src/uu/fmt/src/linebreak.rs at rev 6579cc2. Changes: output goes to a -// `Vec`, input is any `BufRead`, options come from `super::FmtOptions`. +// budgeted sink, search steps poll the shared execution budget, and options +// come from `super::FmtOptions`. // Keep in sync with upstream; see knowledge/foundations/builtins.md "fmt". // spell-checker:ignore (ToDO) INFTY MULT accum breakwords linebreak linebreaking linebreaks linelen maxlength minlength nchars ostream overlen parasplit plass posn powf punct signum slen sstart tabwidth tlen underlen winfo wlen wordlen -use std::io::Write; use std::mem; -use super::FmtOptions; use super::parasplit::{ParaWords, Paragraph, WordInfo}; +use super::{FmtOptions, FmtOutput}; +use crate::error::Result; struct BreakArgs<'a> { opts: &'a FmtOptions, @@ -17,7 +18,7 @@ struct BreakArgs<'a> { indent: &'a [u8], indent_len: usize, uniform: bool, - ostream: &'a mut Vec, + ostream: &'a mut FmtOutput, } impl BreakArgs<'_> { @@ -36,11 +37,7 @@ impl BreakArgs<'_> { } } -pub fn break_lines( - para: &Paragraph, - opts: &FmtOptions, - ostream: &mut Vec, -) -> std::io::Result<()> { +pub fn break_lines(para: &Paragraph, opts: &FmtOptions, ostream: &mut FmtOutput) -> Result<()> { // indent let p_indent = ¶.indent_str; let p_indent_len = para.indent_len; @@ -97,7 +94,7 @@ pub fn break_lines( fn break_simple<'a, T: Iterator>>( mut iter: T, args: &mut BreakArgs<'a>, -) -> std::io::Result<()> { +) -> Result<()> { iter.try_fold((args.init_len, false), |(l, prev_punct), winfo| { accum_words_simple(args, l, prev_punct, winfo) })?; @@ -109,7 +106,7 @@ fn accum_words_simple<'a>( l: usize, prev_punct: bool, winfo: &'a WordInfo<'a>, -) -> std::io::Result<(usize, bool)> { +) -> Result<(usize, bool)> { // compute the length of this word, considering how tabs will expand at this position on the line let wlen = winfo.word_nchars + args.compute_width(winfo, l, false); @@ -137,12 +134,12 @@ fn accum_words_simple<'a>( fn break_knuth_plass<'a, T: Clone + Iterator>>( mut iter: T, args: &mut BreakArgs<'a>, -) -> std::io::Result<()> { +) -> Result<()> { // run the algorithm to get the breakpoints - let breakpoints = find_kp_breakpoints(iter.clone(), args); + let breakpoints = find_kp_breakpoints(iter.clone(), args)?; // iterate through the breakpoints (note that breakpoints is in reverse break order, so we .rev() it - let result: std::io::Result<(bool, bool)> = breakpoints.iter().rev().try_fold( + let result: Result<(bool, bool)> = breakpoints.iter().rev().try_fold( (false, false), |(mut prev_punct, mut fresh), &(next_break, break_before)| { if fresh { @@ -218,7 +215,7 @@ struct LineBreak<'a> { fn find_kp_breakpoints<'a, T: Clone + Iterator>>( iter: T, args: &BreakArgs<'a>, -) -> Vec<(&'a WordInfo<'a>, bool)> { +) -> Result, bool)>> { let mut iter = iter.peekable(); // set up the initial null linebreak let mut linebreaks = vec![LineBreak { @@ -244,6 +241,7 @@ fn find_kp_breakpoints<'a, T: Clone + Iterator>>( let mut new_linebreaks = vec![]; let mut is_sentence_start = false; while let Some(w) = iter.next() { + args.ostream.consume_work(1)?; let next_word_sentence_final = is_next_word_sentence_final(iter.clone()); // if this is the last word, we don't add additional demerits for this break let (is_last_word, is_sentence_end) = match iter.peek() { @@ -268,6 +266,7 @@ fn find_kp_breakpoints<'a, T: Clone + Iterator>>( // break if we are above the minimum required length #[allow(clippy::explicit_iter_loop)] for &i in active_breaks.iter() { + args.ostream.consume_work(1)?; let active = &mut linebreaks[i]; if active.demerits < best_active_demerits { best_active_demerits = active.demerits; @@ -381,7 +380,7 @@ fn find_kp_breakpoints<'a, T: Clone + Iterator>>( } // return the best path - build_best_path(&linebreaks, &active_breaks) + Ok(build_best_path(&linebreaks, &active_breaks)) } fn build_best_path<'a>(paths: &[LineBreak<'a>], active: &[usize]) -> Vec<(&'a WordInfo<'a>, bool)> { @@ -531,13 +530,13 @@ fn slice_if_fresh( } /// Write a newline and add the indent. -fn write_newline(indent: &[u8], ostream: &mut Vec) -> std::io::Result<()> { +fn write_newline(indent: &[u8], ostream: &mut FmtOutput) -> Result<()> { ostream.write_all(b"\n")?; ostream.write_all(indent) } /// Write the word, along with slen spaces. -fn write_with_spaces(word: &[u8], slen: usize, ostream: &mut Vec) -> std::io::Result<()> { +fn write_with_spaces(word: &[u8], slen: usize, ostream: &mut FmtOutput) -> Result<()> { if slen == 2 { ostream.write_all(b" ")?; } else if slen == 1 { diff --git a/crates/bashkit/src/builtins/fmt/mod.rs b/crates/bashkit/src/builtins/fmt/mod.rs index 489533369..cda213592 100644 --- a/crates/bashkit/src/builtins/fmt/mod.rs +++ b/crates/bashkit/src/builtins/fmt/mod.rs @@ -4,9 +4,11 @@ //! from uutils/coreutils `fmt` (MIT, `linebreak.rs`, `parasplit.rs`), a //! Knuth-Plass optimal-fit filler, not a greedy one. Bashkit is MIT, so GNU //! `fmt.c` (GPL) must not be ported; uutils already targets GNU-compatible -//! output. Only option parsing and I/O are bashkit's. Width and goal follow -//! GNU's documented rules: goal defaults to 93% of the width, `-g` alone +//! output. Option parsing, I/O and budget enforcement are bashkit's. Width and +//! goal follow GNU's documented rules: goal defaults to 93% of the width, `-g` alone //! sets the width to goal + 10, width is capped at 2500. +//! Output capacity is leased before allocation; synchronous rendering and +//! optimal-fit searches charge the shared work/cancellation/deadline budget. mod linebreak; mod parasplit; @@ -16,6 +18,7 @@ use async_trait::async_trait; use super::{Builtin, BuiltinHelper, Context, resolve_path}; use crate::error::Result; use crate::interpreter::ExecResult; +use crate::limits::{BudgetedBytes, ExecutionBudget}; /// The fmt builtin. /// @@ -226,21 +229,49 @@ fn parse_tab_width(value: &str) -> std::result::Result { .map_err(|_| err(format!("invalid tab width: '{value}'"))) } +// THREAT[TM-DOS-103]: indentation and prefixes can multiply output size. +// Keep the capacity lease with the sink, and check work before each copy. +struct FmtOutput { + bytes: BudgetedBytes, + budget: Option, +} + +impl FmtOutput { + fn new(budget: Option) -> Result { + Ok(Self { + bytes: BudgetedBytes::new(budget.as_ref())?, + budget, + }) + } + + fn consume_work(&self, units: u64) -> Result<()> { + if let Some(budget) = &self.budget { + budget.consume_work(units)?; + } + Ok(()) + } + + fn write_all(&mut self, bytes: &[u8]) -> Result<()> { + self.consume_work(1 + bytes.len() as u64)?; + self.bytes.try_extend_from_slice(bytes)?; + Ok(()) + } +} + /// Format one input into `out` with the vendored engine. -fn format_input(opts: &FmtOptions, input: Vec, out: &mut Vec) { +fn format_input(opts: &FmtOptions, input: Vec, out: &mut FmtOutput) -> Result<()> { let mut reader: FileOrStdReader = std::io::Cursor::new(input); for para in parasplit::ParagraphStream::new(opts, &mut reader) { + out.consume_work(1)?; match para { Err(line) => { - out.extend_from_slice(&line); - out.push(b'\n'); - } - // Writes to a Vec never fail. - Ok(para) => { - let _ = linebreak::break_lines(¶, opts, out); + out.write_all(&line)?; + out.write_all(b"\n")?; } + Ok(para) => linebreak::break_lines(¶, opts, out)?, } } + Ok(()) } #[async_trait] @@ -253,7 +284,15 @@ impl Builtin for Fmt { Ok(v) => v, Err(e) => return Ok(e), }; - let mut out = Vec::new(); + let budget = ctx + .execution_budget() + .map(|budget| { + budget + .try_with(Clone::clone) + .map_err(|_| crate::Error::Cancelled) + }) + .transpose()?; + let mut out = FmtOutput::new(budget)?; let mut stderr = String::new(); let mut exit_code = 0; for file in files { @@ -274,9 +313,10 @@ impl Builtin for Fmt { } }; ctx.consume_budget_work(1 + (input.len() / 1024) as u64)?; - format_input(&opts, input, &mut out); + format_input(&opts, input, &mut out)?; } - let mut result = ExecResult::with_code(crate::StreamData::from(out), exit_code); + let (bytes, _output_lease) = out.bytes.into_parts(); + let mut result = ExecResult::with_code(crate::StreamData::from(bytes), exit_code); result.stderr = stderr.into(); Ok(result) } @@ -291,9 +331,61 @@ mod tests { let Ok((opts, _)) = parse_args(&args) else { panic!("valid args"); }; - let mut out = Vec::new(); - format_input(&opts, input.as_bytes().to_vec(), &mut out); - String::from_utf8(out).unwrap() + let mut out = FmtOutput::new(None).unwrap(); + format_input(&opts, input.as_bytes().to_vec(), &mut out).unwrap(); + String::from_utf8(out.bytes.into_parts().0).unwrap() + } + + #[test] + fn output_capacity_boundary_is_checked_before_copy() { + use crate::{ExecutionBudgetExceeded, ExecutionLimits, LimitExceeded}; + let token = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let budget = ExecutionBudget::new( + &ExecutionLimits::new().max_live_intermediate_bytes(8), + token, + ); + let mut out = FmtOutput::new(Some(budget)).unwrap(); + out.write_all(b"12345678").unwrap(); + assert!(matches!( + out.write_all(b"9"), + Err(crate::Error::ResourceLimit(LimitExceeded::ExecutionBudget( + ExecutionBudgetExceeded::LiveBytes { used: 16, limit: 8 } + ))) + )); + assert_eq!(&*out.bytes, b"12345678"); + } + + #[test] + fn synchronous_sink_observes_cancellation_and_deadline() { + use crate::{ExecutionLimits, LimitExceeded}; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + for cancel in [true, false] { + let token = Arc::new(AtomicBool::new(false)); + let budget = ExecutionBudget::new( + &ExecutionLimits::new().timeout(std::time::Duration::from_millis(1)), + token.clone(), + ); + let mut out = FmtOutput::new(Some(budget)).unwrap(); + out.write_all(b"a").unwrap(); + if cancel { + token.store(true, Ordering::Relaxed); + } else { + std::thread::sleep(std::time::Duration::from_millis(120)); + } + let error = out.write_all(b"b").unwrap_err(); + if cancel { + assert!(matches!(error, crate::Error::Cancelled)); + } else { + assert!(matches!( + error, + crate::Error::ResourceLimit(LimitExceeded::Timeout(_)) + )); + } + assert_eq!(&*out.bytes, b"a"); + } } #[test] diff --git a/crates/bashkit/tests/integration/fmt_resource_tests.rs b/crates/bashkit/tests/integration/fmt_resource_tests.rs new file mode 100644 index 000000000..01b226553 --- /dev/null +++ b/crates/bashkit/tests/integration/fmt_resource_tests.rs @@ -0,0 +1,229 @@ +//! fmt's repeated indentation/prefix must be admitted before output allocation. +use bashkit::{Bash, Error, ExecOptions, ExecutionLimits, LimitExceeded}; +use std::path::Path; + +fn amplified_input() -> Vec { + format!("{}{}\n", " ".repeat(256), "a ".repeat(128)).into_bytes() +} + +#[tokio::test] +async fn fmt_indentation_respects_live_budget_and_session_recovers() { + for mode in ["-q", "", "-c", "-t", "-s"] { + for from_file in [false, true] { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_live_intermediate_bytes(8192)) + .build(); + let input = amplified_input(); + bash.fs() + .write_file(Path::new("/input"), &input) + .await + .unwrap(); + let result = if from_file { + bash.exec(&format!("fmt {mode} /input")).await + } else { + bash.exec_with_options(&format!("fmt {mode}"), ExecOptions::new().stdin(input)) + .await + }; + assert!( + matches!( + result, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_))) + ), + "mode={mode} file={from_file}: {result:?}" + ); + let result = bash.exec("printf 'a b\\n' | fmt").await.unwrap(); + assert_eq!(result.stdout, "a b\n"); + assert_eq!(result.exit_code, 0); + } + } +} + +#[tokio::test] +async fn fmt_repeated_prefix_and_multiple_files_share_output_budget() { + for mode in ["-q", ""] { + let prefix = "#".repeat(256); + let input = format!("{prefix}{}\n", "a ".repeat(128)); + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_live_intermediate_bytes(8192)) + .build(); + let result = bash + .exec_with_options( + &format!("fmt {mode} -p '{prefix}'"), + ExecOptions::new().stdin(input), + ) + .await; + assert!(matches!( + result, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_))) + )); + } + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_live_intermediate_bytes(8192)) + .build(); + let input = format!("{}a\n", " ".repeat(2048)); + bash.fs() + .write_file(Path::new("/input"), input.as_bytes()) + .await + .unwrap(); + assert_eq!( + bash.exec("fmt /input").await.unwrap().stdout.as_bytes(), + input.as_bytes() + ); + assert!(matches!( + bash.exec("fmt /input /input /input").await, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_))) + )); +} + +#[tokio::test] +async fn fmt_generated_bytes_consume_work_before_capture() { + let mut bash = Bash::builder() + .limits( + ExecutionLimits::new() + .max_work_units(4096) + .max_stdout_bytes(16), + ) + .build(); + assert_eq!( + bash.exec("printf 'a b\\n' | fmt").await.unwrap().stdout, + "a b\n" + ); + for mode in ["-q", ""] { + let result = bash + .exec_with_options( + &format!("fmt {mode}"), + ExecOptions::new().stdin(amplified_input()), + ) + .await; + assert!(matches!( + result, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_))) + )); + assert!(result.unwrap_err().to_string().contains("work units")); + } + assert_eq!( + bash.exec("printf 'recovered\\n' | fmt") + .await + .unwrap() + .stdout, + "recovered\n" + ); +} + +#[tokio::test] +async fn fmt_passthrough_lines_use_the_same_budget() { + let mut bash = Bash::builder() + .limits(ExecutionLimits::new().max_live_intermediate_bytes(8192)) + .build(); + // Prefix mismatch bypasses line breaking, but must still use the sink. + let result = bash + .exec_with_options( + "fmt -p '#'", + ExecOptions::new().stdin(format!("{}\n", "x".repeat(8192))), + ) + .await; + assert!(matches!( + result, + Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_))) + )); + let good = bash + .exec_with_options("fmt -p '#'", ExecOptions::new().stdin("raw\n\n# a b\n")) + .await + .unwrap(); + assert_eq!(good.stdout, "raw\n\n# a b\n"); +} + +#[tokio::test] +async fn fmt_below_budget_preserves_indent_through_pipe_and_redirect() { + let mut bash = Bash::builder() + .limits( + ExecutionLimits::new() + .max_stdout_bytes(16) + .max_live_intermediate_bytes(256_000), + ) + .build(); + let input = amplified_input(); + bash.fs() + .write_file(Path::new("/input"), &input) + .await + .unwrap(); + let expected = format!("{}a\n", " ".repeat(256)).repeat(128); + for mode in ["-q", ""] { + let result = bash + .exec(&format!("fmt {mode} /input | wc -c")) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout.trim(), expected.len().to_string()); + let result = bash + .exec(&format!("fmt {mode} /input > /output")) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!( + bash.fs().read_file(Path::new("/output")).await.unwrap(), + expected.as_bytes() + ); + } +} + +#[tokio::test] +async fn fmt_missing_file_reports_error_and_formats_remaining_file() { + let mut bash = Bash::new(); + bash.fs() + .write_file(Path::new("/input"), b"a b\n") + .await + .unwrap(); + let result = bash.exec("fmt /missing /input").await.unwrap(); + assert_eq!(result.exit_code, 1); + assert_eq!(result.stdout, "a b\n"); + assert_eq!( + result.stderr, + "fmt: cannot open '/missing' for reading: No such file or directory\n" + ); + bashkit::testing::assert_no_leak(&result, "fmt missing file", &[]); +} + +#[tokio::test] +async fn fmt_finite_shell_input_matches_real_bash() { + use std::process::Command; + // One short line per paragraph: GNU/BSD fmt and the vendored optimal-fit + // engine agree here; multi-line break choices are covered by L-FMT-001. + let script = "printf ' alpha beta\\n\\n gamma delta\\n' | fmt -w 40"; + let expected = Command::new("bash") + .args(["--noprofile", "--norc", "-c", script]) + .env("LC_ALL", "C") + .output() + .unwrap(); + assert!(expected.status.success()); + let mut bash = Bash::new(); + let actual = bash.exec(script).await.unwrap(); + assert_eq!(actual.stdout.as_bytes(), expected.stdout); + assert_eq!(actual.stderr.as_bytes(), expected.stderr); + assert_eq!(actual.exit_code, expected.status.code().unwrap()); +} + +#[tokio::test] +async fn fmt_api_cancellation_leaves_session_reusable() { + use std::sync::atomic::Ordering; + use std::time::Duration; + let mut bash = Bash::new(); + let token = bash.cancellation_token(); + let cancel = token.clone(); + // Even without cancellation this finite payload emits only ~8 MiB. + let input = format!("{}{}\n", " ".repeat(256), "a ".repeat(32_768)); + let worker = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1)); + cancel.store(true, Ordering::Relaxed); + }); + let result = bash + .exec_with_options("fmt -q", ExecOptions::new().stdin(input)) + .await; + worker.join().unwrap(); + assert!(result.unwrap_err().to_string().contains("cancelled")); + token.store(false, Ordering::Relaxed); + assert_eq!( + bash.exec("printf 'a b\\n' | fmt").await.unwrap().stdout, + "a b\n" + ); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index db84adb10..8b5d3752a 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -77,6 +77,7 @@ pub mod fdvar_array_tests; pub mod filesystem_security_conformance_tests; pub mod final_env_tests; pub mod find_multi_path_tests; +pub mod fmt_resource_tests; pub mod for_in_reserved_word_tests; pub mod git_advanced_tests; pub mod git_inspection_tests; diff --git a/docs/security.md b/docs/security.md index bce0d7ec0..add44bef5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -33,6 +33,14 @@ can reuse the shell with a fresh budget. Capture limits remain downstream so command-local redirected diagnostics keep their full content when they fit the memory budget. An outer redirect on a child-shell invocation receives that child's already capped capture. + +`fmt` reserves output capacity against `max_live_intermediate_bytes` before +allocation, including repeated indentation and prefixes. Generated bytes and +optimal-fit searches consume the shared work budget and check cancellation and +deadlines. Exceeding a budget returns a resource-limit error. Output within the +budget preserves its formatting through pipelines and file redirects, even +when it exceeds the stdout capture limit. + `cd` searches `CDPATH` one candidate at a time. Temporary path and normalization storage is reserved against `max_live_intermediate_bytes` before allocation. Searches also consume the shared work budget and check diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index 909f869f5..d866e5956 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -618,14 +618,23 @@ regex caps are TM-DOS-023/025 in the [Threat Model](../security/threat-model.md) `fmt` vendors the uutils/coreutils `fmt` engine (MIT): `builtins/fmt/ linebreak.rs` (Knuth-Plass optimal fit) and `parasplit.rs` (paragraphs, prefixes, crown/tagged indents), pinned to the same uutils revision as the -generated argument files. Bashkit owns only `fmt/mod.rs`: option parsing and -I/O. Options are GNU's (`-w`, `-g`, `-c`, `-t`, `-s`, `-u`, `-p`, obsolete +generated argument files. Bashkit owns option parsing, I/O and budget +enforcement. Options are GNU's (`-w`, `-g`, `-c`, `-t`, `-s`, `-u`, `-p`, obsolete `-WIDTH`) plus the uutils extras (`-m`, `-P`, `-x`, `-X`, `-q`, `-T`). Width rules and error messages follow GNU: goal is 93% of the width, `-g` alone sets the width to goal + 10, width is capped at 2500, goal at the width. Vendored `panic!`/`unwrap` paths were turned into early returns. GNU `fmt.c` is GPL and must not be ported; line-break differences are L-FMT-001. +All formatter output, including unformatted lines, retains a `BudgetedBytes` +capacity lease against the shared live-intermediate budget before fallible +allocation. Repeated indentation/prefix bytes consume work before copying; +optimal-fit word/active-path searches also poll the shared work, cancellation +and deadline budget. Sink/search errors propagate to the Bash API as resource +failures, never successful partial output. The stdout capture ceiling does not +limit a formatter sink used by a pipeline or VFS redirect. Regression coverage: +`fmt_resource_tests` and the formatter's capacity/cancellation/deadline unit tests. + ### Network Builtins `curl`, `wget`, `http` require the `http_client` feature + URL allowlist. diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index c3fb3420f..d9bcad4b4 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -1421,7 +1421,7 @@ This section maps former vulnerability IDs to the new threat ID scheme and track | TM-DOS-100 | jq control-character normalization amplification | A jq JSON string consisting of literal controls expands sixfold when each byte becomes `\u00XX`; an unmetered compatibility copy can exhaust memory or CPU before strict parsing | The jq-only normalizer charges input-length work before its single pass, borrows unchanged input, and acquires/grows a shared live-intermediate lease before every allocation growth (`builtins/jq/input.rs`), **MITIGATED** | | TM-DOS-101 | yq structured-data amplification | YAML aliases, YAML/JSON nesting, multi-document floods, filters, and output format expansion are all attacker-controlled | A pre-deserialization lexical pass rejects aliases without misclassifying quoted or block scalar content; real parsers with recursion/depth caps, aggregate budgets, shared jaq work/deadline/output controls, and a final rendered-output cap; integration tests cover alias bombs plus YAML+JSON depth/document/input/output bounds, proptest composes arbitrary YAML+filters, and `yq_fuzz` covers stdin/in-place format paths, **MITIGATED** | | TM-DOS-102 | Archive decoder allocation-before-check | A small gzip/bzip2 stream makes the decoder output buffer allocate beyond live-memory or filesystem quotas before the post-growth size check runs | Decoder chunks validate absolute/ratio bounds and acquire a shared execution-budget lease before `try_reserve_exact` and copy; corrupt, truncated, CRC-invalid, ratio-bomb, and live-budget tests fail closed, **MITIGATED** | -| TM-DOS-103 | Post-allocation resource charging | Archive/compression and other growing buffers could call `reserve`/`extend` before taking a live-byte lease; concurrent `fetch_add` accounting could also wrap or transiently overcommit the ceiling | `BudgetedVec`/`BudgetedBytes` and `BudgetedString` own their `ExecutionBudgetLease`, charge planned capacity before `try_reserve_exact`, roll back on allocation/error, and release on drop. CAS admission rejects overflow and concurrent overcommit. Archive creation, extraction/list output, gzip/bzip2 encoders, decompression, and `od` output use the builders. Tar `-T`/`--files-from` leases raw list reads before VFS loading, retains budget-owned names/vector capacity, decodes lossy UTF-8 directly into admitted storage, and charges bytes/lines/decoding work; bounded allocation regressions include nonexistent names, repeated lists, stdin, and session recovery. Shell diagnostic names are bounded to 1,024 UTF-8 bytes before copying; prefix rewriting and its cached lossy text use these builders before routing, retain both leases through routing/hooks, and charge work/check cancellation and deadlines during scanning and emission. `diagnostic_prefix_tests` covers small-budget amplification across capture, streaming and redirects, plus recovery. `od` rejects widths above 65,536 bytes before layout arithmetic (including empty input), pads only a single <= 8-byte field on the stack, uses u64 padding products for 32-bit safety, and charges input/field rendering work; `od_resource_tests` cover huge-width rejection, boundary formatting, live/work budgets, and subsequent execution, **MITIGATED** | +| TM-DOS-103 | Post-allocation resource charging | Archive/compression and other growing buffers could call `reserve`/`extend` before taking a live-byte lease; concurrent `fetch_add` accounting could also wrap or transiently overcommit the ceiling | `BudgetedVec`/`BudgetedBytes` and `BudgetedString` own their `ExecutionBudgetLease`, charge planned capacity before `try_reserve_exact`, roll back on allocation/error, and release on drop. CAS admission rejects overflow and concurrent overcommit. Archive creation, extraction/list output, gzip/bzip2 encoders, decompression, `od`, and every `fmt` output path use the builders. `fmt` charges generated indentation/prefix bytes before copying and optimal-fit search steps against the shared work/cancellation/deadline budget; formatter errors propagate instead of being discarded (`fmt_resource_tests`). Tar `-T`/`--files-from` leases raw list reads before VFS loading, retains budget-owned names/vector capacity, decodes lossy UTF-8 directly into admitted storage, and charges bytes/lines/decoding work; bounded allocation regressions include nonexistent names, repeated lists, stdin, and session recovery. Shell diagnostic names are bounded to 1,024 UTF-8 bytes before copying; prefix rewriting and its cached lossy text use these builders before routing, retain both leases through routing/hooks, and charge work/check cancellation and deadlines during scanning and emission. `diagnostic_prefix_tests` covers small-budget amplification across capture, streaming and redirects, plus recovery. `od` rejects widths above 65,536 bytes before layout arithmetic (including empty input), pads only a single <= 8-byte field on the stack, uses u64 padding products for 32-bit safety, and charges input/field rendering work; `od_resource_tests` cover huge-width rejection, boundary formatting, live/work budgets, and subsequent execution, **MITIGATED** | | TM-DOS-104 | Pipeline stderr aggregation exceeds the configured output cap before final truncation | A pipeline with many stderr-producing stages can accumulate their combined diagnostics in memory | The pipeline aggregate stops appending at `max_stderr_bytes` and propagates its truncation signal to the final result, **MITIGATED** | | TM-DOS-106 | Zip creation allocation-before-check | `zip` recursively collected complete file contents and encoded its output before acquiring live-byte leases, allowing transient allocations above the configured intermediate-memory ceiling | Zip traversal collects paths only; each VFS file size is leased before `read_file`, leases remain live with the entries, and archive encoding uses `BudgetedBytes` to charge capacity before growth — **MITIGATED** | | TM-DOS-108 | CLI host-stdin read can stall before execution | A producer keeps the one-shot CLI's stdin pipe open while idle or trickles fewer than 10 MiB, so the pre-execution read never reaches EOF or the byte cap | Read stdin on a dedicated thread and bound the wait with the selected execution timeout; timeout exits with a diagnostic suggesting `--no-stdin`, while the existing byte cap remains enforced — **MITIGATED** |