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 @@ -132,7 +132,7 @@ through configurable limits.
| Param expansion bomb (TM-DOS-059) | `${x//a/bigstring}` multiplicative amplification | `max_total_variable_bytes` + `max_stdout_bytes` | MITIGATED |
| Sparse array huge-index (TM-DOS-060) | `arr[999999999]=x` | HashMap storage; `max_array_entries` | MITIGATED |
| Snapshot restore bypasses function/parser limits (TM-DOS-061) | Crafted snapshot with oversized/deep function bodies | Re-parse restored function source under current limits; re-check function memory budget | MITIGATED |
| Persistent fd exhaustion (TM-DOS-063) | `exec N>/tmp/f` across many `N` values | `max_file_descriptors` caps custom persistent descriptors | MITIGATED |
| Persistent fd exhaustion (TM-DOS-063) | `exec N>/tmp/f` across many `N` values, or `cmd {v}>/tmp/f` in a loop | `max_file_descriptors` caps custom persistent descriptors | MITIGATED |

**Configuration:**
```rust
Expand Down
8 changes: 5 additions & 3 deletions crates/bashkit/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,9 +512,11 @@ impl Walker {
fn walk_redirects(&mut self, redirects: &[Redirect], ctx: CommandContext) {
for redirect in redirects {
let mode = match redirect.kind {
RedirectKind::Output | RedirectKind::Clobber | RedirectKind::OutputBoth => {
RedirectMode::Write
}
// `<>` may create and overwrite the file: gate it as a write.
RedirectKind::Output
| RedirectKind::Clobber
| RedirectKind::OutputBoth
| RedirectKind::ReadWrite => RedirectMode::Write,
RedirectKind::Append => RedirectMode::Append,
RedirectKind::Input => RedirectMode::Read,
// Fd duplication, here-documents and here-strings do not name a
Expand Down
248 changes: 105 additions & 143 deletions crates/bashkit/src/interpreter/mod.rs

Large diffs are not rendered by default.

489 changes: 475 additions & 14 deletions crates/bashkit/src/interpreter/redirection.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions crates/bashkit/src/parser/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,8 @@ pub enum RedirectKind {
DupInput,
/// &> - redirect both stdout and stderr
OutputBoth,
/// <> - open for reading and writing (fd 0 by default)
ReadWrite,
}

/// Variable assignment.
Expand Down
200 changes: 98 additions & 102 deletions crates/bashkit/src/parser/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ impl<'a> Lexer<'a> {
} else if self.peek_char() == Some('&') {
self.advance();
Some(Token::DupInput)
} else if self.peek_char() == Some('>') {
self.advance();
Some(Token::RedirectReadWrite)
} else {
Some(Token::RedirectIn)
}
Expand Down Expand Up @@ -421,121 +424,114 @@ impl<'a> Lexer<'a> {
}
}

/// Check if this is a file descriptor redirect (e.g., 2>, 2>>, 2>&1)
/// or just a regular word starting with a digit
/// Check if this is a file descriptor redirect (e.g., 2>, 2>>, 2>&1,
/// 10>file, 3<<<word, 4<>file) or just a regular word starting with a
/// digit. Bash accepts any decimal fd number; more than
/// `MAX_FD_PREFIX_DIGITS` digits (out of `int` range) is a plain word.
fn read_word_or_fd_redirect(&mut self) -> Option<Token> {
// We need to look ahead to see if this is a fd redirect pattern
// Collect the leading digits
let mut fd_str = String::new();

// Peek at the first digit - we know it's a digit from the match
if let Some(ch) = self.peek_char()
&& ch.is_ascii_digit()
{
fd_str.push(ch);
const MAX_FD_PREFIX_DIGITS: usize = 9;
// Only the digits plus a 3-char redirect operator (e.g. "<<<", ">>")
// matter, so bound the lookahead: collecting all remaining input here
// made every digit-initial word O(n) and the whole lex O(n^2)
// (TM-DOS-024).
let input_remaining: String = self.lookahead().take(MAX_FD_PREFIX_DIGITS + 4).collect();
let ndigits = input_remaining
.chars()
.take_while(|c| c.is_ascii_digit())
.count();
if ndigits == 0 || ndigits > MAX_FD_PREFIX_DIGITS {
return self.read_word();
}
let Ok(fd) = input_remaining[..ndigits].parse::<i32>() else {
return self.read_word();
};
let rest = &input_remaining[ndigits..];
// Consume the fd digits plus `op_len` operator chars.
let consume = |lexer: &mut Self, op_len: usize| {
for _ in 0..ndigits + op_len {
lexer.advance();
}
};

// Check if it's a single digit followed by > or <
// We need to peek further without consuming. Only the digit plus a
// 2-char redirect operator (e.g. ">>", "<&", "<<") matter, so bound the
// lookahead — collecting all remaining input here made every
// digit-initial word O(n) and the whole lex O(n^2) (TM-DOS-024).
let input_remaining: String = self.lookahead().take(4).collect();

// Check patterns: "N>" "N>>" "N>&" "N<" "N<&"
if fd_str.len() == 1
&& let Some(first_digit) = fd_str.chars().next()
{
let rest = input_remaining.get(1..).unwrap_or(""); // Skip the digit we already matched

if rest.starts_with(">>") {
// N>> - append redirect with fd
let fd: i32 = first_digit.to_digit(10).unwrap() as i32;
self.advance(); // consume digit
self.advance(); // consume >
self.advance(); // consume >
return Some(Token::RedirectFdAppend(fd));
} else if rest.starts_with(">&") {
// N>&M - duplicate fd
let fd: i32 = first_digit.to_digit(10).unwrap() as i32;
self.advance(); // consume digit
self.advance(); // consume >
self.advance(); // consume &

// Read the target fd number or '-'
let mut target_str = String::new();
while let Some(c) = self.peek_char() {
if c.is_ascii_digit() || c == '-' {
target_str.push(c);
self.advance();
if c == '-' {
break;
}
} else {
if rest.starts_with(">>") {
// N>> - append redirect with fd
consume(self, 2);
return Some(Token::RedirectFdAppend(fd));
} else if rest.starts_with(">&") {
// N>&M - duplicate fd
consume(self, 2);

// Read the target fd number or '-'
let mut target_str = String::new();
while let Some(c) = self.peek_char() {
if c.is_ascii_digit() || c == '-' {
target_str.push(c);
self.advance();
if c == '-' {
break;
}
} else {
break;
}
}

if target_str == "-" {
return Some(Token::DupFdCloseOut(fd));
}
if target_str.is_empty() {
// Just N>& without target - treat as DupOutput with fd
return Some(Token::RedirectFd(fd));
}

let target_fd: i32 = target_str.parse().unwrap_or(1);
return Some(Token::DupFd(fd, target_fd));
} else if rest.starts_with('>') {
// N> - redirect with fd
let fd: i32 = first_digit.to_digit(10).unwrap() as i32;
self.advance(); // consume digit
self.advance(); // consume >
if target_str == "-" {
return Some(Token::DupFdCloseOut(fd));
}
if target_str.is_empty() {
// Just N>& without target - treat as DupOutput with fd
return Some(Token::RedirectFd(fd));
} else if rest.starts_with("<&") {
// N<&M or N<&- - duplicate input fd
let fd: i32 = first_digit.to_digit(10).unwrap() as i32;
self.advance(); // consume digit
self.advance(); // consume <
self.advance(); // consume &

// Read the target fd number or '-'
let mut target_str = String::new();
while let Some(c) = self.peek_char() {
if c.is_ascii_digit() || c == '-' {
target_str.push(c);
self.advance();
if c == '-' {
break;
}
} else {
}

let target_fd: i32 = target_str.parse().unwrap_or(1);
return Some(Token::DupFd(fd, target_fd));
} else if rest.starts_with('>') {
// N> - redirect with fd
consume(self, 1);
return Some(Token::RedirectFd(fd));
} else if rest.starts_with("<&") {
// N<&M or N<&- - duplicate input fd
consume(self, 2);

// Read the target fd number or '-'
let mut target_str = String::new();
while let Some(c) = self.peek_char() {
if c.is_ascii_digit() || c == '-' {
target_str.push(c);
self.advance();
if c == '-' {
break;
}
} else {
break;
}
}

if target_str == "-" {
return Some(Token::DupFdClose(fd));
}
let target_fd: i32 = target_str.parse().unwrap_or(0);
return Some(Token::DupFdIn(fd, target_fd));
} else if rest.starts_with("<<") && !rest.starts_with("<<<") {
// N<<EOF / N<<-EOF - here document on fd N
let fd: i32 = first_digit.to_digit(10).unwrap() as i32;
self.advance(); // consume digit
self.advance(); // consume <
self.advance(); // consume <
let strip = self.peek_char() == Some('-');
if strip {
self.advance();
}
return Some(Token::HereDocFd(fd, strip));
} else if rest.starts_with('<') && !rest.starts_with("<<") {
// N< - input redirect with fd
let fd: i32 = first_digit.to_digit(10).unwrap() as i32;
self.advance(); // consume digit
self.advance(); // consume <
return Some(Token::RedirectFdIn(fd));
if target_str == "-" {
return Some(Token::DupFdClose(fd));
}
let target_fd: i32 = target_str.parse().unwrap_or(0);
return Some(Token::DupFdIn(fd, target_fd));
} else if rest.starts_with("<<<") {
// N<<<word - here string on fd N
consume(self, 3);
return Some(Token::HereStringFd(fd));
} else if rest.starts_with("<<") {
// N<<EOF / N<<-EOF - here document on fd N
consume(self, 2);
let strip = self.peek_char() == Some('-');
if strip {
self.advance();
}
return Some(Token::HereDocFd(fd, strip));
} else if rest.starts_with("<>") {
// N<>file - open file read-write on fd N
consume(self, 2);
return Some(Token::RedirectFdReadWrite(fd));
} else if rest.starts_with('<') {
// N< - input redirect with fd
consume(self, 1);
return Some(Token::RedirectFdIn(fd));
}

// Not a fd redirect pattern, read as regular word
Expand Down
Loading
Loading