use super::platform::{detect_shell_type, ShellType};
const MAX_COMMAND_LENGTH: usize = 10_000;
const MAX_NODES: usize = 50_000;
const PARSE_TIMEOUT_MS: u64 = 50;
#[derive(Debug, Clone, PartialEq)]
pub enum ParseResult {
Simple { commands: Vec<SimpleCommand> },
TooComplex { reason: String, node_type: String },
ParseUnavailable,
ParseAborted { reason: String },
}
#[derive(Debug, Clone, PartialEq)]
pub struct SimpleCommand {
pub text: String,
pub argv: Vec<String>,
pub env_vars: Vec<(String, String)>,
pub redirects: Vec<Redirect>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Redirect {
pub kind: RedirectKind,
pub target: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RedirectKind {
Stdout, StdoutAppend, Stdin, Stderr, StderrAppend, Both, }
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum VarValue {
Literal(String),
#[allow(dead_code)]
Placeholder,
#[allow(dead_code)] CmdSubPlaceholder,
}
pub(crate) type VarScope = std::collections::HashMap<String, VarValue>;
const PLACEHOLDER_STR: &str = "<PLACEHOLDER>";
const CMDSUB_PLACEHOLDER_STR: &str = "<CMDSUB_PLACEHOLDER>";
fn precheck(command: &str) -> Option<ParseResult> {
if command.len() > MAX_COMMAND_LENGTH {
return Some(ParseResult::ParseAborted {
reason: format!(
"command exceeds max length ({} > {})",
command.len(),
MAX_COMMAND_LENGTH
),
});
}
for c in command.chars() {
if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
return Some(ParseResult::TooComplex {
reason: format!("control character detected: U+{:04X}", c as u32),
node_type: "control_char".into(),
});
}
}
for c in command.chars() {
if is_unicode_whitespace(c) {
return Some(ParseResult::TooComplex {
reason: format!("unicode whitespace detected: U+{:04X}", c as u32),
node_type: "unicode_whitespace".into(),
});
}
}
None
}
fn is_unicode_whitespace(c: char) -> bool {
matches!(
c,
'\u{00A0}' | '\u{2000}'..='\u{200B}' | '\u{202F}' | '\u{205F}' | '\u{3000}' | '\u{FEFF}'
)
}
pub fn parse_for_security(command: &str) -> ParseResult {
if command.is_empty() {
return ParseResult::Simple { commands: vec![] };
}
if let Some(result) = precheck(command) {
return result;
}
let shell_type = detect_shell_type(command);
match shell_type {
ShellType::Bash => parse_bash(command),
ShellType::PowerShell => ParseResult::ParseUnavailable,
ShellType::Cmd => super::cmd_parser::parse_cmd(command),
ShellType::Unknown => ParseResult::ParseUnavailable,
}
}
#[allow(deprecated)] fn parse_bash(command: &str) -> ParseResult {
let result = std::panic::catch_unwind(|| {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_bash::LANGUAGE.into())
.map_err(|_| "language error")?;
parser.set_timeout_micros(PARSE_TIMEOUT_MS * 1_000);
let tree = parser.parse(command, None).ok_or("parse failed")?;
let root = tree.root_node();
Ok::<ParseResult, &str>(collect_commands(command, root))
});
match result {
Ok(Ok(r)) => r,
Ok(Err(_)) => ParseResult::ParseUnavailable,
Err(_) => ParseResult::ParseUnavailable,
}
}
pub(crate) fn collect_commands(source: &str, node: tree_sitter::Node) -> ParseResult {
let mut commands = Vec::new();
let mut scope = VarScope::new();
let mut node_count: usize = 0;
match walk_node(source, node, &mut commands, &mut scope, &mut node_count) {
WalkResult::Ok => ParseResult::Simple { commands },
WalkResult::TooComplex(reason, node_type) => ParseResult::TooComplex { reason, node_type },
}
}
pub(crate) enum WalkResult {
Ok,
TooComplex(String, String),
}
fn is_allowed_node(node_type: &str) -> bool {
matches!(
node_type,
"program"
| "command"
| "command_name"
| "command_argument"
| "variable_assignment"
| "file_redirect"
| "redirect"
| "pipeline"
| "list"
| "string"
| "raw_string"
| "concatenation"
| "simple_expansion"
| "word"
| "number"
| "assignment"
| "assign"
| "env_variable"
| "subscript"
| "extglob_pattern"
| "variable_name"
)
}
fn walk_node(
source: &str,
node: tree_sitter::Node,
commands: &mut Vec<SimpleCommand>,
scope: &mut VarScope,
node_count: &mut usize,
) -> WalkResult {
*node_count += 1;
if *node_count > MAX_NODES {
return WalkResult::TooComplex(
format!("exceeded max node budget ({})", MAX_NODES),
"node_budget".into(),
);
}
let node_type = node.kind();
if !node.is_named() {
return WalkResult::Ok;
}
if is_too_complex_node(node_type) {
return WalkResult::TooComplex(
format!("contains complex structure: {}", node_type),
node_type.to_string(),
);
}
match node_type {
"program" | "list" | "pipeline" => {
let mut cursor = node.walk();
let children: Vec<_> = node.children(&mut cursor).collect();
for (i, child) in children.iter().enumerate() {
if i > 0 {
let prev = children[i - 1];
let sep = if !prev.is_named() {
node_text(prev, source).trim()
} else {
""
};
if sep == "|" || sep == "|&" || sep == "&" {
let mut scope_copy = scope.clone();
match walk_node(source, *child, commands, &mut scope_copy, node_count) {
WalkResult::Ok => {}
other => return other,
}
continue;
}
}
if child.is_named() {
match walk_node(source, *child, commands, scope, node_count) {
WalkResult::Ok => {}
other => return other,
}
}
}
WalkResult::Ok
}
"redirected_statement" => {
let mut cursor = node.walk();
let mut cmd_redirects = Vec::new();
for child in node.children(&mut cursor) {
let child_type = child.kind();
if child_type == "file_redirect" || child_type == "herestring_redirect" {
if let Some(r) = parse_redirect(source, child) {
cmd_redirects.push(r);
}
} else {
match walk_node(source, child, commands, scope, node_count) {
WalkResult::Ok => {}
other => return other,
}
}
}
if let Some(last) = commands.last_mut() {
last.redirects.extend(cmd_redirects);
}
WalkResult::Ok
}
"command" => {
match parse_command_node(source, node, scope) {
Ok(cmd) => {
commands.push(cmd);
WalkResult::Ok
}
Err(e) => e,
}
}
"variable_assignment" => {
let assign_text = node_text(node, source);
if let Some((name, val)) = parse_assignment(assign_text) {
scope.insert(name.clone(), VarValue::Literal(val.clone()));
commands.push(SimpleCommand {
text: assign_text.trim().to_string(),
argv: vec![],
env_vars: vec![(name, val)],
redirects: vec![],
});
}
WalkResult::Ok
}
_ => {
if is_allowed_node(node_type) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match walk_node(source, child, commands, scope, node_count) {
WalkResult::Ok => {}
other => return other,
}
}
WalkResult::Ok
} else {
WalkResult::TooComplex(
format!("unhandled node type: {}", node_type),
node_type.to_string(),
)
}
}
}
}
fn is_too_complex_node(node_type: &str) -> bool {
matches!(
node_type,
"command_substitution"
| "process_substitution"
| "case_item"
| "case_statement"
| "for_statement"
| "while_statement"
| "until_statement"
| "if_statement"
| "function_definition"
| "subshell"
| "heredoc_body"
| "heredoc_start"
| "heredoc"
| "declaration_command"
| "unset_command"
| "test_command"
| "negated_command"
| "c_style_for_statement"
| "compound_command"
| "arithmetic_expansion"
)
}
pub(crate) fn node_text<'a>(node: tree_sitter::Node, source: &'a str) -> &'a str {
node.utf8_text(source.as_bytes()).unwrap_or("")
}
fn parse_command_node(
source: &str,
node: tree_sitter::Node,
scope: &mut VarScope,
) -> Result<SimpleCommand, WalkResult> {
let mut argv = Vec::new();
let mut env_vars = Vec::new();
let mut redirects = Vec::new();
let text = node_text(node, source).trim().to_string();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let child_type = child.kind();
match child_type {
"variable_assignment" | "assignment" | "env_variable" => {
let assign_text = node_text(child, source);
if let Some((name, val)) = parse_assignment(assign_text) {
scope.insert(name.clone(), VarValue::Literal(val.clone()));
env_vars.push((name, val));
}
}
"command_name" | "command_argument" | "word" | "raw_string" | "number" => {
let text = extract_text(source, child);
if !text.is_empty() {
argv.push(text);
}
}
"string" | "concatenation" => {
let text = extract_text(source, child);
if !text.is_empty() {
argv.push(text);
}
}
"simple_expansion" => {
let var_name = extract_var_name(source, child);
let resolved = scope.get(&var_name);
match resolved {
Some(VarValue::Literal(v)) => {
if is_bare_var_unsafe(v) {
return Err(WalkResult::TooComplex(
format!("variable value unsafe for bare expansion: ${}", var_name),
"simple_expansion".into(),
));
}
argv.push(v.clone());
}
Some(VarValue::Placeholder) => {
argv.push(PLACEHOLDER_STR.to_string());
}
Some(VarValue::CmdSubPlaceholder) => {
argv.push(CMDSUB_PLACEHOLDER_STR.to_string());
}
None => {
let text = extract_text(source, child);
if !text.is_empty() {
argv.push(text);
}
}
}
}
"file_redirect" | "redirect" => {
if let Some(redirect) = parse_redirect(source, child) {
redirects.push(redirect);
}
}
_ => {
if is_too_complex_node(child_type) {
return Err(WalkResult::TooComplex(
format!("contains complex structure: {}", child_type),
child_type.to_string(),
));
}
if is_allowed_node(child_type) {
let text = extract_text(source, child);
if !text.is_empty() {
argv.push(text);
}
} else {
return Err(WalkResult::TooComplex(
format!("unhandled child node: {}", child_type),
child_type.to_string(),
));
}
}
}
}
if argv
.iter()
.any(|arg| arg == PLACEHOLDER_STR || arg == CMDSUB_PLACEHOLDER_STR)
{
return Err(WalkResult::TooComplex(
"unresolved variable placeholder in arguments".into(),
"simple_expansion".into(),
));
}
if let Some(first) = argv.first() {
if first.starts_with('$') {
return Err(WalkResult::TooComplex(
format!("unresolved variable in command name: {first}"),
"simple_expansion".into(),
));
}
}
Ok(SimpleCommand {
text,
argv,
env_vars,
redirects,
})
}
fn extract_var_name(source: &str, node: tree_sitter::Node) -> String {
let text = node_text(node, source);
if let Some(inner) = text.strip_prefix("${") {
if let Some(inner) = inner.strip_suffix('}') {
return inner.to_string();
}
}
if let Some(name) = text.strip_prefix('$') {
return name.to_string();
}
text.to_string()
}
fn is_bare_var_unsafe(value: &str) -> bool {
value.contains(' ')
|| value.contains('\t')
|| value.contains('\n')
|| value.contains('*')
|| value.contains('?')
|| value.contains('[')
}
pub(crate) fn extract_text(source: &str, node: tree_sitter::Node) -> String {
let node_type = node.kind();
if node_type == "command_name" || node_type == "word" {
let mut cursor = node.walk();
let mut result = String::new();
for child in node.children(&mut cursor) {
if child.is_named() {
result.push_str(&extract_text(source, child));
}
}
if result.is_empty() {
return node_text(node, source).trim().to_string();
}
return result;
}
let raw = node_text(node, source);
match node_type {
"string" | "raw_string" => {
let trimmed = raw.trim();
if trimmed.len() >= 2 {
let first = trimmed.chars().next().unwrap();
let last = trimmed.chars().last().unwrap();
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
return trimmed[1..trimmed.len() - 1].to_string();
}
}
raw.to_string()
}
"concatenation" => {
let mut result = String::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
result.push_str(&extract_text(source, child));
}
result
}
"simple_expansion" => {
raw.to_string()
}
_ => raw.trim().to_string(),
}
}
pub(crate) fn parse_assignment(text: &str) -> Option<(String, String)> {
let text = text.trim();
if let Some(eq_pos) = text.find('=') {
let name = text[..eq_pos].trim().to_string();
let mut val = text[eq_pos + 1..].trim().to_string();
if val.len() >= 2 {
let first = val.chars().next().unwrap();
let last = val.chars().last().unwrap();
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
val = val[1..val.len() - 1].to_string();
}
}
if !name.is_empty() {
return Some((name, val));
}
}
None
}
pub(crate) fn parse_redirect(source: &str, node: tree_sitter::Node) -> Option<Redirect> {
let text = node_text(node, source).trim();
let mut rest = text;
let mut fd: Option<u32> = None;
let digit_end = rest.chars().take_while(|c| c.is_ascii_digit()).count();
if digit_end > 0 {
if let Some(next) = rest.chars().nth(digit_end) {
if matches!(next, '>' | '<') {
fd = rest[..digit_end].parse().ok();
rest = &rest[digit_end..];
}
}
}
let op_len = rest
.chars()
.take_while(|c| matches!(c, '<' | '>' | '&'))
.count();
if op_len == 0 {
return None;
}
let op = &rest[..op_len];
let target = rest[op_len..].trim().to_string();
let kind = if op.contains('<') && op != "<>" {
RedirectKind::Stdin
} else {
match op {
">>" => {
if fd == Some(2) {
RedirectKind::StderrAppend
} else {
RedirectKind::StdoutAppend
}
}
"&>" | ">&" => RedirectKind::Both,
"<>" => RedirectKind::Stdout,
_ => {
if fd == Some(2) {
RedirectKind::Stderr
} else {
RedirectKind::Stdout
}
}
}
};
Some(Redirect { kind, target })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_command() {
let r = parse_for_security("ls -la");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].argv, vec!["ls", "-la"]);
}
_ => panic!("expected Simple, got {:?}", r),
}
}
#[test]
fn test_compound_and() {
let r = parse_for_security("ls && git push");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].argv, vec!["ls"]);
assert_eq!(commands[1].argv, vec!["git", "push"]);
}
_ => panic!("expected Simple, got {:?}", r),
}
}
#[test]
fn test_compound_pipe_semicolon() {
let r = parse_for_security("a | b; c");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 3);
}
_ => panic!("expected Simple, got {:?}", r),
}
}
#[test]
fn test_compound_or() {
let r = parse_for_security("a || b");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 2);
}
_ => panic!("expected Simple, got {:?}", r),
}
}
#[test]
fn test_empty_command() {
let r = parse_for_security("");
assert!(matches!(r, ParseResult::Simple { commands } if commands.is_empty()));
}
#[test]
fn test_command_substitution() {
let r = parse_for_security("echo $(whoami)");
match r {
ParseResult::TooComplex { node_type, .. } => {
assert_eq!(node_type, "command_substitution");
}
_ => panic!("expected TooComplex, got {:?}", r),
}
}
#[test]
fn test_env_var_prefix() {
let r = parse_for_security("FOO=bar ls");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].argv, vec!["ls"]);
assert_eq!(
commands[0].env_vars,
vec![("FOO".to_string(), "bar".to_string())]
);
}
_ => panic!("expected Simple, got {:?}", r),
}
}
#[test]
fn test_redirect() {
let r = parse_for_security("ls > file.txt");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 1);
assert!(!commands[0].redirects.is_empty());
}
_ => panic!("expected Simple, got {:?}", r),
}
}
#[test]
fn test_for_loop() {
let r = parse_for_security("for i in 1 2 3; do echo $i; done");
assert!(matches!(r, ParseResult::TooComplex { .. }));
}
#[test]
fn test_if_statement() {
let r = parse_for_security("if true; then echo yes; fi");
assert!(matches!(r, ParseResult::TooComplex { .. }));
}
#[test]
fn test_while_loop() {
let r = parse_for_security("while true; do echo hi; done");
assert!(matches!(r, ParseResult::TooComplex { .. }));
}
#[test]
fn test_function_definition() {
let r = parse_for_security("foo() { echo bar; }");
assert!(matches!(r, ParseResult::TooComplex { .. }));
}
#[test]
fn test_subshell() {
let r = parse_for_security("(cd /tmp && ls)");
assert!(matches!(r, ParseResult::TooComplex { .. }));
}
#[test]
fn test_pipeline() {
let r = parse_for_security("cat file | grep pattern | head -10");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 3);
assert_eq!(commands[0].argv[0], "cat");
assert_eq!(commands[1].argv[0], "grep");
assert_eq!(commands[2].argv[0], "head");
}
_ => panic!("expected Simple, got {:?}", r),
}
}
#[test]
fn test_quoted_string() {
let r = parse_for_security("echo 'hello world'");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].argv, vec!["echo", "hello world"]);
}
_ => panic!("expected Simple, got {:?}", r),
}
}
#[test]
fn test_double_quoted_string() {
let r = parse_for_security("echo \"hello world\"");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].argv, vec!["echo", "hello world"]);
}
_ => panic!("expected Simple, got {:?}", r),
}
}
#[test]
fn test_b04_max_command_length() {
let long_cmd = "echo ".to_string() + &"a".repeat(MAX_COMMAND_LENGTH);
let r = parse_for_security(&long_cmd);
assert!(
matches!(r, ParseResult::ParseAborted { .. }),
"expected ParseAborted, got {:?}",
r
);
}
#[test]
fn test_b04_control_char() {
let cmd = "echo \x01hello";
let r = parse_for_security(cmd);
match r {
ParseResult::TooComplex { node_type, .. } => {
assert_eq!(node_type, "control_char");
}
_ => panic!("expected TooComplex, got {:?}", r),
}
}
#[test]
fn test_b04_unicode_whitespace() {
let cmd = "echo\u{00A0}hello";
let r = parse_for_security(cmd);
match r {
ParseResult::TooComplex { node_type, .. } => {
assert_eq!(node_type, "unicode_whitespace");
}
_ => panic!("expected TooComplex, got {:?}", r),
}
}
#[test]
fn test_b03_var_static_value() {
let r = parse_for_security("FOO=bar; echo $FOO");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 2);
assert_eq!(commands[1].argv, vec!["echo", "bar"]);
}
_ => panic!("expected Simple, got {:?}", r),
}
}
#[test]
fn test_b03_var_and_inherit() {
let r = parse_for_security("X=hello && echo $X");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 2);
assert_eq!(commands[1].argv, vec!["echo", "hello"]);
}
_ => panic!("expected Simple, got {:?}", r),
}
}
#[test]
fn test_b03_var_pipe_no_inherit() {
let r = parse_for_security("true | X=hello; echo $X");
match r {
ParseResult::Simple { commands } => {
assert_eq!(commands.len(), 3);
assert_eq!(commands[2].argv, vec!["echo", "$X"]);
}
_ => panic!("expected Simple, got {:?}", r),
}
}
}