Skip to content
Merged
666 changes: 665 additions & 1 deletion crates/bashkit/src/interpreter/mod.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 @@ -108,6 +108,8 @@ pub enum CompoundCommand {
Arithmetic(String),
/// Time command - measure execution time
Time(TimeCommand),
/// Conditional expression [[ ... ]]
Conditional(Vec<Word>),
}

/// Time command - wraps a command and measures its execution time.
Expand Down
56 changes: 56 additions & 0 deletions crates/bashkit/src/parser/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,34 @@ impl<'a> Lexer<'a> {
}
}
}
} else if ch == '`' {
// Backtick command substitution: convert `cmd` to $(cmd)
self.advance(); // consume opening `
word.push_str("$(");
while let Some(c) = self.peek_char() {
if c == '`' {
self.advance(); // consume closing `
break;
}
if c == '\\' {
// In backticks, backslash only escapes $, `, \, newline
self.advance();
if let Some(next) = self.peek_char() {
if matches!(next, '$' | '`' | '\\' | '\n') {
word.push(next);
self.advance();
} else {
word.push('\\');
word.push(next);
self.advance();
}
}
} else {
word.push(c);
self.advance();
}
}
word.push(')');
} else if ch == '\\' {
self.advance();
if let Some(next) = self.peek_char() {
Expand Down Expand Up @@ -492,6 +520,34 @@ impl<'a> Lexer<'a> {
}
}
}
'`' => {
// Backtick command substitution inside double quotes
self.advance(); // consume opening `
content.push_str("$(");
while let Some(c) = self.peek_char() {
if c == '`' {
self.advance();
break;
}
if c == '\\' {
self.advance();
if let Some(next) = self.peek_char() {
if matches!(next, '$' | '`' | '\\' | '"') {
content.push(next);
self.advance();
} else {
content.push('\\');
content.push(next);
self.advance();
}
}
} else {
content.push(c);
self.advance();
}
}
content.push(')');
}
_ => {
content.push(ch);
self.advance();
Expand Down
117 changes: 117 additions & 0 deletions crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,11 @@ impl<'a> Parser<'a> {
}
}

// Check for conditional expression [[ ... ]]
if matches!(self.current_token, Some(tokens::Token::DoubleLeftBracket)) {
return self.parse_compound_with_redirects(|s| s.parse_conditional());
}

// Check for arithmetic command ((expression))
if matches!(self.current_token, Some(tokens::Token::DoubleLeftParen)) {
return self.parse_compound_with_redirects(|s| s.parse_arithmetic_command());
Expand Down Expand Up @@ -994,6 +999,118 @@ impl<'a> Parser<'a> {
}

/// Parse arithmetic command ((expression))
/// Parse [[ conditional expression ]]
fn parse_conditional(&mut self) -> Result<CompoundCommand> {
self.advance(); // consume '[['

let mut words = Vec::new();
let mut saw_regex_op = false;

loop {
match &self.current_token {
Some(tokens::Token::DoubleRightBracket) => {
self.advance(); // consume ']]'
break;
}
Some(tokens::Token::Word(w))
| Some(tokens::Token::LiteralWord(w))
| Some(tokens::Token::QuotedWord(w)) => {
let w_clone = w.clone();
let is_quoted =
matches!(self.current_token, Some(tokens::Token::QuotedWord(_)));
let is_literal =
matches!(self.current_token, Some(tokens::Token::LiteralWord(_)));

// After =~, collect the regex pattern (may contain parens)
if saw_regex_op {
let pattern = self.collect_conditional_regex_pattern(&w_clone);
words.push(Word::literal(&pattern));
saw_regex_op = false;
continue;
}

if w_clone == "=~" {
saw_regex_op = true;
}

let word = if is_literal {
Word {
parts: vec![WordPart::Literal(w_clone)],
quoted: true,
}
} else {
let mut parsed = self.parse_word(w_clone);
if is_quoted {
parsed.quoted = true;
}
parsed
};
words.push(word);
self.advance();
}
// Operators that the lexer tokenizes separately
Some(tokens::Token::And) => {
words.push(Word::literal("&&"));
self.advance();
}
Some(tokens::Token::Or) => {
words.push(Word::literal("||"));
self.advance();
}
Some(tokens::Token::LeftParen) => {
words.push(Word::literal("("));
self.advance();
}
Some(tokens::Token::RightParen) => {
words.push(Word::literal(")"));
self.advance();
}
None => {
return Err(crate::error::Error::Parse(
"unexpected end of input in [[ ]]".to_string(),
));
}
_ => {
// Skip unknown tokens
self.advance();
}
}
}

Ok(CompoundCommand::Conditional(words))
}

/// Collect a regex pattern after =~ in [[ ]], handling parens and special chars.
fn collect_conditional_regex_pattern(&mut self, first_word: &str) -> String {
let mut pattern = first_word.to_string();
self.advance(); // consume the first word

// Concatenate adjacent tokens that are part of the regex pattern
loop {
match &self.current_token {
Some(tokens::Token::DoubleRightBracket) => break,
Some(tokens::Token::And) | Some(tokens::Token::Or) => break,
Some(tokens::Token::LeftParen) => {
pattern.push('(');
self.advance();
}
Some(tokens::Token::RightParen) => {
pattern.push(')');
self.advance();
}
Some(tokens::Token::Word(w))
| Some(tokens::Token::LiteralWord(w))
| Some(tokens::Token::QuotedWord(w)) => {
pattern.push_str(w);
self.advance();
}
_ => break,
}
}

pattern
}

fn parse_arithmetic_command(&mut self) -> Result<CompoundCommand> {
self.advance(); // consume '(('

Expand Down
71 changes: 42 additions & 29 deletions crates/bashkit/tests/security_failpoint_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,36 +107,49 @@ async fn security_loop_counter_reset() {
}

/// Test: Function depth bypass is detected
#[tokio::test]
///
/// This test deliberately bypasses function depth limits, causing deep recursion.
/// Run on a thread with 8MB stack so the command limit (not the OS stack) halts it.
#[test]
#[serial]
async fn security_function_depth_bypass() {
fail::cfg("limits::push_function", "return(skip_check)").unwrap();

// Try recursive function - without limit check, this would cause stack overflow
let result = run_script_with_limits(
r#"
recurse() {
echo "depth"
recurse
}
recurse
"#,
ExecutionLimits::new()
.max_function_depth(5)
.max_commands(100)
.timeout(Duration::from_secs(2)),
)
.await;

fail::cfg("limits::push_function", "off").unwrap();

// Should hit command limit even if function depth is bypassed
assert!(
result.stderr.contains("limit")
|| result.stderr.contains("exceeded")
|| result.exit_code != 0,
"Recursive function should be limited"
);
fn security_function_depth_bypass() {
let handle = std::thread::Builder::new()
.stack_size(8 * 1024 * 1024) // 8 MB stack
.spawn(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
fail::cfg("limits::push_function", "return(skip_check)").unwrap();

let result = run_script_with_limits(
r#"
recurse() {
echo "depth"
recurse
}
recurse
"#,
ExecutionLimits::new()
.max_function_depth(5)
.max_commands(100)
.timeout(Duration::from_secs(2)),
)
.await;

fail::cfg("limits::push_function", "off").unwrap();

assert!(
result.stderr.contains("limit")
|| result.stderr.contains("exceeded")
|| result.exit_code != 0,
"Recursive function should be limited"
);
});
})
.unwrap();
handle.join().unwrap();
}

// =============================================================================
Expand Down
1 change: 0 additions & 1 deletion crates/bashkit/tests/spec_cases/bash/command-subst.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ result=$(false); echo $?
### end

### subst_backtick
### skip: backtick command substitution not implemented
echo `echo hello`
### expect
hello
Expand Down
66 changes: 66 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/command.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
### command_v_builtin
# command -v finds builtins
command -v echo
### expect
echo
### end

### command_v_not_found
# command -v returns 1 for unknown commands
### exit_code:1
command -v nonexistent_cmd_xyz
### expect
### end

### command_v_function
# command -v finds user functions
my_func() { echo hi; }
command -v my_func
### expect
my_func
### end

### command_V_builtin
# command -V describes builtins
command -V echo
### expect
echo is a shell builtin
### end

### command_V_function
### bash_diff: real bash also prints the function body
# command -V describes functions
my_func() { echo hi; }
command -V my_func
### expect
my_func is a function
### end

### command_V_keyword
# command -V identifies keywords
command -V if
### expect
if is a shell keyword
### end

### command_run_builtin
# command runs builtins directly
command echo hello
### expect
hello
### end

### command_bypasses_function
# command bypasses function override
echo() { printf "OVERRIDE\n"; }
command echo real
### expect
real
### end

### command_v_keyword
# command -v finds shell keywords
command -v for
### expect
for
### end
Loading
Loading