Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ through configurable limits.
| Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **MITIGATED** |
| Silent truncation at builtin caps (TM-DOS-109) | `seq 200000`, an awk loop past its cap, or an oversized `sprintf` expression returns incomplete output with exit 0 | Caps report `<cmd>: <what> limit (<N>) exceeded` on stderr and exit non-zero; awk caps and formatting errors are fatal | **MITIGATED** |
| In-builtin memory growth (TM-DOS-110) | `awk 'BEGIN { s = "x"; while (1) s = s s }'` or `jq -n '"x" \| until(false; . + .)'` allocates until the host aborts | awk checks each string against a 16 MiB cap before allocating it, caps `$N` field indexes, and caps total variable memory at `max_live_intermediate_bytes` (fatal, exit 2). jq meters every live string, array and object against the same limit and fails before growing (exit 5); non-emitting jq loops and pure recursion stop at the timeout; non-tail recursion hits a live-context ceiling (64) before host stack exhaustion | **MITIGATED** |
| Arrays bypass the retained-memory budget (TM-DOS-114) | Ten array slots each holding a 4 MiB string retain ~40 MB past the 10 MB variable-byte budget | Array keys and values charge `max_total_variable_bytes` alongside scalars, are released on unset/replacement/scope pop, and an over-budget write fails execution | **MITIGATED** |
| Arrays bypass the retained-memory budget (TM-DOS-114) | Ten array slots each holding a 4 MiB string retain ~40 MB past the 10 MB variable-byte budget | Array keys and values charge `max_total_variable_bytes` alongside scalars, are released on unset/replacement/scope pop, and an over-budget write fails execution. Pending indexed compound-assignment keys, values, fields and containers also hold shared intermediate-memory leases, including repeated subscripts | **MITIGATED** |
| `$(<file)` resource-accounting bypass (TM-DOS-115) | Repeated file-read substitutions skip command limits and accumulate file contents during argument expansion | Optimized reads charge command, session-command, and work budgets; file and accumulated substitution bytes hold live-intermediate leases | **MITIGATED** |
| Silent scalar assignment rejection (TM-DOS-111) | A variable write over the byte or count limit is dropped while the script exits 0 | The first rejected write fails execution with a memory-limit error; a later exec can reuse the session | **MITIGATED** |
| AWK `close()` recycles getline read quotas (TM-DOS-116) | `getline x < file; close(file)` in a loop reloads the same large VFS file while retained-cache accounting returns to zero | Every cache miss charges the shared aggregate-input and work budgets before decoding the file; `close()` still releases retained cache bytes but cannot refund either charge | **MITIGATED** |
Expand Down
73 changes: 53 additions & 20 deletions crates/bashkit/src/interpreter/declare.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! Declaration builtins: `declare`/`typeset`, `local`, `export`, `readonly`.
//!
//! Important decisions:
//! - Indexed compound assignments retain budgeted values until all expansion
//! finishes; duplicate subscripts must not bypass intermediate-memory limits.
//! - One engine serves all four builtins (bash shares `declare_internal`):
//! each builtin only restricts its option letters and fixes an attribute
//! (`export` adds `-x`, `readonly` adds `-r`, `local` makes the name local).
Expand All @@ -15,6 +17,7 @@
//! value holds control characters; associative arrays in bash's hash order.

use super::*;
use crate::limits::BudgetedVec;

/// Marks the placeholder that stands for a compound argument (`a=(...)`).
/// A private-use character, so ordinary text never contains it.
Expand Down Expand Up @@ -1385,7 +1388,10 @@ impl Interpreter {
continue;
}
let remaining = max_entries.saturating_sub(map.len()).saturating_mul(2);
for field in self.expand_element_fields(word, remaining).await? {
let fields = self.expand_element_fields(word, remaining).await?;
let (fields, _fields_lease) = fields.into_parts();
for field in fields {
let field = field.into_inner();
match pending_key.take() {
Some(k) => {
let v = self.transform_element(attrs, None, field);
Expand Down Expand Up @@ -1419,31 +1425,35 @@ impl Interpreter {
// subscripts in order against the array as it is being built:
// `a=([0]=1+2 [a[0]]=x)` stores x at 3.
enum Item {
Keyed(String, String, bool),
Fields(Vec<String>),
Keyed(BudgetedString, BudgetedString, bool),
Fields(BudgetedVec<BudgetedString>),
}
let mut items = Vec::with_capacity(words.len());
// THREAT[TM-DOS-114]: duplicate indices still retain every expanded
// value until subscripts run; lease both payloads and containers.
let mut items = BudgetedVec::new(Some(&self.execution_budget))?;
let mut budget = max_entries.saturating_sub(map.len());
for word in words {
self.execution_budget.consume_work(1)?;
// Bash brace-expands indexed elements before spotting
// `[i]=`, so `([2]=v{1,2})` stores the plain words
// `[2]=v1 [2]=v2`; assoc elements never brace-expand.
let braces = self.brace_expand_word(word).is_some();
if let Some((kw, vw, kappend)) = split_keyed_word(word).filter(|_| !braces) {
let key_text = self.expand_word(&kw).await?;
let key_text = self.expand_array_word(&kw).await?;
// `[k]=~:~` tilde-expands like an assignment value.
let vw = self.tilde_assignment_value(&vw).into_owned();
let val = self.expand_word(&vw).await?;
items.push(Item::Keyed(key_text, val, kappend));
let val = self.expand_array_word(&vw).await?;
items.try_push(Item::Keyed(key_text, val, kappend))?;
continue;
}
if budget == 0 {
break;
}
let fields = self.expand_element_fields(word, budget).await?;
budget = budget.saturating_sub(fields.len());
items.push(Item::Fields(fields));
items.try_push(Item::Fields(fields))?;
}
let (items, _items_lease) = items.into_parts();
// Only a subscript that names the array needs it live (keeps
// the common case free of per-element copies).
let mentions_self = items
Expand All @@ -1467,9 +1477,10 @@ impl Interpreter {
let len = map.keys().max().map_or(0, |m| m + 1) as i64;
let i = len + raw;
if i < 0 {
return Err(crate::error::Error::LineAbort(
self.diag(format!("{name}[{key_text}]: bad array subscript\n")),
));
return Err(crate::error::Error::LineAbort(self.diag(format!(
"{name}[{}]: bad array subscript\n",
&*key_text
))));
}
i as usize
} else {
Expand All @@ -1480,7 +1491,7 @@ impl Interpreter {
} else {
None
};
let v = self.transform_element(attrs, old.as_deref(), val);
let v = self.transform_element(attrs, old.as_deref(), val.into_inner());
if map.len() >= max_entries && !map.contains_key(&idx) {
break 'items;
}
Expand All @@ -1491,7 +1502,9 @@ impl Interpreter {
next = idx + 1;
}
Item::Fields(fields) => {
let (fields, _fields_lease) = fields.into_parts();
for field in fields {
let field = field.into_inner();
if map.len() >= max_entries {
break 'items;
}
Expand All @@ -1516,24 +1529,44 @@ impl Interpreter {
/// Expand one unkeyed compound element into fields: word splitting for
/// unquoted expansions (bounded by `limit`), `"${a[@]}"` splats, brace
/// expansion and globbing for unquoted literals.
async fn expand_element_fields(&mut self, word: &Word, limit: usize) -> Result<Vec<String>> {
async fn expand_element_fields(
&mut self,
word: &Word,
limit: usize,
) -> Result<BudgetedVec<BudgetedString>> {
// Brace expansion runs first on the unexpanded word, as for command
// arguments: `a=("x"{1,2})` gives `x1 x2`, `a=('{a,b}')` stays literal.
let Some(braced) = self.brace_expand_word(word) else {
return self.expand_element_word(word, limit).await;
let fields = self.expand_element_word(word, limit).await?;
return self.lease_array_fields(fields);
};
let mut fields = Vec::new();
let mut fields = BudgetedVec::new(Some(&self.execution_budget))?;
for w in &braced {
let left = limit.saturating_sub(fields.len());
if left == 0 {
break;
}
fields.extend(self.expand_element_word(w, left).await?);
let expanded = self.expand_element_word(w, left).await?;
let leased = self.lease_array_fields(expanded)?;
let (expanded, _lease) = leased.into_parts();
for field in expanded {
fields.try_push(field)?;
}
}
fields.truncate(limit);
Ok(fields)
}

fn lease_array_fields(&self, fields: Vec<String>) -> Result<BudgetedVec<BudgetedString>> {
let mut leased = BudgetedVec::new(Some(&self.execution_budget))?;
for field in fields {
leased.try_push(BudgetedString::try_from_string(
field,
Some(&self.execution_budget),
)?)?;
}
Ok(leased)
}

/// One brace-expanded compound element: splitting, splats, then globbing
/// for words with unquoted literal text.
async fn expand_element_word(&mut self, word: &Word, limit: usize) -> Result<Vec<String>> {
Expand All @@ -1560,7 +1593,7 @@ impl Interpreter {
if is_unquoted_expansion {
// Split fields are pathname-expanded like command words:
// `p='*.txt'; a=($p)` holds the matching files.
let expanded = self.expand_word(word).await?;
let expanded = self.expand_array_word(word).await?;
let mut fields = Vec::new();
for field in self.ifs_split_limited(&expanded, limit)? {
match self.expand_glob_item(&field, false).await {
Expand All @@ -1581,15 +1614,15 @@ impl Interpreter {
fields.truncate(limit);
return Ok(fields);
}
let value = self.expand_word(word).await?;
let value = self.expand_array_word(word).await?;
let literal_only = word.parts.iter().all(|p| matches!(p, WordPart::Literal(_)));
let globbable = if word.quoted {
word.has_unquoted_glob
} else {
literal_only
};
if !globbable {
return Ok(vec![value]);
return Ok(vec![value.into_inner()]);
}
let mut fields = match self
.expand_glob_item(&value, word.quoted && word.has_unquoted_glob)
Expand Down
Loading
Loading