Skip to content
Closed
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
384 changes: 349 additions & 35 deletions crates/bashkit/src/builtins/awk.rs

Large diffs are not rendered by default.

54 changes: 28 additions & 26 deletions crates/bashkit/src/builtins/jq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,30 @@ const MAX_JQ_JSON_DEPTH: usize = 100;
/// jq command - JSON processor
pub struct Jq;

impl Jq {
/// Parse multiple JSON values from input using streaming deserializer.
/// Handles multi-line JSON, NDJSON, and concatenated JSON values.
fn parse_json_values(input: &str) -> Result<Vec<serde_json::Value>> {
use serde_json::Deserializer;

let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}

let mut vals = Vec::new();
let stream = Deserializer::from_str(trimmed).into_iter::<serde_json::Value>();
for result in stream {
let json_input =
result.map_err(|e| Error::Execution(format!("jq: invalid JSON: {}", e)))?;
// THREAT[TM-DOS-027]: Check nesting depth before evaluation
check_json_depth(&json_input, MAX_JQ_JSON_DEPTH).map_err(Error::Execution)?;
vals.push(json_input);
}
Ok(vals)
}
}

/// THREAT[TM-DOS-027]: Check JSON nesting depth to prevent stack overflow
/// during jaq filter evaluation on deeply nested input.
fn check_json_depth(
Expand Down Expand Up @@ -220,34 +244,12 @@ impl Builtin for Jq {
vec![Val::from(serde_json::Value::Null)]
} else if slurp {
// -s flag: read all inputs into a single array
let mut vals = Vec::new();
for line in input.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let json_input: serde_json::Value = serde_json::from_str(line)
.map_err(|e| Error::Execution(format!("jq: invalid JSON: {}", e)))?;
// THREAT[TM-DOS-027]: Check nesting depth before evaluation
check_json_depth(&json_input, MAX_JQ_JSON_DEPTH).map_err(Error::Execution)?;
vals.push(json_input);
}
let vals = Self::parse_json_values(input)?;
vec![Val::from(serde_json::Value::Array(vals))]
} else {
// Process each line of input as JSON
let mut vals = Vec::new();
for line in input.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let json_input: serde_json::Value = serde_json::from_str(line)
.map_err(|e| Error::Execution(format!("jq: invalid JSON: {}", e)))?;
// THREAT[TM-DOS-027]: Check nesting depth before evaluation
check_json_depth(&json_input, MAX_JQ_JSON_DEPTH).map_err(Error::Execution)?;
vals.push(Val::from(json_input));
}
vals
// Parse all JSON values from input (handles multi-line and NDJSON)
let json_vals = Self::parse_json_values(input)?;
json_vals.into_iter().map(Val::from).collect()
};

// Track for -e exit status
Expand Down
7 changes: 5 additions & 2 deletions crates/bashkit/src/builtins/sed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,10 +416,10 @@ fn parse_sed_command(s: &str, extended_regex: bool) -> Result<(Option<Address>,

// Convert sed replacement syntax to regex replacement syntax
// sed uses \1, \2, etc. and & for full match
// regex crate uses $1, $2, etc. and $0 for full match
// regex crate uses ${N} format to avoid ambiguity
let replacement = replacement
.replace("\\&", "\x00") // Temporarily escape literal &
.replace('&', "$0")
.replace('&', "${0}")
.replace("\x00", "&");

// Use ${N} format instead of $N to avoid ambiguity with following chars
Expand All @@ -428,6 +428,9 @@ fn parse_sed_command(s: &str, extended_regex: bool) -> Result<(Option<Address>,
.replace_all(&replacement, r"$${$1}")
.to_string();

// Convert \n → newline, \t → tab in replacement
let replacement = replacement.replace("\\n", "\n").replace("\\t", "\t");

// Parse nth occurrence from flags (e.g., "2" in s/a/b/2)
let nth = flags
.chars()
Expand Down
11 changes: 9 additions & 2 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ impl Interpreter {
Command::Simple(c) => c.span.line(),
Command::Pipeline(c) => c.span.line(),
Command::List(c) => c.span.line(),
Command::Compound(c) => match c {
Command::Compound(c, _) => match c {
CompoundCommand::If(cmd) => cmd.span.line(),
CompoundCommand::For(cmd) => cmd.span.line(),
CompoundCommand::ArithmeticFor(cmd) => cmd.span.line(),
Expand Down Expand Up @@ -433,7 +433,14 @@ impl Interpreter {
Command::Simple(simple) => self.execute_simple_command(simple, None).await,
Command::Pipeline(pipeline) => self.execute_pipeline(pipeline).await,
Command::List(list) => self.execute_list(list).await,
Command::Compound(compound) => self.execute_compound(compound).await,
Command::Compound(compound, redirects) => {
let result = self.execute_compound(compound).await?;
if redirects.is_empty() {
Ok(result)
} else {
self.apply_redirections(result, redirects).await
}
}
Command::Function(func_def) => {
// Store the function definition
self.functions
Expand Down
4 changes: 2 additions & 2 deletions crates/bashkit/src/parser/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ pub enum Command {
/// A command list (e.g., `a && b || c`)
List(CommandList),

/// A compound command (if, for, while, case, etc.)
Compound(CompoundCommand),
/// A compound command (if, for, while, case, etc.) with optional redirections
Compound(CompoundCommand, Vec<Redirect>),

/// A function definition
Function(FunctionDef),
Expand Down
127 changes: 114 additions & 13 deletions crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,109 @@ impl<'a> Parser<'a> {
}
}

/// Parse redirections that follow a compound command (>, >>, 2>, etc.)
fn parse_trailing_redirects(&mut self) -> Vec<Redirect> {
let mut redirects = Vec::new();
loop {
match &self.current_token {
Some(tokens::Token::RedirectOut) => {
self.advance();
if let Ok(target) = self.expect_word() {
redirects.push(Redirect {
fd: None,
kind: RedirectKind::Output,
target,
});
}
}
Some(tokens::Token::RedirectAppend) => {
self.advance();
if let Ok(target) = self.expect_word() {
redirects.push(Redirect {
fd: None,
kind: RedirectKind::Append,
target,
});
}
}
Some(tokens::Token::RedirectIn) => {
self.advance();
if let Ok(target) = self.expect_word() {
redirects.push(Redirect {
fd: None,
kind: RedirectKind::Input,
target,
});
}
}
Some(tokens::Token::RedirectBoth) => {
self.advance();
if let Ok(target) = self.expect_word() {
redirects.push(Redirect {
fd: None,
kind: RedirectKind::OutputBoth,
target,
});
}
}
Some(tokens::Token::DupOutput) => {
self.advance();
if let Ok(target) = self.expect_word() {
redirects.push(Redirect {
fd: Some(1),
kind: RedirectKind::DupOutput,
target,
});
}
}
Some(tokens::Token::RedirectFd(fd)) => {
let fd = *fd;
self.advance();
if let Ok(target) = self.expect_word() {
redirects.push(Redirect {
fd: Some(fd),
kind: RedirectKind::Output,
target,
});
}
}
Some(tokens::Token::RedirectFdAppend(fd)) => {
let fd = *fd;
self.advance();
if let Ok(target) = self.expect_word() {
redirects.push(Redirect {
fd: Some(fd),
kind: RedirectKind::Append,
target,
});
}
}
Some(tokens::Token::DupFd(src_fd, dst_fd)) => {
let src_fd = *src_fd;
let dst_fd = *dst_fd;
self.advance();
redirects.push(Redirect {
fd: Some(src_fd),
kind: RedirectKind::DupOutput,
target: Word::literal(dst_fd.to_string()),
});
}
_ => break,
}
}
redirects
}

/// Parse a compound command and any trailing redirections
fn parse_compound_with_redirects(
&mut self,
parser: impl FnOnce(&mut Self) -> Result<CompoundCommand>,
) -> Result<Option<Command>> {
let compound = parser(self)?;
let redirects = self.parse_trailing_redirects();
Ok(Some(Command::Compound(compound, redirects)))
}

/// Parse a single command (simple or compound)
fn parse_command(&mut self) -> Result<Option<Command>> {
self.skip_newlines()?;
Expand All @@ -319,12 +422,12 @@ impl<'a> Parser<'a> {
if let Some(tokens::Token::Word(w)) = &self.current_token {
let word = w.clone();
match word.as_str() {
"if" => return self.parse_if().map(|c| Some(Command::Compound(c))),
"for" => return self.parse_for().map(|c| Some(Command::Compound(c))),
"while" => return self.parse_while().map(|c| Some(Command::Compound(c))),
"until" => return self.parse_until().map(|c| Some(Command::Compound(c))),
"case" => return self.parse_case().map(|c| Some(Command::Compound(c))),
"time" => return self.parse_time().map(|c| Some(Command::Compound(c))),
"if" => return self.parse_compound_with_redirects(|s| s.parse_if()),
"for" => return self.parse_compound_with_redirects(|s| s.parse_for()),
"while" => return self.parse_compound_with_redirects(|s| s.parse_while()),
"until" => return self.parse_compound_with_redirects(|s| s.parse_until()),
"case" => return self.parse_compound_with_redirects(|s| s.parse_case()),
"time" => return self.parse_compound_with_redirects(|s| s.parse_time()),
"function" => return self.parse_function_keyword().map(Some),
_ => {
// Check for POSIX-style function: name() { body }
Expand All @@ -340,19 +443,17 @@ impl<'a> Parser<'a> {

// Check for arithmetic command ((expression))
if matches!(self.current_token, Some(tokens::Token::DoubleLeftParen)) {
return self
.parse_arithmetic_command()
.map(|c| Some(Command::Compound(c)));
return self.parse_compound_with_redirects(|s| s.parse_arithmetic_command());
}

// Check for subshell
if matches!(self.current_token, Some(tokens::Token::LeftParen)) {
return self.parse_subshell().map(|c| Some(Command::Compound(c)));
return self.parse_compound_with_redirects(|s| s.parse_subshell());
}

// Check for brace group
if matches!(self.current_token, Some(tokens::Token::LeftBrace)) {
return self.parse_brace_group().map(|c| Some(Command::Compound(c)));
return self.parse_compound_with_redirects(|s| s.parse_brace_group());
}

// Default to simple command
Expand Down Expand Up @@ -1009,7 +1110,7 @@ impl<'a> Parser<'a> {

Ok(Command::Function(FunctionDef {
name,
body: Box::new(Command::Compound(body)),
body: Box::new(Command::Compound(body, Vec::new())),
span: start_span.merge(self.current_span),
}))
}
Expand Down Expand Up @@ -1046,7 +1147,7 @@ impl<'a> Parser<'a> {

Ok(Command::Function(FunctionDef {
name,
body: Box::new(Command::Compound(body)),
body: Box::new(Command::Compound(body, Vec::new())),
span: start_span.merge(self.current_span),
}))
}
Expand Down
Loading
Loading