diff --git a/crates/bashkit/src/interpreter/arithmetic.rs b/crates/bashkit/src/interpreter/arithmetic.rs index dc8838019..649d8129d 100644 --- a/crates/bashkit/src/interpreter/arithmetic.rs +++ b/crates/bashkit/src/interpreter/arithmetic.rs @@ -14,7 +14,9 @@ //! `arith_error` and the command boundary aborts the line. //! - Integers wrap at 64 bits; shift counts are masked to 0..63 (x86 / bash). //! - THREAT[TM-DOS-026]: recursion depth (`MAX_ARITHMETIC_DEPTH`) and fuel -//! (`MAX_ARITHMETIC_EXPANSION_FUEL`) bound nested and self-referential input. +//! (`MAX_ARITHMETIC_EXPANSION_FUEL`) are shared by textual dollar expansion, +//! subscript evaluation, and recursive variable evaluation. Check before +//! expansion; read-only subscript evaluators borrow the same budget. use super::*; @@ -211,14 +213,40 @@ struct LValue { type ArithResult = std::result::Result; -/// Expression evaluator state shared across recursive variable evaluation. -pub(super) struct ArithEval<'a> { +/// One budget for both textual expansion and recursive expression evaluation. +struct ArithmeticBudget { + depth: usize, + fuel: usize, +} + +impl ArithmeticBudget { + fn new() -> Self { + Self { + depth: 0, + fuel: Interpreter::MAX_ARITHMETIC_EXPANSION_FUEL, + } + } + + fn charge(&mut self, src: &str) -> ArithResult<()> { + if src.len() > Interpreter::MAX_ARITHMETIC_EXPANSION_BYTES { + return Err("expression too long".to_string()); + } + let cost = src.len().max(1); + self.fuel = self + .fuel + .checked_sub(cost) + .ok_or_else(|| "expression recursion level exceeded".to_string())?; + Ok(()) + } +} + +/// Writes are evaluator-local; read-only subscript evaluations share only the budget. +struct ArithEval<'a, 'b> { interp: &'a Interpreter, overlay: HashMap<(String, Option), String>, - pub(super) writes: Vec, + writes: Vec, noeval: u32, - depth: usize, - fuel: usize, + budget: &'b mut ArithmeticBudget, } struct ArithParser<'s> { @@ -258,39 +286,39 @@ impl ArithParser<'_> { } } -impl<'a> ArithEval<'a> { - pub(super) fn new(interp: &'a Interpreter) -> Self { +impl<'a, 'b> ArithEval<'a, 'b> { + fn new(interp: &'a Interpreter, budget: &'b mut ArithmeticBudget) -> Self { Self { interp, overlay: HashMap::new(), writes: Vec::new(), noeval: 0, - depth: 0, - fuel: Interpreter::MAX_ARITHMETIC_EXPANSION_FUEL, + budget, } } fn enter(&mut self) -> ArithResult<()> { - self.depth += 1; - if self.depth >= Interpreter::MAX_ARITHMETIC_DEPTH { + if self.budget.depth + 1 >= Interpreter::MAX_ARITHMETIC_DEPTH { return Err("expression recursion level exceeded".to_string()); } + self.budget.depth += 1; Ok(()) } fn leave(&mut self) { - self.depth -= 1; + self.budget.depth -= 1; } /// Evaluate a full expression (already `$`-expanded). Empty -> 0. - pub(super) fn eval_str(&mut self, src: &str) -> ArithResult { - if src.len() > Interpreter::MAX_ARITHMETIC_EXPANSION_BYTES { - return Err("expression too long".to_string()); - } - if self.fuel < src.len().max(1) { - return Err("expression recursion level exceeded".to_string()); - } - self.fuel -= src.len().max(1); + fn eval_str(&mut self, src: &str) -> ArithResult { + self.enter()?; + let result = self.eval_str_inner(src); + self.leave(); + result + } + + fn eval_str_inner(&mut self, src: &str) -> ArithResult { + self.budget.charge(src)?; if src.trim().is_empty() { return Ok(0); } @@ -299,16 +327,28 @@ impl<'a> ArithEval<'a> { format!("{msg} (error token is \"{}\")", diag_echo(rest)) })?; let mut p = ArithParser { src, toks, pos: 0 }; - self.enter()?; - let r = self.comma(&mut p); - self.leave(); - let v = r?; + let v = self.comma(&mut p)?; if !matches!(p.peek(), Tok::Eof) { return Err(p.err("syntax error in expression")); } Ok(v) } + /// Guard and charge the original text before expanding any recursive subscript. + fn eval_with_dollars(&mut self, src: &str) -> ArithResult { + if !src.contains('$') && !src.contains('"') { + return self.eval_str(src); + } + self.enter()?; + let result = (|| { + self.budget.charge(src)?; + let expanded = self.expand_arith_dollars(src)?; + self.eval_str(&expanded) + })(); + self.leave(); + result + } + fn comma(&mut self, p: &mut ArithParser) -> ArithResult { let mut v = self.assign(p)?; while p.is_op(",") { @@ -829,12 +869,20 @@ impl Interpreter { /// Evaluate `expr`, returning the value or an error message, plus the /// assignments made (in order, including those before an error). pub(super) fn arith_eval(&self, expr: &str) -> (ArithResult, Vec) { - let expanded = self.expand_arith_dollars(expr); - let mut ev = ArithEval::new(self); - let r = ev.eval_str(&expanded); + let mut budget = ArithmeticBudget::new(); + let mut ev = ArithEval::new(self, &mut budget); + let r = ev.eval_with_dollars(expr); (r, ev.writes) } + /// Conditional operands have already undergone shell expansion. + pub(super) fn arith_eval_unexpanded(&self, expr: &str) -> (ArithResult, Vec) { + let mut budget = ArithmeticBudget::new(); + let mut ev = ArithEval::new(self, &mut budget); + let result = ev.eval_str(expr); + (result, ev.writes) + } + /// Apply writes produced by [`arith_eval`](Self::arith_eval). pub(super) fn apply_arith_writes(&mut self, writes: Vec) { for (name, key, value) in writes { @@ -934,12 +982,75 @@ impl Interpreter { self.arith_error.lock().ok().and_then(|mut s| s.take()) } + /// Read-only parameter/redirect lookup uses the same guarded expansion path. + pub(super) fn expand_name_or_array_element(&self, name: &str) -> String { + let mut budget = ArithmeticBudget::new(); + let mut ev = ArithEval::new(self, &mut budget); + match ev.expand_name_or_array_element(name) { + Ok(value) => value, + Err(msg) => { + self.record_arith_error(msg); + String::new() + } + } + } +} + +impl ArithEval<'_, '_> { + fn expand_variable(&mut self, name: &str) -> ArithResult { + self.enter()?; + let result = { + let resolved = self.interp.resolve_nameref(name); + if parse_embedded_array_ref(resolved).is_some() { + self.expand_name_or_array_element(resolved) + } else { + Ok(self.interp.expand_variable(resolved)) + } + }; + self.leave(); + result + } + + fn expand_variable_or_literal(&mut self, sub: &str) -> ArithResult { + let trimmed = sub.trim(); + if !trimmed.contains(['"', '\'', '\\']) + && trimmed.matches('$').count() <= 1 + && let Some(name) = trimmed.strip_prefix('$') + { + return self.expand_variable(name.trim_start_matches('{').trim_end_matches('}')); + } + if !sub.contains(['$', '"', '\'', '\\']) { + return Ok(sub.to_string()); + } + Interpreter::expand_key_text_using(sub, |name, braced| { + if braced { + if let Some(array) = name + .strip_suffix("[@]") + .or_else(|| name.strip_suffix("[*]")) + { + let separator = if name.ends_with("[*]") { + self.interp.get_ifs_separator() + } else { + " ".to_string() + }; + return Ok(self + .interp + .array_values(self.interp.resolve_nameref(array)) + .join(&separator)); + } + self.expand_brace_expr_in_arithmetic(name) + } else { + self.expand_variable(name) + } + }) + } + /// Textual `$` expansion inside an arithmetic expression (`$x`, `${..}`, /// `$1`, `$#`, ...). Bare identifiers are left for the evaluator. /// Double quotes are dropped (`"$x"` is `$x`). - pub(super) fn expand_arith_dollars(&self, expr: &str) -> String { + fn expand_arith_dollars(&mut self, expr: &str) -> ArithResult { if !expr.contains('$') && !expr.contains('"') { - return expr.to_string(); + return Ok(expr.to_string()); } let expr = expr.replace('"', ""); let mut result = String::new(); @@ -970,10 +1081,11 @@ impl Interpreter { } // THREAT[TM-DOS-130]: nesting recurses; bash-like scripts need few levels. if inner.matches("$((").count() > 32 { - self.record_arith_error("expression recursion level exceeded".to_string()); - result.push('0'); + return Err("expression recursion level exceeded".to_string()); } else { - result.push_str(&self.evaluate_arithmetic(&inner).to_string()); + let value = + ArithEval::new(self.interp, self.budget).eval_with_dollars(&inner)?; + result.push_str(&value.to_string()); } } else if chars.peek() == Some(&'{') { chars.next(); @@ -990,12 +1102,12 @@ impl Interpreter { } brace_content.push(c); } - result.push_str(&self.expand_brace_expr_in_arithmetic(&brace_content)); + result.push_str(&self.expand_brace_expr_in_arithmetic(&brace_content)?); } else if let Some(&c) = chars.peek() && matches!(c, '#' | '?' | '$' | '!' | '@' | '*' | '-' | '0'..='9') { chars.next(); - result.push_str(&self.expand_variable(&c.to_string())); + result.push_str(&self.expand_variable(&c.to_string())?); } else { let mut name = String::new(); while let Some(&c) = chars.peek() { @@ -1009,42 +1121,42 @@ impl Interpreter { if name.is_empty() { result.push('$'); } else { - result.push_str(&self.expand_variable(&name)); + result.push_str(&self.expand_variable(&name)?); } } } - result + Ok(result) } /// Expand a `${...}` expression encountered inside arithmetic context. /// Handles: `${#arr[@]}`, `${#arr[*]}`, `${#var}`, `${arr[idx]}`, `${var}`. - pub(super) fn expand_brace_expr_in_arithmetic(&self, inner: &str) -> String { + fn expand_brace_expr_in_arithmetic(&mut self, inner: &str) -> ArithResult { // ${#arr[@]} or ${#arr[*]} — array length if let Some(rest) = inner.strip_prefix('#') { if let Some(bracket) = rest.find('[') { if !rest.ends_with(']') { - return "0".to_string(); + return Ok("0".to_string()); } let end = rest.len() - 1; if bracket + 1 > end { - return "0".to_string(); + return Ok("0".to_string()); } let arr_name = &rest[..bracket]; let idx = &rest[bracket + 1..end]; if idx == "@" || idx == "*" { - if let Some(arr) = self.scoped.arrays.get(arr_name) { - return arr.len().to_string(); + if let Some(arr) = self.interp.scoped.arrays.get(arr_name) { + return Ok(arr.len().to_string()); } - if let Some(arr) = self.scoped.assoc_arrays.get(arr_name) { - return arr.len().to_string(); + if let Some(arr) = self.interp.scoped.assoc_arrays.get(arr_name) { + return Ok(arr.len().to_string()); } - return "0".to_string(); + return Ok("0".to_string()); } - let val = self.expand_name_or_array_element(&rest[..=end]); - return self.shell_length(&val).to_string(); + let val = self.expand_name_or_array_element(&rest[..=end])?; + return Ok(self.interp.shell_length(&val).to_string()); } - let val = self.expand_variable(rest); - return self.shell_length(&val).to_string(); + let val = self.expand_variable(rest)?; + return Ok(self.interp.shell_length(&val).to_string()); } if let Some(bracket) = inner.find('[') @@ -1067,37 +1179,37 @@ impl Interpreter { /// Expand a parameter expansion with operators inside arithmetic context. /// Handles common cases like ${var%%-*}, ${var##prefix}, etc. - pub(super) fn expand_param_op_in_arithmetic(&self, inner: &str) -> String { + fn expand_param_op_in_arithmetic(&mut self, inner: &str) -> ArithResult { for (pos, ch) in inner.char_indices() { match ch { '%' => { let name = &inner[..pos]; - let value = self.expand_name_or_array_element(name); + let value = self.expand_name_or_array_element(name)?; if inner[pos..].starts_with("%%") { let pattern = &inner[pos + 2..]; - return self.remove_pattern(&value, pattern, false, true); + return Ok(self.interp.remove_pattern(&value, pattern, false, true)); } let pattern = &inner[pos + 1..]; - return self.remove_pattern(&value, pattern, false, false); + return Ok(self.interp.remove_pattern(&value, pattern, false, false)); } '#' if pos > 0 => { let name = &inner[..pos]; - let value = self.expand_name_or_array_element(name); + let value = self.expand_name_or_array_element(name)?; if inner[pos..].starts_with("##") { let pattern = &inner[pos + 2..]; - return self.remove_pattern(&value, pattern, true, true); + return Ok(self.interp.remove_pattern(&value, pattern, true, true)); } let pattern = &inner[pos + 1..]; - return self.remove_pattern(&value, pattern, true, false); + return Ok(self.interp.remove_pattern(&value, pattern, true, false)); } ':' if inner[pos..].starts_with(":-") => { let name = &inner[..pos]; let default = &inner[pos + 2..]; - let value = self.expand_name_or_array_element(name); + let value = self.expand_name_or_array_element(name)?; if value.is_empty() { - return default.to_string(); + return Ok(default.to_string()); } - return value; + return Ok(value); } _ => {} } @@ -1109,28 +1221,34 @@ impl Interpreter { /// Used by parameter expansion inside arithmetic so `${arr[$key]:-N}` and /// friends can read associative/indexed array elements — `expand_variable` /// alone only handles scalar names. Fixes issue #1776. - pub(super) fn expand_name_or_array_element(&self, name: &str) -> String { + fn expand_name_or_array_element(&mut self, name: &str) -> ArithResult { if let Some(bracket) = name.find('[') && name.ends_with(']') { let arr_name = &name[..bracket]; - let resolved = self.resolve_nameref(arr_name); + let resolved = self.interp.resolve_nameref(arr_name); let idx_str = strip_subscript_quotes(&name[bracket + 1..name.len() - 1]); - if let Some(arr) = self.scoped.assoc_arrays.get(resolved) { - let key = self.expand_variable_or_literal(idx_str); - return arr.get(&key).cloned().unwrap_or_default(); + if let Some(arr) = self.interp.scoped.assoc_arrays.get(resolved) { + let key = self.expand_variable_or_literal(idx_str)?; + return Ok(arr.get(&key).cloned().unwrap_or_default()); } - if let Some(arr) = self.scoped.arrays.get(resolved) { - let idx = self.resolve_indexed_array_subscript(resolved, idx_str); - return arr.get(&idx).cloned().unwrap_or_default(); + if let Some(arr) = self.interp.scoped.arrays.get(resolved) { + let idx = self.resolve_indexed_array_subscript(resolved, idx_str)?; + return Ok(arr.get(&idx).cloned().unwrap_or_default()); } - if self.resolve_indexed_array_subscript(resolved, idx_str) == 0 { + if self.resolve_indexed_array_subscript(resolved, idx_str)? == 0 { return self.expand_variable(resolved); } - return String::new(); + return Ok(String::new()); } self.expand_variable(name) } + fn resolve_indexed_array_subscript(&mut self, name: &str, sub: &str) -> ArithResult { + // Preserve read-only dollar-subscript behavior: discard child writes, + // but never refresh depth or fuel on a recursive entry. + let raw = ArithEval::new(self.interp, self.budget).eval_with_dollars(sub)?; + Ok(self.interp.normalize_indexed_array_subscript(name, raw)) + } } /// Drop one layer of matching quotes around a subscript: `c["x y"]` -> key `x y`. diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 0ec20f358..87a32e96d 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -6046,9 +6046,7 @@ impl Interpreter { fn cond_int_cmp(&mut self, left: &str, right: &str, cmp: fn(i64, i64) -> bool) -> bool { let mut values = [0i64; 2]; for (slot, operand) in values.iter_mut().zip([left, right]) { - let mut ev = arithmetic::ArithEval::new(self); - let r = ev.eval_str(operand); - let writes = std::mem::take(&mut ev.writes); + let (r, writes) = self.arith_eval_unexpanded(operand); self.apply_arith_writes(writes); match r { Ok(v) => *slot = v, @@ -13079,8 +13077,10 @@ impl Interpreter { /// [`Self::expand_key_text`]; `plain` reads only plain scalar values /// (no nameref, array or special-parameter resolution). fn expand_key_text_with(&self, s: &str, plain: bool) -> String { - let lookup = |name: &str| -> String { - if plain { + let lookup = |name: &str, braced: bool| { + Ok::<_, std::convert::Infallible>(if !plain && !braced { + self.expand_variable(name) + } else if plain { self.scoped.variables.get(name).cloned().unwrap_or_default() } else if let Some(arr) = name .strip_suffix("[@]") @@ -13096,8 +13096,16 @@ impl Interpreter { self.array_values(self.resolve_nameref(arr)).join(&sep) } else { self.resolve_param_expansion_name(name).1 - } + }) }; + Self::expand_key_text_using(s, lookup).unwrap() + } + + /// One quote/key scanner; callers own lookup semantics and recursion budgets. + fn expand_key_text_using( + s: &str, + mut lookup: impl FnMut(&str, bool) -> std::result::Result, + ) -> std::result::Result { let mut out = String::new(); let mut chars = s.chars().peekable(); while let Some(c) = chars.next() { @@ -13121,7 +13129,7 @@ impl Interpreter { } name.push(n); } - out.push_str(&lookup(&name)); + out.push_str(&lookup(&name, true)?); } '$' if chars .peek() @@ -13135,16 +13143,12 @@ impl Interpreter { name.push(n); chars.next(); } - out.push_str(&if plain { - lookup(&name) - } else { - self.expand_variable(&name) - }); + out.push_str(&lookup(&name, false)?); } _ => out.push(c), } } - out + Ok(out) } /// Fully expand an associative array key using standard word expansion. @@ -13540,6 +13544,10 @@ impl Interpreter { fn resolve_indexed_array_subscript(&self, arr_name: &str, key: &str) -> usize { let raw_idx = self.evaluate_arithmetic(key); + self.normalize_indexed_array_subscript(arr_name, raw_idx) + } + + fn normalize_indexed_array_subscript(&self, arr_name: &str, raw_idx: i64) -> usize { if raw_idx < 0 { let len = self .scoped diff --git a/crates/bashkit/tests/integration/arithmetic_resource_tests.rs b/crates/bashkit/tests/integration/arithmetic_resource_tests.rs new file mode 100644 index 000000000..4f108dc85 --- /dev/null +++ b/crates/bashkit/tests/integration/arithmetic_resource_tests.rs @@ -0,0 +1,143 @@ +// THREAT[TM-DOS-026]: textual expansion and subscript evaluation share limits. +use bashkit::Bash; + +fn nested_array_expansion(levels: usize, length: bool) -> String { + let mut expr = "0".to_string(); + for _ in 0..levels { + expr = if length { + format!("${{#a[{expr}]}}") + } else { + format!("${{a[{expr}]}}") + }; + } + expr +} + +#[tokio::test] +async fn arithmetic_dollar_subscripts_stop_at_recursion_limit() { + // Bounded input exercises the guard without probing a host crash threshold. + for length in [false, true] { + let mut bash = Bash::new(); + let expr = nested_array_expansion(51, length); + let result = bash.exec(&format!("a=(0); let '{expr}'")).await.unwrap(); + assert_ne!(result.exit_code, 0); + assert!( + result + .stderr + .contains("expression recursion level exceeded"), + "{}", + result.stderr + ); + let recovery = bash.exec("echo recovered").await.unwrap(); + assert_eq!(recovery.stdout, "recovered\n"); + assert_eq!(recovery.exit_code, 0); + assert!(recovery.stderr.is_empty()); + } +} + +#[tokio::test] +async fn arithmetic_dollar_siblings_share_expansion_fuel() { + let mut bash = Bash::new(); + let term = nested_array_expansion(10, false); + // Source and individual terms fit. Aggregate recursive work exceeds the fuel. + let expr = format!("{}+1", vec![term; 30].join("+")); + assert!(expr.len() < 8192); + let result = bash.exec(&format!("a=(0); let '{expr}'")).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!( + result + .stderr + .contains("expression recursion level exceeded") + ); +} + +#[tokio::test] +async fn arithmetic_dollar_subscripts_match_bash_within_limits() { + let expr = nested_array_expansion(4, false); + // NOTE: subscript stays portable on purpose: macOS ships bash 3.2 with no + // negative-index support while Linux CI has bash 5.x, so `${a[-1]}` would + // make this differential assertion environment-dependent. `i-0` still + // exercises arithmetic inside the subscript on every bash. + let script = format!( + "a=(0 7); n=123; i=1; echo $(({expr}+${{a[i]}})); \ + echo $((${{#a[1]}}+${{n%3}})); echo $((${{a[i-0]}}))" + ); + let expected = std::process::Command::new("bash") + .args(["--noprofile", "--norc", "-c", &script]) + .output() + .unwrap(); + assert!(expected.status.success()); + let mut bash = Bash::new(); + let result = bash.exec(&script).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stderr.is_empty(), "{}", result.stderr); + assert_eq!(result.stdout.as_bytes(), expected.stdout); +} + +#[tokio::test] +async fn arithmetic_quoted_let_keeps_shallow_dollar_expansion() { + let mut bash = Bash::new(); + let expr = nested_array_expansion(4, false); + let result = bash + .exec(&format!("a=(0 7); let 'x={expr}+${{a[1]}}'; echo \"$x\"")) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "7\n"); + assert!(result.stderr.is_empty()); +} + +#[tokio::test] +async fn arithmetic_nameref_and_assoc_keys_cannot_restart_the_budget() { + let expr = nested_array_expansion(51, false); + for usage in [ + "$r", + "${r}", + "${#r}", + "${m[$r]}", + "${m[k$r]}", + "${m[k${r}]}", + ] { + let mut bash = Bash::new(); + let result = bash + .exec(&format!( + "a=(0); declare -A m; declare -n r='a[{expr}]'; let '{usage}'" + )) + .await + .unwrap(); + assert_eq!(result.exit_code, 1, "{usage}"); + assert!( + result + .stderr + .contains("expression recursion level exceeded"), + "{usage}: {}", + result.stderr + ); + } +} + +#[tokio::test] +async fn nested_arithmetic_dollars_share_fuel_with_subscripts() { + let term = format!("$(({}))", nested_array_expansion(10, false)); + let expr = format!("{}+1", vec![term; 30].join("+")); + let mut bash = Bash::new(); + let result = bash.exec(&format!("a=(0); let '{expr}'")).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!( + result + .stderr + .contains("expression recursion level exceeded") + ); +} + +#[tokio::test] +async fn dollar_subscript_writes_remain_read_only() { + let mut bash = Bash::new(); + let result = bash + .exec("a=(0 7); i=0; let 'x=${a[i=1]}'; echo \"$i $x\"") + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "0 7\n"); + assert!(result.stderr.is_empty()); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 8d649db77..3bc4432a5 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -17,6 +17,7 @@ pub mod agent_skills_publication_tests; pub mod allexport_tests; pub mod archive_bzip2_tests; pub mod arithmetic_fuzz_scaffold_tests; +pub mod arithmetic_resource_tests; pub mod array_budget_security_tests; pub mod awk_fuzz_scaffold_tests; pub mod awk_newline_tests; diff --git a/docs/security.md b/docs/security.md index d8f1e66fd..91dc142b8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -115,6 +115,11 @@ behaviour on malformed input. behaviour parity where expected, and confirm intentional divergences where security requires it. +Arithmetic limits cover textual dollar expansion inside expressions. +Nested array subscripts share the enclosing expression's recursion and work +limits, including quoted arguments to `let`. Exceeding either limit returns +an arithmetic error; subsequent executions can reuse the session. + ## Panic safety All builtin commands are wrapped with `catch_unwind`. If a builtin panics, the diff --git a/knowledge/foundations/architecture.md b/knowledge/foundations/architecture.md index c2e8d822a..b6d2bb4c2 100644 --- a/knowledge/foundations/architecture.md +++ b/knowledge/foundations/architecture.md @@ -92,6 +92,18 @@ process-local scheduling, not a serializable continuation; snapshots still capture state only between executions. See [Builtin Commands](builtins.md) and [Snapshot History](snapshot-history.md). +### Arithmetic expansion budget + +Arithmetic's synchronous evaluator owns one depth/fuel budget per top-level +expression. Dollar expansion checks and charges original text before +recursing into array subscripts or nested `$((...))`. Read-only child +evaluators borrow that budget while discarding writes; siblings cannot +restart fuel. Nameref and associative-key lookups stay in the same budget. +Recursive bare-variable evaluation and syntax parsing share the depth +counter. Expansion errors propagate to the arithmetic boundary; later host +executions receive fresh state. Regression and Bash differential coverage: +`crates/bashkit/tests/integration/arithmetic_resource_tests.rs` (TM-DOS-026). + ### Shared execution budget Each `exec_with_options` creates exactly one `ExecutionBudget` before hooks or diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index a5896ba04..db87172e1 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -240,10 +240,10 @@ runaway scripts without permanently breaking the session. | TM-DOS-020 | Function recursion | `f() { f; }; f` | Effective depth limit is at most 16 even if an embedder requests more; the previous default 100 and hardened 64 could overflow a 2 MiB host stack before the counter fired (#2459). The CLI subprocess test runs direct and mutual recursion on that stack size. | **MITIGATED** | | TM-DOS-021 | Command sub nesting | `$($($($())))` | Child parsers inherit remaining depth budget + fuel from parent | **MITIGATED** | | TM-DOS-022 | Parser recursion | Deeply nested `(((())))` | `max_ast_depth` limit (100) + `HARD_MAX_AST_DEPTH` cap (100) | **MITIGATED** | -| TM-DOS-026 | Arithmetic recursion and expansion amplification | `$((((...))))`, `a=a+a; $((a))`, `i=arr[i]; $((arr[i]))` | `MAX_ARITHMETIC_DEPTH` limit (50), shared expansion fuel, cycle detection, expanded-size cap | **MITIGATED** | +| TM-DOS-026 | Arithmetic recursion and expansion amplification | `$((((...))))`, `a=a+a; $((a))`, `i=arr[i]; $((arr[i]))`, nested `${a[${a[...]}]}` passed to `let` | `MAX_ARITHMETIC_DEPTH` limit (50) checked before dollar expansion and tokenization; shared fuel across textual expansion, nested read-only subscripts, nameref/key lookup, and recursive variable evaluation; expanded-size cap | **MITIGATED** | | TM-DOS-064 | Heredoc suffix re-injection CPU amplification | Many `: <