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
1 change: 1 addition & 0 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ through configurable limits.
| AWK unbounded loops (TM-DOS-033) | `BEGIN { while(1){} }`, nested loops, deep recursion | Per-loop and whole-program loop caps from `ExecutionLimits`, call-depth cap; all fatal (exit 2) | MITIGATED |
| awk commands (TM-DOS-128) | `system()`, `print \| cmd` or `cmd \| getline` in a loop | Each command runs as `sh -c` in the sandbox shell under the session's command budget and timeout; awk's loop, output and getline caps still apply | MITIGATED |
| Command hash table growth (TM-DOS-129) | `hash -p /x nameN` or many distinct PATH commands | The table holds at most 512 entries and drops the oldest; assigning `PATH` empties it | MITIGATED |
| Array and arithmetic reports (TM-DOS-130) | `$((` nested in arithmetic, many `${a[-9]}` reads, huge `${!r}` target | 32 nesting levels; warnings capped at 64 KiB per command; indirect target echoed truncated to 256 chars | MITIGATED |

**Stack Overflow / Recursion:**

Expand Down
51 changes: 51 additions & 0 deletions crates/bashkit/src/builtins/printf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ pub(super) fn render_printf_bytes(
args: &[String],
) -> std::result::Result<Vec<u8>, String> {
let format = strip_zero_hex_escapes(format);
let format = drop_zero_flag_on_strings(format.as_ref());
let format = format.as_ref();
let values = format_arguments(args);
validate_format_caps(format.as_bytes(), args)?;
Expand All @@ -222,6 +223,51 @@ pub(super) fn render_printf_bytes(
Ok(out)
}

/// bash ignores the `0` flag on `%s` (`%06s` pads with blanks); the uutils
/// formatter rejects it, so drop it from string directives.
fn drop_zero_flag_on_strings(format: &str) -> Cow<'_, str> {
let bytes = format.as_bytes();
let mut out = String::with_capacity(format.len());
let mut changed = false;
let mut i = 0;
let mut copied = 0;
while i < bytes.len() {
if bytes[i] != b'%' {
i += 1;
continue;
}
if bytes.get(i + 1) == Some(&b'%') {
i += 2;
continue;
}
let flags_start = i + 1;
let mut j = flags_start;
while j < bytes.len() && matches!(bytes[j], b'-' | b'+' | b' ' | b'#' | b'0') {
j += 1;
}
let flags_end = j;
while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b'.') {
j += 1;
}
if bytes.get(j) == Some(&b's') && bytes[flags_start..flags_end].contains(&b'0') {
out.push_str(&format[copied..flags_start]);
for &b in &bytes[flags_start..flags_end] {
if b != b'0' {
out.push(b as char);
}
}
copied = flags_end;
changed = true;
}
i = j.max(i + 1);
}
if !changed {
return Cow::Borrowed(format);
}
out.push_str(&format[copied..]);
Cow::Owned(out)
}

/// In a printf FORMAT (unlike a `%b` argument) bash prints `\c` literally;
/// the uutils formatter would stop output there, so escape the backslash.
fn escape_format_backslash_c(format: &str) -> Cow<'_, str> {
Expand Down Expand Up @@ -459,6 +505,11 @@ fn expand_time_directives(
s => Some(parse_leading_i64(s)),
};
*arg = strftime(seconds, fmt)?;
// bash formats into a 128-byte buffer: a longer result
// (strftime reports overflow) prints nothing.
if arg.len() >= 128 {
arg.clear();
}
}
}
Ok((out, args))
Expand Down
9 changes: 6 additions & 3 deletions crates/bashkit/src/builtins/trap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,9 @@ impl Builtin for Trap {
}

// `trap SIGNAL` and `trap N ...` (first operand a number) reset
// every operand; otherwise the first operand is the handler.
// (` 42 `: bash skips blanks around the number.)
let first = args[0].trim_matches([' ', '\t']);
// every operand; otherwise the first operand is the handler
// (` 42 ` is a handler: bash runs it as a command).
let first = args[0].as_str();
let reset_all =
args.len() == 1 || (!first.is_empty() && first.bytes().all(|b| b.is_ascii_digit()));
let (handler, specs) = if reset_all {
Expand All @@ -173,6 +173,9 @@ impl Builtin for Trap {
if key == "ERR" {
*shell.err_trap_dormant = false;
}
if key == "DEBUG" {
*shell.debug_trap_dormant = false;
}
match handler {
Some(cmd) => {
shell.traps.insert(key, cmd.clone());
Expand Down
65 changes: 58 additions & 7 deletions crates/bashkit/src/interpreter/arithmetic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,12 @@ impl<'a> ArithEval<'a> {
{
return Ok(0);
}
let lv = self.lvalue(&name, sub.as_deref())?;
// A plain read of `a[-9]` past the start reports and reads 0
// (bash); `a[-9]++` stays an error.
let soft = !matches!(p.peek(), Tok::Op("++" | "--"));
let Some(lv) = self.lvalue_mode(&name, sub.as_deref(), soft)? else {
return Ok(0);
};
let cur = self.read_lvalue_int(&lv)?;
match p.peek() {
Tok::Op(o @ ("++" | "--")) => {
Expand Down Expand Up @@ -660,6 +665,22 @@ impl<'a> ArithEval<'a> {

/// Resolve `name` / `name[sub]` to a concrete storage slot.
fn lvalue(&mut self, name: &str, sub: Option<&str>) -> ArithResult<LValue> {
self.lvalue_mode(name, sub, false).map(|lv| {
lv.unwrap_or_else(|| LValue {
name: String::new(),
key: None,
})
})
}

/// [`Self::lvalue`]; with `soft_oob` a negative index before element 0
/// is reported as a warning and gives `None` instead of an error.
fn lvalue_mode(
&mut self,
name: &str,
sub: Option<&str>,
soft_oob: bool,
) -> ArithResult<Option<LValue>> {
let resolved = self.interp.resolve_nameref(name).to_string();
let (name, sub): (String, Option<String>) = match sub {
Some(s) => (resolved, Some(s.to_string())),
Expand All @@ -669,14 +690,14 @@ impl<'a> ArithEval<'a> {
},
};
let Some(sub) = sub else {
return Ok(LValue { name, key: None });
return Ok(Some(LValue { name, key: None }));
};
if self.interp.is_assoc_array(&name) {
let key = strip_subscript_quotes(&sub).to_string();
return Ok(LValue {
return Ok(Some(LValue {
name,
key: Some(key),
});
}));
}
if sub.trim().is_empty() {
return Err(format!("{name}[]: bad array subscript"));
Expand All @@ -686,13 +707,17 @@ impl<'a> ArithEval<'a> {
let len = self.interp.indexed_len_for_subscript(&name);
idx += len;
if idx < 0 && self.noeval == 0 {
if soft_oob {
self.interp.warn_bad_subscript(&name);
return Ok(None);
}
return Err(format!("{name}[{sub}]: bad array subscript"));
}
}
Ok(LValue {
Ok(Some(LValue {
name,
key: Some(idx.max(0).to_string()),
})
}))
}

fn read_lvalue_str(&self, lv: &LValue) -> String {
Expand Down Expand Up @@ -853,7 +878,33 @@ impl Interpreter {
result.push(ch);
continue;
}
if chars.peek() == Some(&'{') {
if chars.peek() == Some(&'(') && chars.clone().nth(1) == Some('(') {
// `$((...))` inside arithmetic (`a[$((i/3))]=x`,
// `$((1 + $((2)) ))`): its value, evaluated here.
chars.next();
chars.next();
let mut inner = String::new();
let mut depth = 0i32;
while let Some(c) = chars.next() {
match c {
'(' => depth += 1,
')' if depth == 0 && chars.peek() == Some(&')') => {
chars.next();
break;
}
')' => depth -= 1,
_ => {}
}
inner.push(c);
}
// 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');
} else {
result.push_str(&self.evaluate_arithmetic(&inner).to_string());
}
} else if chars.peek() == Some(&'{') {
chars.next();
let mut brace_content = String::new();
let mut brace_depth = 1i32;
Expand Down
Loading
Loading