shell_command_parser.rs

   1use brush_parser::ast;
   2use brush_parser::word::WordPiece;
   3use brush_parser::{Parser, ParserOptions, SourceInfo};
   4use std::io::BufReader;
   5
   6pub fn extract_commands(command: &str) -> Option<Vec<String>> {
   7    let reader = BufReader::new(command.as_bytes());
   8    let options = ParserOptions::default();
   9    let source_info = SourceInfo::default();
  10    let mut parser = Parser::new(reader, &options, &source_info);
  11
  12    let program = parser.parse_program().ok()?;
  13
  14    let mut commands = Vec::new();
  15    extract_commands_from_program(&program, &mut commands)?;
  16
  17    Some(commands)
  18}
  19
  20fn extract_commands_from_program(program: &ast::Program, commands: &mut Vec<String>) -> Option<()> {
  21    for complete_command in &program.complete_commands {
  22        extract_commands_from_compound_list(complete_command, commands)?;
  23    }
  24    Some(())
  25}
  26
  27fn extract_commands_from_compound_list(
  28    compound_list: &ast::CompoundList,
  29    commands: &mut Vec<String>,
  30) -> Option<()> {
  31    for item in &compound_list.0 {
  32        extract_commands_from_and_or_list(&item.0, commands)?;
  33    }
  34    Some(())
  35}
  36
  37fn extract_commands_from_and_or_list(
  38    and_or_list: &ast::AndOrList,
  39    commands: &mut Vec<String>,
  40) -> Option<()> {
  41    extract_commands_from_pipeline(&and_or_list.first, commands)?;
  42
  43    for and_or in &and_or_list.additional {
  44        match and_or {
  45            ast::AndOr::And(pipeline) | ast::AndOr::Or(pipeline) => {
  46                extract_commands_from_pipeline(pipeline, commands)?;
  47            }
  48        }
  49    }
  50    Some(())
  51}
  52
  53fn extract_commands_from_pipeline(
  54    pipeline: &ast::Pipeline,
  55    commands: &mut Vec<String>,
  56) -> Option<()> {
  57    for command in &pipeline.seq {
  58        extract_commands_from_command(command, commands)?;
  59    }
  60    Some(())
  61}
  62
  63fn extract_commands_from_command(command: &ast::Command, commands: &mut Vec<String>) -> Option<()> {
  64    match command {
  65        ast::Command::Simple(simple_command) => {
  66            extract_commands_from_simple_command(simple_command, commands)?;
  67        }
  68        ast::Command::Compound(compound_command, redirect_list) => {
  69            let body_start = extract_commands_from_compound_command(compound_command, commands)?;
  70            if let Some(redirect_list) = redirect_list {
  71                let mut normalized_redirects = Vec::new();
  72                for redirect in &redirect_list.0 {
  73                    match normalize_io_redirect(redirect)? {
  74                        RedirectNormalization::Normalized(s) => normalized_redirects.push(s),
  75                        RedirectNormalization::Skip => {}
  76                    }
  77                }
  78                if !normalized_redirects.is_empty() {
  79                    if body_start >= commands.len() {
  80                        return None;
  81                    }
  82                    commands.extend(normalized_redirects);
  83                }
  84                for redirect in &redirect_list.0 {
  85                    extract_commands_from_io_redirect(redirect, commands)?;
  86                }
  87            }
  88        }
  89        ast::Command::Function(func_def) => {
  90            extract_commands_from_function_body(&func_def.body, commands)?;
  91        }
  92        ast::Command::ExtendedTest(test_expr) => {
  93            extract_commands_from_extended_test_expr(test_expr, commands)?;
  94        }
  95    }
  96    Some(())
  97}
  98
  99enum RedirectNormalization {
 100    Normalized(String),
 101    Skip,
 102}
 103
 104fn extract_commands_from_simple_command(
 105    simple_command: &ast::SimpleCommand,
 106    commands: &mut Vec<String>,
 107) -> Option<()> {
 108    // Build a normalized command string from individual words, stripping shell
 109    // quotes so that security patterns match regardless of quoting style.
 110    // For example, both `rm -rf '/'` and `rm -rf /` normalize to "rm -rf /".
 111    //
 112    // If any word fails to normalize, we return None so that `extract_commands`
 113    // returns None — the same as a shell parse failure. The caller then falls
 114    // back to raw-input matching with always_allow disabled.
 115    let mut words = Vec::new();
 116    let mut redirects = Vec::new();
 117
 118    if let Some(prefix) = &simple_command.prefix {
 119        for item in &prefix.0 {
 120            if let ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) = item {
 121                match normalize_io_redirect(redirect) {
 122                    Some(RedirectNormalization::Normalized(s)) => redirects.push(s),
 123                    Some(RedirectNormalization::Skip) => {}
 124                    None => return None,
 125                }
 126            }
 127        }
 128    }
 129    if let Some(word) = &simple_command.word_or_name {
 130        words.push(normalize_word(word)?);
 131    }
 132    if let Some(suffix) = &simple_command.suffix {
 133        for item in &suffix.0 {
 134            match item {
 135                ast::CommandPrefixOrSuffixItem::Word(word) => {
 136                    words.push(normalize_word(word)?);
 137                }
 138                ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => {
 139                    match normalize_io_redirect(redirect) {
 140                        Some(RedirectNormalization::Normalized(s)) => redirects.push(s),
 141                        Some(RedirectNormalization::Skip) => {}
 142                        None => return None,
 143                    }
 144                }
 145                _ => {}
 146            }
 147        }
 148    }
 149
 150    if words.is_empty() && !redirects.is_empty() {
 151        return None;
 152    }
 153
 154    let command_str = words.join(" ");
 155    if !command_str.is_empty() {
 156        commands.push(command_str);
 157    }
 158    commands.extend(redirects);
 159
 160    // Extract nested commands from command substitutions, process substitutions, etc.
 161    if let Some(prefix) = &simple_command.prefix {
 162        extract_commands_from_command_prefix(prefix, commands)?;
 163    }
 164    if let Some(word) = &simple_command.word_or_name {
 165        extract_commands_from_word(word, commands)?;
 166    }
 167    if let Some(suffix) = &simple_command.suffix {
 168        extract_commands_from_command_suffix(suffix, commands)?;
 169    }
 170    Some(())
 171}
 172
 173/// Normalizes a shell word by stripping quoting syntax and returning the
 174/// semantic (unquoted) value. Returns `None` if word parsing fails.
 175fn normalize_word(word: &ast::Word) -> Option<String> {
 176    let options = ParserOptions::default();
 177    let pieces = brush_parser::word::parse(&word.value, &options).ok()?;
 178    let mut result = String::new();
 179    for piece_with_source in &pieces {
 180        normalize_word_piece_into(
 181            &piece_with_source.piece,
 182            &word.value,
 183            piece_with_source.start_index,
 184            piece_with_source.end_index,
 185            &mut result,
 186        )?;
 187    }
 188    Some(result)
 189}
 190
 191fn normalize_word_piece_into(
 192    piece: &WordPiece,
 193    raw_value: &str,
 194    start_index: usize,
 195    end_index: usize,
 196    result: &mut String,
 197) -> Option<()> {
 198    match piece {
 199        WordPiece::Text(text) => result.push_str(text),
 200        WordPiece::SingleQuotedText(text) => result.push_str(text),
 201        WordPiece::AnsiCQuotedText(text) => result.push_str(text),
 202        WordPiece::EscapeSequence(text) => {
 203            result.push_str(text.strip_prefix('\\').unwrap_or(text));
 204        }
 205        WordPiece::DoubleQuotedSequence(pieces)
 206        | WordPiece::GettextDoubleQuotedSequence(pieces) => {
 207            for inner in pieces {
 208                normalize_word_piece_into(
 209                    &inner.piece,
 210                    raw_value,
 211                    inner.start_index,
 212                    inner.end_index,
 213                    result,
 214                )?;
 215            }
 216        }
 217        WordPiece::TildePrefix(prefix) => {
 218            result.push('~');
 219            result.push_str(prefix);
 220        }
 221        // For parameter expansions, command substitutions, and arithmetic expressions,
 222        // preserve the original source text so that patterns like `\$HOME` continue
 223        // to match.
 224        WordPiece::ParameterExpansion(_)
 225        | WordPiece::CommandSubstitution(_)
 226        | WordPiece::BackquotedCommandSubstitution(_)
 227        | WordPiece::ArithmeticExpression(_) => {
 228            let source = raw_value.get(start_index..end_index)?;
 229            result.push_str(source);
 230        }
 231    }
 232    Some(())
 233}
 234
 235fn is_known_safe_redirect_target(normalized_target: &str) -> bool {
 236    normalized_target == "/dev/null"
 237}
 238
 239fn normalize_io_redirect(redirect: &ast::IoRedirect) -> Option<RedirectNormalization> {
 240    match redirect {
 241        ast::IoRedirect::File(fd, kind, target) => {
 242            let target_word = match target {
 243                ast::IoFileRedirectTarget::Filename(word) => word,
 244                _ => return Some(RedirectNormalization::Skip),
 245            };
 246            let operator = match kind {
 247                ast::IoFileRedirectKind::Read => "<",
 248                ast::IoFileRedirectKind::Write => ">",
 249                ast::IoFileRedirectKind::Append => ">>",
 250                ast::IoFileRedirectKind::ReadAndWrite => "<>",
 251                ast::IoFileRedirectKind::Clobber => ">|",
 252                // The parser pairs DuplicateInput/DuplicateOutput with
 253                // IoFileRedirectTarget::Duplicate (not Filename), so the
 254                // target match above will return Skip before we reach here.
 255                // These arms are kept for defensiveness.
 256                ast::IoFileRedirectKind::DuplicateInput => "<&",
 257                ast::IoFileRedirectKind::DuplicateOutput => ">&",
 258            };
 259            let fd_prefix = match fd {
 260                Some(fd) => fd.to_string(),
 261                None => String::new(),
 262            };
 263            let normalized = normalize_word(target_word)?;
 264            if is_known_safe_redirect_target(&normalized) {
 265                return Some(RedirectNormalization::Skip);
 266            }
 267            Some(RedirectNormalization::Normalized(format!(
 268                "{}{} {}",
 269                fd_prefix, operator, normalized
 270            )))
 271        }
 272        ast::IoRedirect::OutputAndError(word, append) => {
 273            let operator = if *append { "&>>" } else { "&>" };
 274            let normalized = normalize_word(word)?;
 275            if is_known_safe_redirect_target(&normalized) {
 276                return Some(RedirectNormalization::Skip);
 277            }
 278            Some(RedirectNormalization::Normalized(format!(
 279                "{} {}",
 280                operator, normalized
 281            )))
 282        }
 283        ast::IoRedirect::HereDocument(_, _) | ast::IoRedirect::HereString(_, _) => {
 284            Some(RedirectNormalization::Skip)
 285        }
 286    }
 287}
 288
 289fn extract_commands_from_command_prefix(
 290    prefix: &ast::CommandPrefix,
 291    commands: &mut Vec<String>,
 292) -> Option<()> {
 293    for item in &prefix.0 {
 294        extract_commands_from_prefix_or_suffix_item(item, commands)?;
 295    }
 296    Some(())
 297}
 298
 299fn extract_commands_from_command_suffix(
 300    suffix: &ast::CommandSuffix,
 301    commands: &mut Vec<String>,
 302) -> Option<()> {
 303    for item in &suffix.0 {
 304        extract_commands_from_prefix_or_suffix_item(item, commands)?;
 305    }
 306    Some(())
 307}
 308
 309fn extract_commands_from_prefix_or_suffix_item(
 310    item: &ast::CommandPrefixOrSuffixItem,
 311    commands: &mut Vec<String>,
 312) -> Option<()> {
 313    match item {
 314        ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => {
 315            extract_commands_from_io_redirect(redirect, commands)?;
 316        }
 317        ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, _word) => {
 318            extract_commands_from_assignment(assignment, commands)?;
 319        }
 320        ast::CommandPrefixOrSuffixItem::Word(word) => {
 321            extract_commands_from_word(word, commands)?;
 322        }
 323        ast::CommandPrefixOrSuffixItem::ProcessSubstitution(_kind, subshell) => {
 324            extract_commands_from_compound_list(&subshell.list, commands)?;
 325        }
 326    }
 327    Some(())
 328}
 329
 330fn extract_commands_from_io_redirect(
 331    redirect: &ast::IoRedirect,
 332    commands: &mut Vec<String>,
 333) -> Option<()> {
 334    match redirect {
 335        ast::IoRedirect::File(_fd, _kind, target) => match target {
 336            ast::IoFileRedirectTarget::ProcessSubstitution(_kind, subshell) => {
 337                extract_commands_from_compound_list(&subshell.list, commands)?;
 338            }
 339            ast::IoFileRedirectTarget::Filename(word) => {
 340                extract_commands_from_word(word, commands)?;
 341            }
 342            _ => {}
 343        },
 344        ast::IoRedirect::HereDocument(_fd, here_doc) => {
 345            if here_doc.requires_expansion {
 346                extract_commands_from_word(&here_doc.doc, commands)?;
 347            }
 348        }
 349        ast::IoRedirect::HereString(_fd, word) => {
 350            extract_commands_from_word(word, commands)?;
 351        }
 352        ast::IoRedirect::OutputAndError(word, _) => {
 353            extract_commands_from_word(word, commands)?;
 354        }
 355    }
 356    Some(())
 357}
 358
 359fn extract_commands_from_assignment(
 360    assignment: &ast::Assignment,
 361    commands: &mut Vec<String>,
 362) -> Option<()> {
 363    match &assignment.value {
 364        ast::AssignmentValue::Scalar(word) => {
 365            extract_commands_from_word(word, commands)?;
 366        }
 367        ast::AssignmentValue::Array(words) => {
 368            for (opt_word, word) in words {
 369                if let Some(w) = opt_word {
 370                    extract_commands_from_word(w, commands)?;
 371                }
 372                extract_commands_from_word(word, commands)?;
 373            }
 374        }
 375    }
 376    Some(())
 377}
 378
 379fn extract_commands_from_word(word: &ast::Word, commands: &mut Vec<String>) -> Option<()> {
 380    let options = ParserOptions::default();
 381    let pieces = brush_parser::word::parse(&word.value, &options).ok()?;
 382    for piece_with_source in pieces {
 383        extract_commands_from_word_piece(&piece_with_source.piece, commands)?;
 384    }
 385    Some(())
 386}
 387
 388fn extract_commands_from_word_piece(piece: &WordPiece, commands: &mut Vec<String>) -> Option<()> {
 389    match piece {
 390        WordPiece::CommandSubstitution(cmd_str)
 391        | WordPiece::BackquotedCommandSubstitution(cmd_str) => {
 392            let nested_commands = extract_commands(cmd_str)?;
 393            commands.extend(nested_commands);
 394        }
 395        WordPiece::DoubleQuotedSequence(pieces)
 396        | WordPiece::GettextDoubleQuotedSequence(pieces) => {
 397            for inner_piece_with_source in pieces {
 398                extract_commands_from_word_piece(&inner_piece_with_source.piece, commands)?;
 399            }
 400        }
 401        WordPiece::EscapeSequence(_)
 402        | WordPiece::SingleQuotedText(_)
 403        | WordPiece::Text(_)
 404        | WordPiece::AnsiCQuotedText(_)
 405        | WordPiece::TildePrefix(_)
 406        | WordPiece::ParameterExpansion(_)
 407        | WordPiece::ArithmeticExpression(_) => {}
 408    }
 409    Some(())
 410}
 411
 412fn extract_commands_from_compound_command(
 413    compound_command: &ast::CompoundCommand,
 414    commands: &mut Vec<String>,
 415) -> Option<usize> {
 416    match compound_command {
 417        ast::CompoundCommand::BraceGroup(brace_group) => {
 418            let body_start = commands.len();
 419            extract_commands_from_compound_list(&brace_group.list, commands)?;
 420            Some(body_start)
 421        }
 422        ast::CompoundCommand::Subshell(subshell) => {
 423            let body_start = commands.len();
 424            extract_commands_from_compound_list(&subshell.list, commands)?;
 425            Some(body_start)
 426        }
 427        ast::CompoundCommand::ForClause(for_clause) => {
 428            if let Some(words) = &for_clause.values {
 429                for word in words {
 430                    extract_commands_from_word(word, commands)?;
 431                }
 432            }
 433            let body_start = commands.len();
 434            extract_commands_from_do_group(&for_clause.body, commands)?;
 435            Some(body_start)
 436        }
 437        ast::CompoundCommand::CaseClause(case_clause) => {
 438            extract_commands_from_word(&case_clause.value, commands)?;
 439            let body_start = commands.len();
 440            for item in &case_clause.cases {
 441                if let Some(body) = &item.cmd {
 442                    extract_commands_from_compound_list(body, commands)?;
 443                }
 444            }
 445            Some(body_start)
 446        }
 447        ast::CompoundCommand::IfClause(if_clause) => {
 448            extract_commands_from_compound_list(&if_clause.condition, commands)?;
 449            let body_start = commands.len();
 450            extract_commands_from_compound_list(&if_clause.then, commands)?;
 451            if let Some(elses) = &if_clause.elses {
 452                for else_item in elses {
 453                    if let Some(condition) = &else_item.condition {
 454                        extract_commands_from_compound_list(condition, commands)?;
 455                    }
 456                    extract_commands_from_compound_list(&else_item.body, commands)?;
 457                }
 458            }
 459            Some(body_start)
 460        }
 461        ast::CompoundCommand::WhileClause(while_clause)
 462        | ast::CompoundCommand::UntilClause(while_clause) => {
 463            extract_commands_from_compound_list(&while_clause.0, commands)?;
 464            let body_start = commands.len();
 465            extract_commands_from_do_group(&while_clause.1, commands)?;
 466            Some(body_start)
 467        }
 468        ast::CompoundCommand::ArithmeticForClause(arith_for) => {
 469            let body_start = commands.len();
 470            extract_commands_from_do_group(&arith_for.body, commands)?;
 471            Some(body_start)
 472        }
 473        ast::CompoundCommand::Arithmetic(_arith_cmd) => Some(commands.len()),
 474    }
 475}
 476
 477fn extract_commands_from_do_group(
 478    do_group: &ast::DoGroupCommand,
 479    commands: &mut Vec<String>,
 480) -> Option<()> {
 481    extract_commands_from_compound_list(&do_group.list, commands)
 482}
 483
 484fn extract_commands_from_function_body(
 485    func_body: &ast::FunctionBody,
 486    commands: &mut Vec<String>,
 487) -> Option<()> {
 488    let body_start = extract_commands_from_compound_command(&func_body.0, commands)?;
 489    if let Some(redirect_list) = &func_body.1 {
 490        let mut normalized_redirects = Vec::new();
 491        for redirect in &redirect_list.0 {
 492            match normalize_io_redirect(redirect)? {
 493                RedirectNormalization::Normalized(s) => normalized_redirects.push(s),
 494                RedirectNormalization::Skip => {}
 495            }
 496        }
 497        if !normalized_redirects.is_empty() {
 498            if body_start >= commands.len() {
 499                return None;
 500            }
 501            commands.extend(normalized_redirects);
 502        }
 503        for redirect in &redirect_list.0 {
 504            extract_commands_from_io_redirect(redirect, commands)?;
 505        }
 506    }
 507    Some(())
 508}
 509
 510fn extract_commands_from_extended_test_expr(
 511    test_expr: &ast::ExtendedTestExprCommand,
 512    commands: &mut Vec<String>,
 513) -> Option<()> {
 514    extract_commands_from_extended_test_expr_inner(&test_expr.expr, commands)
 515}
 516
 517fn extract_commands_from_extended_test_expr_inner(
 518    expr: &ast::ExtendedTestExpr,
 519    commands: &mut Vec<String>,
 520) -> Option<()> {
 521    match expr {
 522        ast::ExtendedTestExpr::Not(inner) => {
 523            extract_commands_from_extended_test_expr_inner(inner, commands)?;
 524        }
 525        ast::ExtendedTestExpr::And(left, right) | ast::ExtendedTestExpr::Or(left, right) => {
 526            extract_commands_from_extended_test_expr_inner(left, commands)?;
 527            extract_commands_from_extended_test_expr_inner(right, commands)?;
 528        }
 529        ast::ExtendedTestExpr::Parenthesized(inner) => {
 530            extract_commands_from_extended_test_expr_inner(inner, commands)?;
 531        }
 532        ast::ExtendedTestExpr::UnaryTest(_, word) => {
 533            extract_commands_from_word(word, commands)?;
 534        }
 535        ast::ExtendedTestExpr::BinaryTest(_, word1, word2) => {
 536            extract_commands_from_word(word1, commands)?;
 537            extract_commands_from_word(word2, commands)?;
 538        }
 539    }
 540    Some(())
 541}
 542
 543#[cfg(test)]
 544mod tests {
 545    use super::*;
 546
 547    #[test]
 548    fn test_simple_command() {
 549        let commands = extract_commands("ls").expect("parse failed");
 550        assert_eq!(commands, vec!["ls"]);
 551    }
 552
 553    #[test]
 554    fn test_command_with_args() {
 555        let commands = extract_commands("ls -la /tmp").expect("parse failed");
 556        assert_eq!(commands, vec!["ls -la /tmp"]);
 557    }
 558
 559    #[test]
 560    fn test_single_quoted_argument_is_normalized() {
 561        let commands = extract_commands("rm -rf '/'").expect("parse failed");
 562        assert_eq!(commands, vec!["rm -rf /"]);
 563    }
 564
 565    #[test]
 566    fn test_single_quoted_command_name_is_normalized() {
 567        let commands = extract_commands("'rm' -rf /").expect("parse failed");
 568        assert_eq!(commands, vec!["rm -rf /"]);
 569    }
 570
 571    #[test]
 572    fn test_double_quoted_argument_is_normalized() {
 573        let commands = extract_commands("rm -rf \"/\"").expect("parse failed");
 574        assert_eq!(commands, vec!["rm -rf /"]);
 575    }
 576
 577    #[test]
 578    fn test_double_quoted_command_name_is_normalized() {
 579        let commands = extract_commands("\"rm\" -rf /").expect("parse failed");
 580        assert_eq!(commands, vec!["rm -rf /"]);
 581    }
 582
 583    #[test]
 584    fn test_escaped_argument_is_normalized() {
 585        let commands = extract_commands("rm -rf \\/").expect("parse failed");
 586        assert_eq!(commands, vec!["rm -rf /"]);
 587    }
 588
 589    #[test]
 590    fn test_partial_quoting_command_name_is_normalized() {
 591        let commands = extract_commands("r'm' -rf /").expect("parse failed");
 592        assert_eq!(commands, vec!["rm -rf /"]);
 593    }
 594
 595    #[test]
 596    fn test_partial_quoting_flag_is_normalized() {
 597        let commands = extract_commands("rm -r'f' /").expect("parse failed");
 598        assert_eq!(commands, vec!["rm -rf /"]);
 599    }
 600
 601    #[test]
 602    fn test_quoted_bypass_in_chained_command() {
 603        let commands = extract_commands("ls && 'rm' -rf '/'").expect("parse failed");
 604        assert_eq!(commands, vec!["ls", "rm -rf /"]);
 605    }
 606
 607    #[test]
 608    fn test_tilde_preserved_after_normalization() {
 609        let commands = extract_commands("rm -rf ~").expect("parse failed");
 610        assert_eq!(commands, vec!["rm -rf ~"]);
 611    }
 612
 613    #[test]
 614    fn test_quoted_tilde_normalized() {
 615        let commands = extract_commands("rm -rf '~'").expect("parse failed");
 616        assert_eq!(commands, vec!["rm -rf ~"]);
 617    }
 618
 619    #[test]
 620    fn test_parameter_expansion_preserved() {
 621        let commands = extract_commands("rm -rf $HOME").expect("parse failed");
 622        assert_eq!(commands, vec!["rm -rf $HOME"]);
 623    }
 624
 625    #[test]
 626    fn test_braced_parameter_expansion_preserved() {
 627        let commands = extract_commands("rm -rf ${HOME}").expect("parse failed");
 628        assert_eq!(commands, vec!["rm -rf ${HOME}"]);
 629    }
 630
 631    #[test]
 632    fn test_and_operator() {
 633        let commands = extract_commands("ls && rm -rf /").expect("parse failed");
 634        assert_eq!(commands, vec!["ls", "rm -rf /"]);
 635    }
 636
 637    #[test]
 638    fn test_or_operator() {
 639        let commands = extract_commands("ls || rm -rf /").expect("parse failed");
 640        assert_eq!(commands, vec!["ls", "rm -rf /"]);
 641    }
 642
 643    #[test]
 644    fn test_semicolon() {
 645        let commands = extract_commands("ls; rm -rf /").expect("parse failed");
 646        assert_eq!(commands, vec!["ls", "rm -rf /"]);
 647    }
 648
 649    #[test]
 650    fn test_pipe() {
 651        let commands = extract_commands("ls | xargs rm -rf").expect("parse failed");
 652        assert_eq!(commands, vec!["ls", "xargs rm -rf"]);
 653    }
 654
 655    #[test]
 656    fn test_background() {
 657        let commands = extract_commands("ls & rm -rf /").expect("parse failed");
 658        assert_eq!(commands, vec!["ls", "rm -rf /"]);
 659    }
 660
 661    #[test]
 662    fn test_command_substitution_dollar() {
 663        let commands = extract_commands("echo $(whoami)").expect("parse failed");
 664        assert!(commands.iter().any(|c| c.contains("echo")));
 665        assert!(commands.contains(&"whoami".to_string()));
 666    }
 667
 668    #[test]
 669    fn test_command_substitution_backticks() {
 670        let commands = extract_commands("echo `whoami`").expect("parse failed");
 671        assert!(commands.iter().any(|c| c.contains("echo")));
 672        assert!(commands.contains(&"whoami".to_string()));
 673    }
 674
 675    #[test]
 676    fn test_process_substitution_input() {
 677        let commands = extract_commands("cat <(ls)").expect("parse failed");
 678        assert!(commands.iter().any(|c| c.contains("cat")));
 679        assert!(commands.contains(&"ls".to_string()));
 680    }
 681
 682    #[test]
 683    fn test_process_substitution_output() {
 684        let commands = extract_commands("ls >(cat)").expect("parse failed");
 685        assert!(commands.iter().any(|c| c.contains("ls")));
 686        assert!(commands.contains(&"cat".to_string()));
 687    }
 688
 689    #[test]
 690    fn test_newline_separator() {
 691        let commands = extract_commands("ls\nrm -rf /").expect("parse failed");
 692        assert_eq!(commands, vec!["ls", "rm -rf /"]);
 693    }
 694
 695    #[test]
 696    fn test_subshell() {
 697        let commands = extract_commands("(ls && rm -rf /)").expect("parse failed");
 698        assert_eq!(commands, vec!["ls", "rm -rf /"]);
 699    }
 700
 701    #[test]
 702    fn test_mixed_operators() {
 703        let commands = extract_commands("ls; echo hello && rm -rf /").expect("parse failed");
 704        assert_eq!(commands, vec!["ls", "echo hello", "rm -rf /"]);
 705    }
 706
 707    #[test]
 708    fn test_no_spaces_around_operators() {
 709        let commands = extract_commands("ls&&rm").expect("parse failed");
 710        assert_eq!(commands, vec!["ls", "rm"]);
 711    }
 712
 713    #[test]
 714    fn test_nested_command_substitution() {
 715        let commands = extract_commands("echo $(cat $(whoami).txt)").expect("parse failed");
 716        assert!(commands.iter().any(|c| c.contains("echo")));
 717        assert!(commands.iter().any(|c| c.contains("cat")));
 718        assert!(commands.contains(&"whoami".to_string()));
 719    }
 720
 721    #[test]
 722    fn test_empty_command() {
 723        let commands = extract_commands("").expect("parse failed");
 724        assert!(commands.is_empty());
 725    }
 726
 727    #[test]
 728    fn test_invalid_syntax_returns_none() {
 729        let result = extract_commands("ls &&");
 730        assert!(result.is_none());
 731    }
 732
 733    #[test]
 734    fn test_unparsable_nested_substitution_returns_none() {
 735        let result = extract_commands("echo $(ls &&)");
 736        assert!(result.is_none());
 737    }
 738
 739    #[test]
 740    fn test_unparsable_nested_backtick_substitution_returns_none() {
 741        let result = extract_commands("echo `ls &&`");
 742        assert!(result.is_none());
 743    }
 744
 745    #[test]
 746    fn test_redirect_write_includes_target_path() {
 747        let commands = extract_commands("echo hello > /etc/passwd").expect("parse failed");
 748        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
 749    }
 750
 751    #[test]
 752    fn test_redirect_append_includes_target_path() {
 753        let commands = extract_commands("cat file >> /tmp/log").expect("parse failed");
 754        assert_eq!(commands, vec!["cat file", ">> /tmp/log"]);
 755    }
 756
 757    #[test]
 758    fn test_fd_redirect_handled_gracefully() {
 759        let commands = extract_commands("cmd 2>&1").expect("parse failed");
 760        assert_eq!(commands, vec!["cmd"]);
 761    }
 762
 763    #[test]
 764    fn test_input_redirect() {
 765        let commands = extract_commands("sort < /tmp/input").expect("parse failed");
 766        assert_eq!(commands, vec!["sort", "< /tmp/input"]);
 767    }
 768
 769    #[test]
 770    fn test_multiple_redirects() {
 771        let commands = extract_commands("cmd > /tmp/out 2> /tmp/err").expect("parse failed");
 772        assert_eq!(commands, vec!["cmd", "> /tmp/out", "2> /tmp/err"]);
 773    }
 774
 775    #[test]
 776    fn test_prefix_position_redirect() {
 777        let commands = extract_commands("> /tmp/out echo hello").expect("parse failed");
 778        assert_eq!(commands, vec!["echo hello", "> /tmp/out"]);
 779    }
 780
 781    #[test]
 782    fn test_redirect_with_variable_expansion() {
 783        let commands = extract_commands("echo > $HOME/file").expect("parse failed");
 784        assert_eq!(commands, vec!["echo", "> $HOME/file"]);
 785    }
 786
 787    #[test]
 788    fn test_output_and_error_redirect() {
 789        let commands = extract_commands("cmd &> /tmp/all").expect("parse failed");
 790        assert_eq!(commands, vec!["cmd", "&> /tmp/all"]);
 791    }
 792
 793    #[test]
 794    fn test_append_output_and_error_redirect() {
 795        let commands = extract_commands("cmd &>> /tmp/all").expect("parse failed");
 796        assert_eq!(commands, vec!["cmd", "&>> /tmp/all"]);
 797    }
 798
 799    #[test]
 800    fn test_redirect_in_chained_command() {
 801        let commands =
 802            extract_commands("echo hello > /tmp/out && cat /tmp/out").expect("parse failed");
 803        assert_eq!(commands, vec!["echo hello", "> /tmp/out", "cat /tmp/out"]);
 804    }
 805
 806    #[test]
 807    fn test_here_string_dropped_from_normalized_output() {
 808        let commands = extract_commands("cat <<< 'hello'").expect("parse failed");
 809        assert_eq!(commands, vec!["cat"]);
 810    }
 811
 812    #[test]
 813    fn test_brace_group_redirect() {
 814        let commands = extract_commands("{ echo hello; } > /etc/passwd").expect("parse failed");
 815        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
 816    }
 817
 818    #[test]
 819    fn test_subshell_redirect() {
 820        let commands = extract_commands("(cmd) > /etc/passwd").expect("parse failed");
 821        assert_eq!(commands, vec!["cmd", "> /etc/passwd"]);
 822    }
 823
 824    #[test]
 825    fn test_for_loop_redirect() {
 826        let commands =
 827            extract_commands("for f in *; do cat \"$f\"; done > /tmp/out").expect("parse failed");
 828        assert_eq!(commands, vec!["cat $f", "> /tmp/out"]);
 829    }
 830
 831    #[test]
 832    fn test_brace_group_multi_command_redirect() {
 833        let commands =
 834            extract_commands("{ echo hello; cat; } > /etc/passwd").expect("parse failed");
 835        assert_eq!(commands, vec!["echo hello", "cat", "> /etc/passwd"]);
 836    }
 837
 838    #[test]
 839    fn test_quoted_redirect_target_is_normalized() {
 840        let commands = extract_commands("echo hello > '/etc/passwd'").expect("parse failed");
 841        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
 842    }
 843
 844    #[test]
 845    fn test_redirect_without_space() {
 846        let commands = extract_commands("echo hello >/etc/passwd").expect("parse failed");
 847        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
 848    }
 849
 850    #[test]
 851    fn test_clobber_redirect() {
 852        let commands = extract_commands("cmd >| /tmp/file").expect("parse failed");
 853        assert_eq!(commands, vec!["cmd", ">| /tmp/file"]);
 854    }
 855
 856    #[test]
 857    fn test_fd_to_fd_redirect_skipped() {
 858        let commands = extract_commands("cmd 1>&2").expect("parse failed");
 859        assert_eq!(commands, vec!["cmd"]);
 860    }
 861
 862    #[test]
 863    fn test_bare_redirect_returns_none() {
 864        let result = extract_commands("> /etc/passwd");
 865        assert!(result.is_none());
 866    }
 867
 868    #[test]
 869    fn test_arithmetic_with_redirect_returns_none() {
 870        let result = extract_commands("(( x = 1 )) > /tmp/file");
 871        assert!(result.is_none());
 872    }
 873
 874    #[test]
 875    fn test_redirect_target_with_command_substitution() {
 876        let commands = extract_commands("echo > $(mktemp)").expect("parse failed");
 877        assert_eq!(commands, vec!["echo", "> $(mktemp)", "mktemp"]);
 878    }
 879
 880    #[test]
 881    fn test_nested_compound_redirects() {
 882        let commands = extract_commands("{ echo > /tmp/a; } > /tmp/b").expect("parse failed");
 883        assert_eq!(commands, vec!["echo", "> /tmp/a", "> /tmp/b"]);
 884    }
 885
 886    #[test]
 887    fn test_while_loop_redirect() {
 888        let commands =
 889            extract_commands("while true; do echo line; done > /tmp/log").expect("parse failed");
 890        assert_eq!(commands, vec!["true", "echo line", "> /tmp/log"]);
 891    }
 892
 893    #[test]
 894    fn test_if_clause_redirect() {
 895        let commands =
 896            extract_commands("if true; then echo yes; fi > /tmp/out").expect("parse failed");
 897        assert_eq!(commands, vec!["true", "echo yes", "> /tmp/out"]);
 898    }
 899
 900    #[test]
 901    fn test_pipe_with_redirect_on_last_command() {
 902        let commands = extract_commands("ls | grep foo > /tmp/out").expect("parse failed");
 903        assert_eq!(commands, vec!["ls", "grep foo", "> /tmp/out"]);
 904    }
 905
 906    #[test]
 907    fn test_pipe_with_stderr_redirect_on_first_command() {
 908        let commands = extract_commands("ls 2>/dev/null | grep foo").expect("parse failed");
 909        assert_eq!(commands, vec!["ls", "grep foo"]);
 910    }
 911
 912    #[test]
 913    fn test_function_definition_redirect() {
 914        let commands = extract_commands("f() { echo hi; } > /tmp/out").expect("parse failed");
 915        assert_eq!(commands, vec!["echo hi", "> /tmp/out"]);
 916    }
 917
 918    #[test]
 919    fn test_read_and_write_redirect() {
 920        let commands = extract_commands("cmd <> /dev/tty").expect("parse failed");
 921        assert_eq!(commands, vec!["cmd", "<> /dev/tty"]);
 922    }
 923
 924    #[test]
 925    fn test_case_clause_with_redirect() {
 926        let commands =
 927            extract_commands("case $x in a) echo hi;; esac > /tmp/out").expect("parse failed");
 928        assert_eq!(commands, vec!["echo hi", "> /tmp/out"]);
 929    }
 930
 931    #[test]
 932    fn test_until_loop_with_redirect() {
 933        let commands =
 934            extract_commands("until false; do echo line; done > /tmp/log").expect("parse failed");
 935        assert_eq!(commands, vec!["false", "echo line", "> /tmp/log"]);
 936    }
 937
 938    #[test]
 939    fn test_arithmetic_for_clause_with_redirect() {
 940        let commands = extract_commands("for ((i=0; i<10; i++)); do echo $i; done > /tmp/out")
 941            .expect("parse failed");
 942        assert_eq!(commands, vec!["echo $i", "> /tmp/out"]);
 943    }
 944
 945    #[test]
 946    fn test_if_elif_else_with_redirect() {
 947        let commands = extract_commands(
 948            "if true; then echo a; elif false; then echo b; else echo c; fi > /tmp/out",
 949        )
 950        .expect("parse failed");
 951        assert_eq!(
 952            commands,
 953            vec!["true", "echo a", "false", "echo b", "echo c", "> /tmp/out"]
 954        );
 955    }
 956
 957    #[test]
 958    fn test_multiple_redirects_on_compound_command() {
 959        let commands = extract_commands("{ cmd; } > /tmp/out 2> /tmp/err").expect("parse failed");
 960        assert_eq!(commands, vec!["cmd", "> /tmp/out", "2> /tmp/err"]);
 961    }
 962
 963    #[test]
 964    fn test_here_document_command_substitution_extracted() {
 965        let commands = extract_commands("cat <<EOF\n$(rm -rf /)\nEOF").expect("parse failed");
 966        assert!(commands.iter().any(|c| c.contains("cat")));
 967        assert!(commands.contains(&"rm -rf /".to_string()));
 968    }
 969
 970    #[test]
 971    fn test_here_document_quoted_delimiter_no_extraction() {
 972        let commands = extract_commands("cat <<'EOF'\n$(rm -rf /)\nEOF").expect("parse failed");
 973        assert_eq!(commands, vec!["cat"]);
 974    }
 975
 976    #[test]
 977    fn test_here_document_backtick_substitution_extracted() {
 978        let commands = extract_commands("cat <<EOF\n`whoami`\nEOF").expect("parse failed");
 979        assert!(commands.iter().any(|c| c.contains("cat")));
 980        assert!(commands.contains(&"whoami".to_string()));
 981    }
 982
 983    #[test]
 984    fn test_brace_group_redirect_with_command_substitution() {
 985        let commands = extract_commands("{ echo hello; } > $(mktemp)").expect("parse failed");
 986        assert!(commands.contains(&"echo hello".to_string()));
 987        assert!(commands.contains(&"mktemp".to_string()));
 988    }
 989
 990    #[test]
 991    fn test_function_definition_redirect_with_command_substitution() {
 992        let commands = extract_commands("f() { echo hi; } > $(mktemp)").expect("parse failed");
 993        assert!(commands.contains(&"echo hi".to_string()));
 994        assert!(commands.contains(&"mktemp".to_string()));
 995    }
 996
 997    #[test]
 998    fn test_brace_group_redirect_with_process_substitution() {
 999        let commands = extract_commands("{ cat; } > >(tee /tmp/log)").expect("parse failed");
1000        assert!(commands.contains(&"cat".to_string()));
1001        assert!(commands.contains(&"tee /tmp/log".to_string()));
1002    }
1003
1004    #[test]
1005    fn test_redirect_to_dev_null_skipped() {
1006        let commands = extract_commands("cmd > /dev/null").expect("parse failed");
1007        assert_eq!(commands, vec!["cmd"]);
1008    }
1009
1010    #[test]
1011    fn test_stderr_redirect_to_dev_null_skipped() {
1012        let commands = extract_commands("cmd 2>/dev/null").expect("parse failed");
1013        assert_eq!(commands, vec!["cmd"]);
1014    }
1015
1016    #[test]
1017    fn test_stderr_redirect_to_dev_null_with_space_skipped() {
1018        let commands = extract_commands("cmd 2> /dev/null").expect("parse failed");
1019        assert_eq!(commands, vec!["cmd"]);
1020    }
1021
1022    #[test]
1023    fn test_append_redirect_to_dev_null_skipped() {
1024        let commands = extract_commands("cmd >> /dev/null").expect("parse failed");
1025        assert_eq!(commands, vec!["cmd"]);
1026    }
1027
1028    #[test]
1029    fn test_output_and_error_redirect_to_dev_null_skipped() {
1030        let commands = extract_commands("cmd &>/dev/null").expect("parse failed");
1031        assert_eq!(commands, vec!["cmd"]);
1032    }
1033
1034    #[test]
1035    fn test_append_output_and_error_redirect_to_dev_null_skipped() {
1036        let commands = extract_commands("cmd &>>/dev/null").expect("parse failed");
1037        assert_eq!(commands, vec!["cmd"]);
1038    }
1039
1040    #[test]
1041    fn test_quoted_dev_null_redirect_skipped() {
1042        let commands = extract_commands("cmd 2>'/dev/null'").expect("parse failed");
1043        assert_eq!(commands, vec!["cmd"]);
1044    }
1045
1046    #[test]
1047    fn test_redirect_to_real_file_still_included() {
1048        let commands = extract_commands("echo hello > /etc/passwd").expect("parse failed");
1049        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
1050    }
1051
1052    #[test]
1053    fn test_dev_null_redirect_in_chained_command() {
1054        let commands =
1055            extract_commands("git log 2>/dev/null || echo fallback").expect("parse failed");
1056        assert_eq!(commands, vec!["git log", "echo fallback"]);
1057    }
1058
1059    #[test]
1060    fn test_mixed_safe_and_unsafe_redirects() {
1061        let commands = extract_commands("cmd > /tmp/out 2>/dev/null").expect("parse failed");
1062        assert_eq!(commands, vec!["cmd", "> /tmp/out"]);
1063    }
1064}