shell_command_parser.rs

   1use brush_parser::ast;
   2use brush_parser::ast::SourceLocation;
   3use brush_parser::word::WordPiece;
   4use brush_parser::{Parser, ParserOptions, SourceInfo};
   5use std::io::BufReader;
   6
   7#[derive(Debug, Clone, PartialEq, Eq)]
   8pub struct TerminalCommandPrefix {
   9    pub normalized: String,
  10    pub display: String,
  11    pub tokens: Vec<String>,
  12    pub command: String,
  13    pub subcommand: Option<String>,
  14}
  15
  16#[derive(Debug, Clone, PartialEq, Eq)]
  17pub enum TerminalCommandValidation {
  18    Safe,
  19    Unsafe,
  20    Unsupported,
  21}
  22
  23pub fn extract_commands(command: &str) -> Option<Vec<String>> {
  24    let reader = BufReader::new(command.as_bytes());
  25    let options = ParserOptions::default();
  26    let source_info = SourceInfo::default();
  27    let mut parser = Parser::new(reader, &options, &source_info);
  28
  29    let program = parser.parse_program().ok()?;
  30
  31    let mut commands = Vec::new();
  32    extract_commands_from_program(&program, &mut commands)?;
  33
  34    Some(commands)
  35}
  36
  37pub fn extract_terminal_command_prefix(command: &str) -> Option<TerminalCommandPrefix> {
  38    let reader = BufReader::new(command.as_bytes());
  39    let options = ParserOptions::default();
  40    let source_info = SourceInfo::default();
  41    let mut parser = Parser::new(reader, &options, &source_info);
  42
  43    let program = parser.parse_program().ok()?;
  44    let simple_command = first_simple_command(&program)?;
  45
  46    let mut normalized_tokens = Vec::new();
  47    let mut display_start = None;
  48    let mut display_end = None;
  49
  50    if let Some(prefix) = &simple_command.prefix {
  51        for item in &prefix.0 {
  52            if let ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) = item {
  53                match normalize_assignment_for_command_prefix(assignment, word)? {
  54                    NormalizedAssignment::Included(normalized_assignment) => {
  55                        normalized_tokens.push(normalized_assignment);
  56                        update_display_bounds(&mut display_start, &mut display_end, word);
  57                    }
  58                    NormalizedAssignment::Skipped => {}
  59                }
  60            }
  61        }
  62    }
  63
  64    let command_word = simple_command.word_or_name.as_ref()?;
  65    let command_name = normalize_word(command_word)?;
  66    normalized_tokens.push(command_name.clone());
  67    update_display_bounds(&mut display_start, &mut display_end, command_word);
  68
  69    let mut subcommand = None;
  70    if let Some(suffix) = &simple_command.suffix {
  71        for item in &suffix.0 {
  72            match item {
  73                ast::CommandPrefixOrSuffixItem::IoRedirect(_) => continue,
  74                ast::CommandPrefixOrSuffixItem::Word(word) => {
  75                    let normalized_word = normalize_word(word)?;
  76                    if !normalized_word.starts_with('-') {
  77                        subcommand = Some(normalized_word.clone());
  78                        normalized_tokens.push(normalized_word);
  79                        update_display_bounds(&mut display_start, &mut display_end, word);
  80                    }
  81                    break;
  82                }
  83                _ => break,
  84            }
  85        }
  86    }
  87
  88    let start = display_start?;
  89    let end = display_end?;
  90    let display = command.get(start..end)?.to_string();
  91
  92    Some(TerminalCommandPrefix {
  93        normalized: normalized_tokens.join(" "),
  94        display,
  95        tokens: normalized_tokens,
  96        command: command_name,
  97        subcommand,
  98    })
  99}
 100
 101pub fn validate_terminal_command(command: &str) -> TerminalCommandValidation {
 102    let reader = BufReader::new(command.as_bytes());
 103    let options = ParserOptions::default();
 104    let source_info = SourceInfo::default();
 105    let mut parser = Parser::new(reader, &options, &source_info);
 106
 107    let program = match parser.parse_program() {
 108        Ok(program) => program,
 109        Err(_) => return TerminalCommandValidation::Unsupported,
 110    };
 111
 112    match program_validation(&program) {
 113        TerminalProgramValidation::Safe => TerminalCommandValidation::Safe,
 114        TerminalProgramValidation::Unsafe => TerminalCommandValidation::Unsafe,
 115        TerminalProgramValidation::Unsupported => TerminalCommandValidation::Unsupported,
 116    }
 117}
 118
 119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 120enum TerminalProgramValidation {
 121    Safe,
 122    Unsafe,
 123    Unsupported,
 124}
 125
 126fn first_simple_command(program: &ast::Program) -> Option<&ast::SimpleCommand> {
 127    let complete_command = program.complete_commands.first()?;
 128    let compound_list_item = complete_command.0.first()?;
 129    let command = compound_list_item.0.first.seq.first()?;
 130
 131    match command {
 132        ast::Command::Simple(simple_command) => Some(simple_command),
 133        _ => None,
 134    }
 135}
 136
 137fn update_display_bounds(start: &mut Option<usize>, end: &mut Option<usize>, word: &ast::Word) {
 138    if let Some(location) = word.location() {
 139        let word_start = location.start.index;
 140        let word_end = location.end.index;
 141        *start = Some(start.map_or(word_start, |current| current.min(word_start)));
 142        *end = Some(end.map_or(word_end, |current| current.max(word_end)));
 143    }
 144}
 145
 146enum NormalizedAssignment {
 147    Included(String),
 148    Skipped,
 149}
 150
 151fn normalize_assignment_for_command_prefix(
 152    assignment: &ast::Assignment,
 153    word: &ast::Word,
 154) -> Option<NormalizedAssignment> {
 155    let operator = if assignment.append { "+=" } else { "=" };
 156    let assignment_prefix = format!("{}{}", assignment.name, operator);
 157
 158    match &assignment.value {
 159        ast::AssignmentValue::Scalar(value) => {
 160            let normalized_value = normalize_word(value)?;
 161            let raw_value = word.value.strip_prefix(&assignment_prefix)?;
 162            let rendered_value = if shell_value_requires_quoting(&normalized_value) {
 163                raw_value.to_string()
 164            } else {
 165                normalized_value
 166            };
 167
 168            Some(NormalizedAssignment::Included(format!(
 169                "{assignment_prefix}{rendered_value}"
 170            )))
 171        }
 172        ast::AssignmentValue::Array(_) => Some(NormalizedAssignment::Skipped),
 173    }
 174}
 175
 176fn shell_value_requires_quoting(value: &str) -> bool {
 177    value.chars().any(|character| {
 178        character.is_whitespace()
 179            || !matches!(
 180                character,
 181                'a'..='z'
 182                    | 'A'..='Z'
 183                    | '0'..='9'
 184                    | '_'
 185                    | '@'
 186                    | '%'
 187                    | '+'
 188                    | '='
 189                    | ':'
 190                    | ','
 191                    | '.'
 192                    | '/'
 193                    | '-'
 194            )
 195    })
 196}
 197
 198fn program_validation(program: &ast::Program) -> TerminalProgramValidation {
 199    combine_validations(
 200        program
 201            .complete_commands
 202            .iter()
 203            .map(compound_list_validation),
 204    )
 205}
 206
 207fn compound_list_validation(compound_list: &ast::CompoundList) -> TerminalProgramValidation {
 208    combine_validations(
 209        compound_list
 210            .0
 211            .iter()
 212            .map(|item| and_or_list_validation(&item.0)),
 213    )
 214}
 215
 216fn and_or_list_validation(and_or_list: &ast::AndOrList) -> TerminalProgramValidation {
 217    combine_validations(
 218        std::iter::once(pipeline_validation(&and_or_list.first)).chain(
 219            and_or_list.additional.iter().map(|and_or| match and_or {
 220                ast::AndOr::And(pipeline) | ast::AndOr::Or(pipeline) => {
 221                    pipeline_validation(pipeline)
 222                }
 223            }),
 224        ),
 225    )
 226}
 227
 228fn pipeline_validation(pipeline: &ast::Pipeline) -> TerminalProgramValidation {
 229    combine_validations(pipeline.seq.iter().map(command_validation))
 230}
 231
 232fn command_validation(command: &ast::Command) -> TerminalProgramValidation {
 233    match command {
 234        ast::Command::Simple(simple_command) => simple_command_validation(simple_command),
 235        ast::Command::Compound(compound_command, redirect_list) => combine_validations(
 236            std::iter::once(compound_command_validation(compound_command))
 237                .chain(redirect_list.iter().map(redirect_list_validation)),
 238        ),
 239        ast::Command::Function(function_definition) => {
 240            function_body_validation(&function_definition.body)
 241        }
 242        ast::Command::ExtendedTest(test_expr) => extended_test_expr_validation(test_expr),
 243    }
 244}
 245
 246fn simple_command_validation(simple_command: &ast::SimpleCommand) -> TerminalProgramValidation {
 247    combine_validations(
 248        simple_command
 249            .prefix
 250            .iter()
 251            .map(command_prefix_validation)
 252            .chain(simple_command.word_or_name.iter().map(word_validation))
 253            .chain(simple_command.suffix.iter().map(command_suffix_validation)),
 254    )
 255}
 256
 257fn command_prefix_validation(prefix: &ast::CommandPrefix) -> TerminalProgramValidation {
 258    combine_validations(prefix.0.iter().map(prefix_or_suffix_item_validation))
 259}
 260
 261fn command_suffix_validation(suffix: &ast::CommandSuffix) -> TerminalProgramValidation {
 262    combine_validations(suffix.0.iter().map(prefix_or_suffix_item_validation))
 263}
 264
 265fn prefix_or_suffix_item_validation(
 266    item: &ast::CommandPrefixOrSuffixItem,
 267) -> TerminalProgramValidation {
 268    match item {
 269        ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => io_redirect_validation(redirect),
 270        ast::CommandPrefixOrSuffixItem::Word(word) => word_validation(word),
 271        ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) => {
 272            combine_validations([assignment_validation(assignment), word_validation(word)])
 273        }
 274        ast::CommandPrefixOrSuffixItem::ProcessSubstitution(_, _) => {
 275            TerminalProgramValidation::Unsafe
 276        }
 277    }
 278}
 279
 280fn io_redirect_validation(redirect: &ast::IoRedirect) -> TerminalProgramValidation {
 281    match redirect {
 282        ast::IoRedirect::File(_, _, target) => match target {
 283            ast::IoFileRedirectTarget::Filename(word) => word_validation(word),
 284            ast::IoFileRedirectTarget::ProcessSubstitution(_, _) => {
 285                TerminalProgramValidation::Unsafe
 286            }
 287            _ => TerminalProgramValidation::Safe,
 288        },
 289        ast::IoRedirect::HereDocument(_, here_doc) => {
 290            if here_doc.requires_expansion {
 291                word_validation(&here_doc.doc)
 292            } else {
 293                TerminalProgramValidation::Safe
 294            }
 295        }
 296        ast::IoRedirect::HereString(_, word) | ast::IoRedirect::OutputAndError(word, _) => {
 297            word_validation(word)
 298        }
 299    }
 300}
 301
 302fn assignment_validation(assignment: &ast::Assignment) -> TerminalProgramValidation {
 303    match &assignment.value {
 304        ast::AssignmentValue::Scalar(word) => word_validation(word),
 305        ast::AssignmentValue::Array(words) => {
 306            combine_validations(words.iter().flat_map(|(key, value)| {
 307                key.iter()
 308                    .map(word_validation)
 309                    .chain(std::iter::once(word_validation(value)))
 310            }))
 311        }
 312    }
 313}
 314
 315fn word_validation(word: &ast::Word) -> TerminalProgramValidation {
 316    let options = ParserOptions::default();
 317    let pieces = match brush_parser::word::parse(&word.value, &options) {
 318        Ok(pieces) => pieces,
 319        Err(_) => return TerminalProgramValidation::Unsupported,
 320    };
 321
 322    combine_validations(
 323        pieces
 324            .iter()
 325            .map(|piece_with_source| word_piece_validation(&piece_with_source.piece)),
 326    )
 327}
 328
 329fn word_piece_validation(piece: &WordPiece) -> TerminalProgramValidation {
 330    match piece {
 331        WordPiece::Text(_)
 332        | WordPiece::SingleQuotedText(_)
 333        | WordPiece::AnsiCQuotedText(_)
 334        | WordPiece::EscapeSequence(_)
 335        | WordPiece::TildePrefix(_) => TerminalProgramValidation::Safe,
 336        WordPiece::DoubleQuotedSequence(pieces)
 337        | WordPiece::GettextDoubleQuotedSequence(pieces) => combine_validations(
 338            pieces
 339                .iter()
 340                .map(|inner| word_piece_validation(&inner.piece)),
 341        ),
 342        WordPiece::ParameterExpansion(_) | WordPiece::ArithmeticExpression(_) => {
 343            TerminalProgramValidation::Unsafe
 344        }
 345        WordPiece::CommandSubstitution(command)
 346        | WordPiece::BackquotedCommandSubstitution(command) => {
 347            let reader = BufReader::new(command.as_bytes());
 348            let options = ParserOptions::default();
 349            let source_info = SourceInfo::default();
 350            let mut parser = Parser::new(reader, &options, &source_info);
 351
 352            match parser.parse_program() {
 353                Ok(_) => TerminalProgramValidation::Unsafe,
 354                Err(_) => TerminalProgramValidation::Unsupported,
 355            }
 356        }
 357    }
 358}
 359
 360fn compound_command_validation(
 361    compound_command: &ast::CompoundCommand,
 362) -> TerminalProgramValidation {
 363    match compound_command {
 364        ast::CompoundCommand::BraceGroup(brace_group) => {
 365            compound_list_validation(&brace_group.list)
 366        }
 367        ast::CompoundCommand::Subshell(subshell) => compound_list_validation(&subshell.list),
 368        ast::CompoundCommand::ForClause(for_clause) => combine_validations(
 369            for_clause
 370                .values
 371                .iter()
 372                .flat_map(|values| values.iter().map(word_validation))
 373                .chain(std::iter::once(do_group_validation(&for_clause.body))),
 374        ),
 375        ast::CompoundCommand::CaseClause(case_clause) => combine_validations(
 376            std::iter::once(word_validation(&case_clause.value))
 377                .chain(
 378                    case_clause
 379                        .cases
 380                        .iter()
 381                        .flat_map(|item| item.cmd.iter().map(compound_list_validation)),
 382                )
 383                .chain(
 384                    case_clause
 385                        .cases
 386                        .iter()
 387                        .flat_map(|item| item.patterns.iter().map(word_validation)),
 388                ),
 389        ),
 390        ast::CompoundCommand::IfClause(if_clause) => combine_validations(
 391            std::iter::once(compound_list_validation(&if_clause.condition))
 392                .chain(std::iter::once(compound_list_validation(&if_clause.then)))
 393                .chain(if_clause.elses.iter().flat_map(|elses| {
 394                    elses.iter().flat_map(|else_item| {
 395                        else_item
 396                            .condition
 397                            .iter()
 398                            .map(compound_list_validation)
 399                            .chain(std::iter::once(compound_list_validation(&else_item.body)))
 400                    })
 401                })),
 402        ),
 403        ast::CompoundCommand::WhileClause(while_clause)
 404        | ast::CompoundCommand::UntilClause(while_clause) => combine_validations([
 405            compound_list_validation(&while_clause.0),
 406            do_group_validation(&while_clause.1),
 407        ]),
 408        ast::CompoundCommand::ArithmeticForClause(_) => TerminalProgramValidation::Unsafe,
 409        ast::CompoundCommand::Arithmetic(_) => TerminalProgramValidation::Unsafe,
 410    }
 411}
 412
 413fn do_group_validation(do_group: &ast::DoGroupCommand) -> TerminalProgramValidation {
 414    compound_list_validation(&do_group.list)
 415}
 416
 417fn function_body_validation(function_body: &ast::FunctionBody) -> TerminalProgramValidation {
 418    combine_validations(
 419        std::iter::once(compound_command_validation(&function_body.0))
 420            .chain(function_body.1.iter().map(redirect_list_validation)),
 421    )
 422}
 423
 424fn redirect_list_validation(redirect_list: &ast::RedirectList) -> TerminalProgramValidation {
 425    combine_validations(redirect_list.0.iter().map(io_redirect_validation))
 426}
 427
 428fn extended_test_expr_validation(
 429    test_expr: &ast::ExtendedTestExprCommand,
 430) -> TerminalProgramValidation {
 431    extended_test_expr_inner_validation(&test_expr.expr)
 432}
 433
 434fn extended_test_expr_inner_validation(expr: &ast::ExtendedTestExpr) -> TerminalProgramValidation {
 435    match expr {
 436        ast::ExtendedTestExpr::Not(inner) | ast::ExtendedTestExpr::Parenthesized(inner) => {
 437            extended_test_expr_inner_validation(inner)
 438        }
 439        ast::ExtendedTestExpr::And(left, right) | ast::ExtendedTestExpr::Or(left, right) => {
 440            combine_validations([
 441                extended_test_expr_inner_validation(left),
 442                extended_test_expr_inner_validation(right),
 443            ])
 444        }
 445        ast::ExtendedTestExpr::UnaryTest(_, word) => word_validation(word),
 446        ast::ExtendedTestExpr::BinaryTest(_, left, right) => {
 447            combine_validations([word_validation(left), word_validation(right)])
 448        }
 449    }
 450}
 451
 452fn combine_validations(
 453    validations: impl IntoIterator<Item = TerminalProgramValidation>,
 454) -> TerminalProgramValidation {
 455    let mut saw_unsafe = false;
 456    let mut saw_unsupported = false;
 457
 458    for validation in validations {
 459        match validation {
 460            TerminalProgramValidation::Unsupported => saw_unsupported = true,
 461            TerminalProgramValidation::Unsafe => saw_unsafe = true,
 462            TerminalProgramValidation::Safe => {}
 463        }
 464    }
 465
 466    if saw_unsafe {
 467        TerminalProgramValidation::Unsafe
 468    } else if saw_unsupported {
 469        TerminalProgramValidation::Unsupported
 470    } else {
 471        TerminalProgramValidation::Safe
 472    }
 473}
 474
 475fn extract_commands_from_program(program: &ast::Program, commands: &mut Vec<String>) -> Option<()> {
 476    for complete_command in &program.complete_commands {
 477        extract_commands_from_compound_list(complete_command, commands)?;
 478    }
 479    Some(())
 480}
 481
 482fn extract_commands_from_compound_list(
 483    compound_list: &ast::CompoundList,
 484    commands: &mut Vec<String>,
 485) -> Option<()> {
 486    for item in &compound_list.0 {
 487        extract_commands_from_and_or_list(&item.0, commands)?;
 488    }
 489    Some(())
 490}
 491
 492fn extract_commands_from_and_or_list(
 493    and_or_list: &ast::AndOrList,
 494    commands: &mut Vec<String>,
 495) -> Option<()> {
 496    extract_commands_from_pipeline(&and_or_list.first, commands)?;
 497
 498    for and_or in &and_or_list.additional {
 499        match and_or {
 500            ast::AndOr::And(pipeline) | ast::AndOr::Or(pipeline) => {
 501                extract_commands_from_pipeline(pipeline, commands)?;
 502            }
 503        }
 504    }
 505    Some(())
 506}
 507
 508fn extract_commands_from_pipeline(
 509    pipeline: &ast::Pipeline,
 510    commands: &mut Vec<String>,
 511) -> Option<()> {
 512    for command in &pipeline.seq {
 513        extract_commands_from_command(command, commands)?;
 514    }
 515    Some(())
 516}
 517
 518fn extract_commands_from_command(command: &ast::Command, commands: &mut Vec<String>) -> Option<()> {
 519    match command {
 520        ast::Command::Simple(simple_command) => {
 521            extract_commands_from_simple_command(simple_command, commands)?;
 522        }
 523        ast::Command::Compound(compound_command, redirect_list) => {
 524            let body_start = extract_commands_from_compound_command(compound_command, commands)?;
 525            if let Some(redirect_list) = redirect_list {
 526                let mut normalized_redirects = Vec::new();
 527                for redirect in &redirect_list.0 {
 528                    match normalize_io_redirect(redirect)? {
 529                        RedirectNormalization::Normalized(s) => normalized_redirects.push(s),
 530                        RedirectNormalization::Skip => {}
 531                    }
 532                }
 533                if !normalized_redirects.is_empty() {
 534                    if body_start >= commands.len() {
 535                        return None;
 536                    }
 537                    commands.extend(normalized_redirects);
 538                }
 539                for redirect in &redirect_list.0 {
 540                    extract_commands_from_io_redirect(redirect, commands)?;
 541                }
 542            }
 543        }
 544        ast::Command::Function(func_def) => {
 545            extract_commands_from_function_body(&func_def.body, commands)?;
 546        }
 547        ast::Command::ExtendedTest(test_expr) => {
 548            extract_commands_from_extended_test_expr(test_expr, commands)?;
 549        }
 550    }
 551    Some(())
 552}
 553
 554enum RedirectNormalization {
 555    Normalized(String),
 556    Skip,
 557}
 558
 559fn extract_commands_from_simple_command(
 560    simple_command: &ast::SimpleCommand,
 561    commands: &mut Vec<String>,
 562) -> Option<()> {
 563    // Build a normalized command string from individual words, stripping shell
 564    // quotes so that security patterns match regardless of quoting style.
 565    // For example, both `rm -rf '/'` and `rm -rf /` normalize to "rm -rf /".
 566    //
 567    // If any word fails to normalize, we return None so that `extract_commands`
 568    // returns None — the same as a shell parse failure. The caller then falls
 569    // back to raw-input matching with always_allow disabled.
 570    let mut words = Vec::new();
 571    let mut redirects = Vec::new();
 572
 573    if let Some(prefix) = &simple_command.prefix {
 574        for item in &prefix.0 {
 575            match item {
 576                ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => {
 577                    match normalize_io_redirect(redirect) {
 578                        Some(RedirectNormalization::Normalized(s)) => redirects.push(s),
 579                        Some(RedirectNormalization::Skip) => {}
 580                        None => return None,
 581                    }
 582                }
 583                ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) => {
 584                    match normalize_assignment_for_command_prefix(assignment, word)? {
 585                        NormalizedAssignment::Included(normalized_assignment) => {
 586                            words.push(normalized_assignment);
 587                        }
 588                        NormalizedAssignment::Skipped => {}
 589                    }
 590                }
 591                ast::CommandPrefixOrSuffixItem::Word(word) => {
 592                    words.push(normalize_word(word)?);
 593                }
 594                ast::CommandPrefixOrSuffixItem::ProcessSubstitution(_, _) => return None,
 595            }
 596        }
 597    }
 598    if let Some(word) = &simple_command.word_or_name {
 599        words.push(normalize_word(word)?);
 600    }
 601    if let Some(suffix) = &simple_command.suffix {
 602        for item in &suffix.0 {
 603            match item {
 604                ast::CommandPrefixOrSuffixItem::Word(word) => {
 605                    words.push(normalize_word(word)?);
 606                }
 607                ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => {
 608                    match normalize_io_redirect(redirect) {
 609                        Some(RedirectNormalization::Normalized(s)) => redirects.push(s),
 610                        Some(RedirectNormalization::Skip) => {}
 611                        None => return None,
 612                    }
 613                }
 614                ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) => {
 615                    match normalize_assignment_for_command_prefix(assignment, word)? {
 616                        NormalizedAssignment::Included(normalized_assignment) => {
 617                            words.push(normalized_assignment);
 618                        }
 619                        NormalizedAssignment::Skipped => {}
 620                    }
 621                }
 622                ast::CommandPrefixOrSuffixItem::ProcessSubstitution(_, _) => {}
 623            }
 624        }
 625    }
 626
 627    if words.is_empty() && !redirects.is_empty() {
 628        return None;
 629    }
 630
 631    let command_str = words.join(" ");
 632    if !command_str.is_empty() {
 633        commands.push(command_str);
 634    }
 635    commands.extend(redirects);
 636
 637    // Extract nested commands from command substitutions, process substitutions, etc.
 638    if let Some(prefix) = &simple_command.prefix {
 639        extract_commands_from_command_prefix(prefix, commands)?;
 640    }
 641    if let Some(word) = &simple_command.word_or_name {
 642        extract_commands_from_word(word, commands)?;
 643    }
 644    if let Some(suffix) = &simple_command.suffix {
 645        extract_commands_from_command_suffix(suffix, commands)?;
 646    }
 647    Some(())
 648}
 649
 650/// Normalizes a shell word by stripping quoting syntax and returning the
 651/// semantic (unquoted) value. Returns `None` if word parsing fails.
 652fn normalize_word(word: &ast::Word) -> Option<String> {
 653    let options = ParserOptions::default();
 654    let pieces = brush_parser::word::parse(&word.value, &options).ok()?;
 655    let mut result = String::new();
 656    for piece_with_source in &pieces {
 657        normalize_word_piece_into(
 658            &piece_with_source.piece,
 659            &word.value,
 660            piece_with_source.start_index,
 661            piece_with_source.end_index,
 662            &mut result,
 663        )?;
 664    }
 665    Some(result)
 666}
 667
 668fn normalize_word_piece_into(
 669    piece: &WordPiece,
 670    raw_value: &str,
 671    start_index: usize,
 672    end_index: usize,
 673    result: &mut String,
 674) -> Option<()> {
 675    match piece {
 676        WordPiece::Text(text) => result.push_str(text),
 677        WordPiece::SingleQuotedText(text) => result.push_str(text),
 678        WordPiece::AnsiCQuotedText(text) => result.push_str(text),
 679        WordPiece::EscapeSequence(text) => {
 680            result.push_str(text.strip_prefix('\\').unwrap_or(text));
 681        }
 682        WordPiece::DoubleQuotedSequence(pieces)
 683        | WordPiece::GettextDoubleQuotedSequence(pieces) => {
 684            for inner in pieces {
 685                normalize_word_piece_into(
 686                    &inner.piece,
 687                    raw_value,
 688                    inner.start_index,
 689                    inner.end_index,
 690                    result,
 691                )?;
 692            }
 693        }
 694        WordPiece::TildePrefix(prefix) => {
 695            result.push('~');
 696            result.push_str(prefix);
 697        }
 698        // For parameter expansions, command substitutions, and arithmetic expressions,
 699        // preserve the original source text so that patterns like `\$HOME` continue
 700        // to match.
 701        WordPiece::ParameterExpansion(_)
 702        | WordPiece::CommandSubstitution(_)
 703        | WordPiece::BackquotedCommandSubstitution(_)
 704        | WordPiece::ArithmeticExpression(_) => {
 705            let source = raw_value.get(start_index..end_index)?;
 706            result.push_str(source);
 707        }
 708    }
 709    Some(())
 710}
 711
 712fn is_known_safe_redirect_target(normalized_target: &str) -> bool {
 713    normalized_target == "/dev/null"
 714}
 715
 716fn normalize_io_redirect(redirect: &ast::IoRedirect) -> Option<RedirectNormalization> {
 717    match redirect {
 718        ast::IoRedirect::File(fd, kind, target) => {
 719            let target_word = match target {
 720                ast::IoFileRedirectTarget::Filename(word) => word,
 721                _ => return Some(RedirectNormalization::Skip),
 722            };
 723            let operator = match kind {
 724                ast::IoFileRedirectKind::Read => "<",
 725                ast::IoFileRedirectKind::Write => ">",
 726                ast::IoFileRedirectKind::Append => ">>",
 727                ast::IoFileRedirectKind::ReadAndWrite => "<>",
 728                ast::IoFileRedirectKind::Clobber => ">|",
 729                // The parser pairs DuplicateInput/DuplicateOutput with
 730                // IoFileRedirectTarget::Duplicate (not Filename), so the
 731                // target match above will return Skip before we reach here.
 732                // These arms are kept for defensiveness.
 733                ast::IoFileRedirectKind::DuplicateInput => "<&",
 734                ast::IoFileRedirectKind::DuplicateOutput => ">&",
 735            };
 736            let fd_prefix = match fd {
 737                Some(fd) => fd.to_string(),
 738                None => String::new(),
 739            };
 740            let normalized = normalize_word(target_word)?;
 741            if is_known_safe_redirect_target(&normalized) {
 742                return Some(RedirectNormalization::Skip);
 743            }
 744            Some(RedirectNormalization::Normalized(format!(
 745                "{}{} {}",
 746                fd_prefix, operator, normalized
 747            )))
 748        }
 749        ast::IoRedirect::OutputAndError(word, append) => {
 750            let operator = if *append { "&>>" } else { "&>" };
 751            let normalized = normalize_word(word)?;
 752            if is_known_safe_redirect_target(&normalized) {
 753                return Some(RedirectNormalization::Skip);
 754            }
 755            Some(RedirectNormalization::Normalized(format!(
 756                "{} {}",
 757                operator, normalized
 758            )))
 759        }
 760        ast::IoRedirect::HereDocument(_, _) | ast::IoRedirect::HereString(_, _) => {
 761            Some(RedirectNormalization::Skip)
 762        }
 763    }
 764}
 765
 766fn extract_commands_from_command_prefix(
 767    prefix: &ast::CommandPrefix,
 768    commands: &mut Vec<String>,
 769) -> Option<()> {
 770    for item in &prefix.0 {
 771        extract_commands_from_prefix_or_suffix_item(item, commands)?;
 772    }
 773    Some(())
 774}
 775
 776fn extract_commands_from_command_suffix(
 777    suffix: &ast::CommandSuffix,
 778    commands: &mut Vec<String>,
 779) -> Option<()> {
 780    for item in &suffix.0 {
 781        extract_commands_from_prefix_or_suffix_item(item, commands)?;
 782    }
 783    Some(())
 784}
 785
 786fn extract_commands_from_prefix_or_suffix_item(
 787    item: &ast::CommandPrefixOrSuffixItem,
 788    commands: &mut Vec<String>,
 789) -> Option<()> {
 790    match item {
 791        ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => {
 792            extract_commands_from_io_redirect(redirect, commands)?;
 793        }
 794        ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, _word) => {
 795            extract_commands_from_assignment(assignment, commands)?;
 796        }
 797        ast::CommandPrefixOrSuffixItem::Word(word) => {
 798            extract_commands_from_word(word, commands)?;
 799        }
 800        ast::CommandPrefixOrSuffixItem::ProcessSubstitution(_kind, subshell) => {
 801            extract_commands_from_compound_list(&subshell.list, commands)?;
 802        }
 803    }
 804    Some(())
 805}
 806
 807fn extract_commands_from_io_redirect(
 808    redirect: &ast::IoRedirect,
 809    commands: &mut Vec<String>,
 810) -> Option<()> {
 811    match redirect {
 812        ast::IoRedirect::File(_fd, _kind, target) => match target {
 813            ast::IoFileRedirectTarget::ProcessSubstitution(_kind, subshell) => {
 814                extract_commands_from_compound_list(&subshell.list, commands)?;
 815            }
 816            ast::IoFileRedirectTarget::Filename(word) => {
 817                extract_commands_from_word(word, commands)?;
 818            }
 819            _ => {}
 820        },
 821        ast::IoRedirect::HereDocument(_fd, here_doc) => {
 822            if here_doc.requires_expansion {
 823                extract_commands_from_word(&here_doc.doc, commands)?;
 824            }
 825        }
 826        ast::IoRedirect::HereString(_fd, word) => {
 827            extract_commands_from_word(word, commands)?;
 828        }
 829        ast::IoRedirect::OutputAndError(word, _) => {
 830            extract_commands_from_word(word, commands)?;
 831        }
 832    }
 833    Some(())
 834}
 835
 836fn extract_commands_from_assignment(
 837    assignment: &ast::Assignment,
 838    commands: &mut Vec<String>,
 839) -> Option<()> {
 840    match &assignment.value {
 841        ast::AssignmentValue::Scalar(word) => {
 842            extract_commands_from_word(word, commands)?;
 843        }
 844        ast::AssignmentValue::Array(words) => {
 845            for (opt_word, word) in words {
 846                if let Some(w) = opt_word {
 847                    extract_commands_from_word(w, commands)?;
 848                }
 849                extract_commands_from_word(word, commands)?;
 850            }
 851        }
 852    }
 853    Some(())
 854}
 855
 856fn extract_commands_from_word(word: &ast::Word, commands: &mut Vec<String>) -> Option<()> {
 857    let options = ParserOptions::default();
 858    let pieces = brush_parser::word::parse(&word.value, &options).ok()?;
 859    for piece_with_source in pieces {
 860        extract_commands_from_word_piece(&piece_with_source.piece, commands)?;
 861    }
 862    Some(())
 863}
 864
 865fn extract_commands_from_word_piece(piece: &WordPiece, commands: &mut Vec<String>) -> Option<()> {
 866    match piece {
 867        WordPiece::CommandSubstitution(cmd_str)
 868        | WordPiece::BackquotedCommandSubstitution(cmd_str) => {
 869            let nested_commands = extract_commands(cmd_str)?;
 870            commands.extend(nested_commands);
 871        }
 872        WordPiece::DoubleQuotedSequence(pieces)
 873        | WordPiece::GettextDoubleQuotedSequence(pieces) => {
 874            for inner_piece_with_source in pieces {
 875                extract_commands_from_word_piece(&inner_piece_with_source.piece, commands)?;
 876            }
 877        }
 878        WordPiece::EscapeSequence(_)
 879        | WordPiece::SingleQuotedText(_)
 880        | WordPiece::Text(_)
 881        | WordPiece::AnsiCQuotedText(_)
 882        | WordPiece::TildePrefix(_)
 883        | WordPiece::ParameterExpansion(_)
 884        | WordPiece::ArithmeticExpression(_) => {}
 885    }
 886    Some(())
 887}
 888
 889fn extract_commands_from_compound_command(
 890    compound_command: &ast::CompoundCommand,
 891    commands: &mut Vec<String>,
 892) -> Option<usize> {
 893    match compound_command {
 894        ast::CompoundCommand::BraceGroup(brace_group) => {
 895            let body_start = commands.len();
 896            extract_commands_from_compound_list(&brace_group.list, commands)?;
 897            Some(body_start)
 898        }
 899        ast::CompoundCommand::Subshell(subshell) => {
 900            let body_start = commands.len();
 901            extract_commands_from_compound_list(&subshell.list, commands)?;
 902            Some(body_start)
 903        }
 904        ast::CompoundCommand::ForClause(for_clause) => {
 905            if let Some(words) = &for_clause.values {
 906                for word in words {
 907                    extract_commands_from_word(word, commands)?;
 908                }
 909            }
 910            let body_start = commands.len();
 911            extract_commands_from_do_group(&for_clause.body, commands)?;
 912            Some(body_start)
 913        }
 914        ast::CompoundCommand::CaseClause(case_clause) => {
 915            extract_commands_from_word(&case_clause.value, commands)?;
 916            let body_start = commands.len();
 917            for item in &case_clause.cases {
 918                if let Some(body) = &item.cmd {
 919                    extract_commands_from_compound_list(body, commands)?;
 920                }
 921            }
 922            Some(body_start)
 923        }
 924        ast::CompoundCommand::IfClause(if_clause) => {
 925            extract_commands_from_compound_list(&if_clause.condition, commands)?;
 926            let body_start = commands.len();
 927            extract_commands_from_compound_list(&if_clause.then, commands)?;
 928            if let Some(elses) = &if_clause.elses {
 929                for else_item in elses {
 930                    if let Some(condition) = &else_item.condition {
 931                        extract_commands_from_compound_list(condition, commands)?;
 932                    }
 933                    extract_commands_from_compound_list(&else_item.body, commands)?;
 934                }
 935            }
 936            Some(body_start)
 937        }
 938        ast::CompoundCommand::WhileClause(while_clause)
 939        | ast::CompoundCommand::UntilClause(while_clause) => {
 940            extract_commands_from_compound_list(&while_clause.0, commands)?;
 941            let body_start = commands.len();
 942            extract_commands_from_do_group(&while_clause.1, commands)?;
 943            Some(body_start)
 944        }
 945        ast::CompoundCommand::ArithmeticForClause(arith_for) => {
 946            let body_start = commands.len();
 947            extract_commands_from_do_group(&arith_for.body, commands)?;
 948            Some(body_start)
 949        }
 950        ast::CompoundCommand::Arithmetic(_arith_cmd) => Some(commands.len()),
 951    }
 952}
 953
 954fn extract_commands_from_do_group(
 955    do_group: &ast::DoGroupCommand,
 956    commands: &mut Vec<String>,
 957) -> Option<()> {
 958    extract_commands_from_compound_list(&do_group.list, commands)
 959}
 960
 961fn extract_commands_from_function_body(
 962    func_body: &ast::FunctionBody,
 963    commands: &mut Vec<String>,
 964) -> Option<()> {
 965    let body_start = extract_commands_from_compound_command(&func_body.0, commands)?;
 966    if let Some(redirect_list) = &func_body.1 {
 967        let mut normalized_redirects = Vec::new();
 968        for redirect in &redirect_list.0 {
 969            match normalize_io_redirect(redirect)? {
 970                RedirectNormalization::Normalized(s) => normalized_redirects.push(s),
 971                RedirectNormalization::Skip => {}
 972            }
 973        }
 974        if !normalized_redirects.is_empty() {
 975            if body_start >= commands.len() {
 976                return None;
 977            }
 978            commands.extend(normalized_redirects);
 979        }
 980        for redirect in &redirect_list.0 {
 981            extract_commands_from_io_redirect(redirect, commands)?;
 982        }
 983    }
 984    Some(())
 985}
 986
 987fn extract_commands_from_extended_test_expr(
 988    test_expr: &ast::ExtendedTestExprCommand,
 989    commands: &mut Vec<String>,
 990) -> Option<()> {
 991    extract_commands_from_extended_test_expr_inner(&test_expr.expr, commands)
 992}
 993
 994fn extract_commands_from_extended_test_expr_inner(
 995    expr: &ast::ExtendedTestExpr,
 996    commands: &mut Vec<String>,
 997) -> Option<()> {
 998    match expr {
 999        ast::ExtendedTestExpr::Not(inner) => {
1000            extract_commands_from_extended_test_expr_inner(inner, commands)?;
1001        }
1002        ast::ExtendedTestExpr::And(left, right) | ast::ExtendedTestExpr::Or(left, right) => {
1003            extract_commands_from_extended_test_expr_inner(left, commands)?;
1004            extract_commands_from_extended_test_expr_inner(right, commands)?;
1005        }
1006        ast::ExtendedTestExpr::Parenthesized(inner) => {
1007            extract_commands_from_extended_test_expr_inner(inner, commands)?;
1008        }
1009        ast::ExtendedTestExpr::UnaryTest(_, word) => {
1010            extract_commands_from_word(word, commands)?;
1011        }
1012        ast::ExtendedTestExpr::BinaryTest(_, word1, word2) => {
1013            extract_commands_from_word(word1, commands)?;
1014            extract_commands_from_word(word2, commands)?;
1015        }
1016    }
1017    Some(())
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    #[test]
1025    fn test_simple_command() {
1026        let commands = extract_commands("ls").expect("parse failed");
1027        assert_eq!(commands, vec!["ls"]);
1028    }
1029
1030    #[test]
1031    fn test_command_with_args() {
1032        let commands = extract_commands("ls -la /tmp").expect("parse failed");
1033        assert_eq!(commands, vec!["ls -la /tmp"]);
1034    }
1035
1036    #[test]
1037    fn test_single_quoted_argument_is_normalized() {
1038        let commands = extract_commands("rm -rf '/'").expect("parse failed");
1039        assert_eq!(commands, vec!["rm -rf /"]);
1040    }
1041
1042    #[test]
1043    fn test_single_quoted_command_name_is_normalized() {
1044        let commands = extract_commands("'rm' -rf /").expect("parse failed");
1045        assert_eq!(commands, vec!["rm -rf /"]);
1046    }
1047
1048    #[test]
1049    fn test_double_quoted_argument_is_normalized() {
1050        let commands = extract_commands("rm -rf \"/\"").expect("parse failed");
1051        assert_eq!(commands, vec!["rm -rf /"]);
1052    }
1053
1054    #[test]
1055    fn test_double_quoted_command_name_is_normalized() {
1056        let commands = extract_commands("\"rm\" -rf /").expect("parse failed");
1057        assert_eq!(commands, vec!["rm -rf /"]);
1058    }
1059
1060    #[test]
1061    fn test_escaped_argument_is_normalized() {
1062        let commands = extract_commands("rm -rf \\/").expect("parse failed");
1063        assert_eq!(commands, vec!["rm -rf /"]);
1064    }
1065
1066    #[test]
1067    fn test_partial_quoting_command_name_is_normalized() {
1068        let commands = extract_commands("r'm' -rf /").expect("parse failed");
1069        assert_eq!(commands, vec!["rm -rf /"]);
1070    }
1071
1072    #[test]
1073    fn test_partial_quoting_flag_is_normalized() {
1074        let commands = extract_commands("rm -r'f' /").expect("parse failed");
1075        assert_eq!(commands, vec!["rm -rf /"]);
1076    }
1077
1078    #[test]
1079    fn test_quoted_bypass_in_chained_command() {
1080        let commands = extract_commands("ls && 'rm' -rf '/'").expect("parse failed");
1081        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1082    }
1083
1084    #[test]
1085    fn test_tilde_preserved_after_normalization() {
1086        let commands = extract_commands("rm -rf ~").expect("parse failed");
1087        assert_eq!(commands, vec!["rm -rf ~"]);
1088    }
1089
1090    #[test]
1091    fn test_quoted_tilde_normalized() {
1092        let commands = extract_commands("rm -rf '~'").expect("parse failed");
1093        assert_eq!(commands, vec!["rm -rf ~"]);
1094    }
1095
1096    #[test]
1097    fn test_parameter_expansion_preserved() {
1098        let commands = extract_commands("rm -rf $HOME").expect("parse failed");
1099        assert_eq!(commands, vec!["rm -rf $HOME"]);
1100    }
1101
1102    #[test]
1103    fn test_braced_parameter_expansion_preserved() {
1104        let commands = extract_commands("rm -rf ${HOME}").expect("parse failed");
1105        assert_eq!(commands, vec!["rm -rf ${HOME}"]);
1106    }
1107
1108    #[test]
1109    fn test_and_operator() {
1110        let commands = extract_commands("ls && rm -rf /").expect("parse failed");
1111        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1112    }
1113
1114    #[test]
1115    fn test_or_operator() {
1116        let commands = extract_commands("ls || rm -rf /").expect("parse failed");
1117        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1118    }
1119
1120    #[test]
1121    fn test_semicolon() {
1122        let commands = extract_commands("ls; rm -rf /").expect("parse failed");
1123        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1124    }
1125
1126    #[test]
1127    fn test_pipe() {
1128        let commands = extract_commands("ls | xargs rm -rf").expect("parse failed");
1129        assert_eq!(commands, vec!["ls", "xargs rm -rf"]);
1130    }
1131
1132    #[test]
1133    fn test_background() {
1134        let commands = extract_commands("ls & rm -rf /").expect("parse failed");
1135        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1136    }
1137
1138    #[test]
1139    fn test_command_substitution_dollar() {
1140        let commands = extract_commands("echo $(whoami)").expect("parse failed");
1141        assert!(commands.iter().any(|c| c.contains("echo")));
1142        assert!(commands.contains(&"whoami".to_string()));
1143    }
1144
1145    #[test]
1146    fn test_command_substitution_backticks() {
1147        let commands = extract_commands("echo `whoami`").expect("parse failed");
1148        assert!(commands.iter().any(|c| c.contains("echo")));
1149        assert!(commands.contains(&"whoami".to_string()));
1150    }
1151
1152    #[test]
1153    fn test_process_substitution_input() {
1154        let commands = extract_commands("cat <(ls)").expect("parse failed");
1155        assert!(commands.iter().any(|c| c.contains("cat")));
1156        assert!(commands.contains(&"ls".to_string()));
1157    }
1158
1159    #[test]
1160    fn test_process_substitution_output() {
1161        let commands = extract_commands("ls >(cat)").expect("parse failed");
1162        assert!(commands.iter().any(|c| c.contains("ls")));
1163        assert!(commands.contains(&"cat".to_string()));
1164    }
1165
1166    #[test]
1167    fn test_newline_separator() {
1168        let commands = extract_commands("ls\nrm -rf /").expect("parse failed");
1169        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1170    }
1171
1172    #[test]
1173    fn test_subshell() {
1174        let commands = extract_commands("(ls && rm -rf /)").expect("parse failed");
1175        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1176    }
1177
1178    #[test]
1179    fn test_mixed_operators() {
1180        let commands = extract_commands("ls; echo hello && rm -rf /").expect("parse failed");
1181        assert_eq!(commands, vec!["ls", "echo hello", "rm -rf /"]);
1182    }
1183
1184    #[test]
1185    fn test_no_spaces_around_operators() {
1186        let commands = extract_commands("ls&&rm").expect("parse failed");
1187        assert_eq!(commands, vec!["ls", "rm"]);
1188    }
1189
1190    #[test]
1191    fn test_nested_command_substitution() {
1192        let commands = extract_commands("echo $(cat $(whoami).txt)").expect("parse failed");
1193        assert!(commands.iter().any(|c| c.contains("echo")));
1194        assert!(commands.iter().any(|c| c.contains("cat")));
1195        assert!(commands.contains(&"whoami".to_string()));
1196    }
1197
1198    #[test]
1199    fn test_empty_command() {
1200        let commands = extract_commands("").expect("parse failed");
1201        assert!(commands.is_empty());
1202    }
1203
1204    #[test]
1205    fn test_invalid_syntax_returns_none() {
1206        let result = extract_commands("ls &&");
1207        assert!(result.is_none());
1208    }
1209
1210    #[test]
1211    fn test_unparsable_nested_substitution_returns_none() {
1212        let result = extract_commands("echo $(ls &&)");
1213        assert!(result.is_none());
1214    }
1215
1216    #[test]
1217    fn test_unparsable_nested_backtick_substitution_returns_none() {
1218        let result = extract_commands("echo `ls &&`");
1219        assert!(result.is_none());
1220    }
1221
1222    #[test]
1223    fn test_redirect_write_includes_target_path() {
1224        let commands = extract_commands("echo hello > /etc/passwd").expect("parse failed");
1225        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
1226    }
1227
1228    #[test]
1229    fn test_redirect_append_includes_target_path() {
1230        let commands = extract_commands("cat file >> /tmp/log").expect("parse failed");
1231        assert_eq!(commands, vec!["cat file", ">> /tmp/log"]);
1232    }
1233
1234    #[test]
1235    fn test_fd_redirect_handled_gracefully() {
1236        let commands = extract_commands("cmd 2>&1").expect("parse failed");
1237        assert_eq!(commands, vec!["cmd"]);
1238    }
1239
1240    #[test]
1241    fn test_input_redirect() {
1242        let commands = extract_commands("sort < /tmp/input").expect("parse failed");
1243        assert_eq!(commands, vec!["sort", "< /tmp/input"]);
1244    }
1245
1246    #[test]
1247    fn test_multiple_redirects() {
1248        let commands = extract_commands("cmd > /tmp/out 2> /tmp/err").expect("parse failed");
1249        assert_eq!(commands, vec!["cmd", "> /tmp/out", "2> /tmp/err"]);
1250    }
1251
1252    #[test]
1253    fn test_prefix_position_redirect() {
1254        let commands = extract_commands("> /tmp/out echo hello").expect("parse failed");
1255        assert_eq!(commands, vec!["echo hello", "> /tmp/out"]);
1256    }
1257
1258    #[test]
1259    fn test_redirect_with_variable_expansion() {
1260        let commands = extract_commands("echo > $HOME/file").expect("parse failed");
1261        assert_eq!(commands, vec!["echo", "> $HOME/file"]);
1262    }
1263
1264    #[test]
1265    fn test_output_and_error_redirect() {
1266        let commands = extract_commands("cmd &> /tmp/all").expect("parse failed");
1267        assert_eq!(commands, vec!["cmd", "&> /tmp/all"]);
1268    }
1269
1270    #[test]
1271    fn test_append_output_and_error_redirect() {
1272        let commands = extract_commands("cmd &>> /tmp/all").expect("parse failed");
1273        assert_eq!(commands, vec!["cmd", "&>> /tmp/all"]);
1274    }
1275
1276    #[test]
1277    fn test_redirect_in_chained_command() {
1278        let commands =
1279            extract_commands("echo hello > /tmp/out && cat /tmp/out").expect("parse failed");
1280        assert_eq!(commands, vec!["echo hello", "> /tmp/out", "cat /tmp/out"]);
1281    }
1282
1283    #[test]
1284    fn test_here_string_dropped_from_normalized_output() {
1285        let commands = extract_commands("cat <<< 'hello'").expect("parse failed");
1286        assert_eq!(commands, vec!["cat"]);
1287    }
1288
1289    #[test]
1290    fn test_brace_group_redirect() {
1291        let commands = extract_commands("{ echo hello; } > /etc/passwd").expect("parse failed");
1292        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
1293    }
1294
1295    #[test]
1296    fn test_subshell_redirect() {
1297        let commands = extract_commands("(cmd) > /etc/passwd").expect("parse failed");
1298        assert_eq!(commands, vec!["cmd", "> /etc/passwd"]);
1299    }
1300
1301    #[test]
1302    fn test_for_loop_redirect() {
1303        let commands =
1304            extract_commands("for f in *; do cat \"$f\"; done > /tmp/out").expect("parse failed");
1305        assert_eq!(commands, vec!["cat $f", "> /tmp/out"]);
1306    }
1307
1308    #[test]
1309    fn test_brace_group_multi_command_redirect() {
1310        let commands =
1311            extract_commands("{ echo hello; cat; } > /etc/passwd").expect("parse failed");
1312        assert_eq!(commands, vec!["echo hello", "cat", "> /etc/passwd"]);
1313    }
1314
1315    #[test]
1316    fn test_quoted_redirect_target_is_normalized() {
1317        let commands = extract_commands("echo hello > '/etc/passwd'").expect("parse failed");
1318        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
1319    }
1320
1321    #[test]
1322    fn test_redirect_without_space() {
1323        let commands = extract_commands("echo hello >/etc/passwd").expect("parse failed");
1324        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
1325    }
1326
1327    #[test]
1328    fn test_clobber_redirect() {
1329        let commands = extract_commands("cmd >| /tmp/file").expect("parse failed");
1330        assert_eq!(commands, vec!["cmd", ">| /tmp/file"]);
1331    }
1332
1333    #[test]
1334    fn test_fd_to_fd_redirect_skipped() {
1335        let commands = extract_commands("cmd 1>&2").expect("parse failed");
1336        assert_eq!(commands, vec!["cmd"]);
1337    }
1338
1339    #[test]
1340    fn test_bare_redirect_returns_none() {
1341        let result = extract_commands("> /etc/passwd");
1342        assert!(result.is_none());
1343    }
1344
1345    #[test]
1346    fn test_arithmetic_with_redirect_returns_none() {
1347        let result = extract_commands("(( x = 1 )) > /tmp/file");
1348        assert!(result.is_none());
1349    }
1350
1351    #[test]
1352    fn test_redirect_target_with_command_substitution() {
1353        let commands = extract_commands("echo > $(mktemp)").expect("parse failed");
1354        assert_eq!(commands, vec!["echo", "> $(mktemp)", "mktemp"]);
1355    }
1356
1357    #[test]
1358    fn test_nested_compound_redirects() {
1359        let commands = extract_commands("{ echo > /tmp/a; } > /tmp/b").expect("parse failed");
1360        assert_eq!(commands, vec!["echo", "> /tmp/a", "> /tmp/b"]);
1361    }
1362
1363    #[test]
1364    fn test_while_loop_redirect() {
1365        let commands =
1366            extract_commands("while true; do echo line; done > /tmp/log").expect("parse failed");
1367        assert_eq!(commands, vec!["true", "echo line", "> /tmp/log"]);
1368    }
1369
1370    #[test]
1371    fn test_if_clause_redirect() {
1372        let commands =
1373            extract_commands("if true; then echo yes; fi > /tmp/out").expect("parse failed");
1374        assert_eq!(commands, vec!["true", "echo yes", "> /tmp/out"]);
1375    }
1376
1377    #[test]
1378    fn test_pipe_with_redirect_on_last_command() {
1379        let commands = extract_commands("ls | grep foo > /tmp/out").expect("parse failed");
1380        assert_eq!(commands, vec!["ls", "grep foo", "> /tmp/out"]);
1381    }
1382
1383    #[test]
1384    fn test_pipe_with_stderr_redirect_on_first_command() {
1385        let commands = extract_commands("ls 2>/dev/null | grep foo").expect("parse failed");
1386        assert_eq!(commands, vec!["ls", "grep foo"]);
1387    }
1388
1389    #[test]
1390    fn test_function_definition_redirect() {
1391        let commands = extract_commands("f() { echo hi; } > /tmp/out").expect("parse failed");
1392        assert_eq!(commands, vec!["echo hi", "> /tmp/out"]);
1393    }
1394
1395    #[test]
1396    fn test_read_and_write_redirect() {
1397        let commands = extract_commands("cmd <> /dev/tty").expect("parse failed");
1398        assert_eq!(commands, vec!["cmd", "<> /dev/tty"]);
1399    }
1400
1401    #[test]
1402    fn test_case_clause_with_redirect() {
1403        let commands =
1404            extract_commands("case $x in a) echo hi;; esac > /tmp/out").expect("parse failed");
1405        assert_eq!(commands, vec!["echo hi", "> /tmp/out"]);
1406    }
1407
1408    #[test]
1409    fn test_until_loop_with_redirect() {
1410        let commands =
1411            extract_commands("until false; do echo line; done > /tmp/log").expect("parse failed");
1412        assert_eq!(commands, vec!["false", "echo line", "> /tmp/log"]);
1413    }
1414
1415    #[test]
1416    fn test_arithmetic_for_clause_with_redirect() {
1417        let commands = extract_commands("for ((i=0; i<10; i++)); do echo $i; done > /tmp/out")
1418            .expect("parse failed");
1419        assert_eq!(commands, vec!["echo $i", "> /tmp/out"]);
1420    }
1421
1422    #[test]
1423    fn test_if_elif_else_with_redirect() {
1424        let commands = extract_commands(
1425            "if true; then echo a; elif false; then echo b; else echo c; fi > /tmp/out",
1426        )
1427        .expect("parse failed");
1428        assert_eq!(
1429            commands,
1430            vec!["true", "echo a", "false", "echo b", "echo c", "> /tmp/out"]
1431        );
1432    }
1433
1434    #[test]
1435    fn test_multiple_redirects_on_compound_command() {
1436        let commands = extract_commands("{ cmd; } > /tmp/out 2> /tmp/err").expect("parse failed");
1437        assert_eq!(commands, vec!["cmd", "> /tmp/out", "2> /tmp/err"]);
1438    }
1439
1440    #[test]
1441    fn test_here_document_command_substitution_extracted() {
1442        let commands = extract_commands("cat <<EOF\n$(rm -rf /)\nEOF").expect("parse failed");
1443        assert!(commands.iter().any(|c| c.contains("cat")));
1444        assert!(commands.contains(&"rm -rf /".to_string()));
1445    }
1446
1447    #[test]
1448    fn test_here_document_quoted_delimiter_no_extraction() {
1449        let commands = extract_commands("cat <<'EOF'\n$(rm -rf /)\nEOF").expect("parse failed");
1450        assert_eq!(commands, vec!["cat"]);
1451    }
1452
1453    #[test]
1454    fn test_here_document_backtick_substitution_extracted() {
1455        let commands = extract_commands("cat <<EOF\n`whoami`\nEOF").expect("parse failed");
1456        assert!(commands.iter().any(|c| c.contains("cat")));
1457        assert!(commands.contains(&"whoami".to_string()));
1458    }
1459
1460    #[test]
1461    fn test_brace_group_redirect_with_command_substitution() {
1462        let commands = extract_commands("{ echo hello; } > $(mktemp)").expect("parse failed");
1463        assert!(commands.contains(&"echo hello".to_string()));
1464        assert!(commands.contains(&"mktemp".to_string()));
1465    }
1466
1467    #[test]
1468    fn test_function_definition_redirect_with_command_substitution() {
1469        let commands = extract_commands("f() { echo hi; } > $(mktemp)").expect("parse failed");
1470        assert!(commands.contains(&"echo hi".to_string()));
1471        assert!(commands.contains(&"mktemp".to_string()));
1472    }
1473
1474    #[test]
1475    fn test_brace_group_redirect_with_process_substitution() {
1476        let commands = extract_commands("{ cat; } > >(tee /tmp/log)").expect("parse failed");
1477        assert!(commands.contains(&"cat".to_string()));
1478        assert!(commands.contains(&"tee /tmp/log".to_string()));
1479    }
1480
1481    #[test]
1482    fn test_redirect_to_dev_null_skipped() {
1483        let commands = extract_commands("cmd > /dev/null").expect("parse failed");
1484        assert_eq!(commands, vec!["cmd"]);
1485    }
1486
1487    #[test]
1488    fn test_stderr_redirect_to_dev_null_skipped() {
1489        let commands = extract_commands("cmd 2>/dev/null").expect("parse failed");
1490        assert_eq!(commands, vec!["cmd"]);
1491    }
1492
1493    #[test]
1494    fn test_stderr_redirect_to_dev_null_with_space_skipped() {
1495        let commands = extract_commands("cmd 2> /dev/null").expect("parse failed");
1496        assert_eq!(commands, vec!["cmd"]);
1497    }
1498
1499    #[test]
1500    fn test_append_redirect_to_dev_null_skipped() {
1501        let commands = extract_commands("cmd >> /dev/null").expect("parse failed");
1502        assert_eq!(commands, vec!["cmd"]);
1503    }
1504
1505    #[test]
1506    fn test_output_and_error_redirect_to_dev_null_skipped() {
1507        let commands = extract_commands("cmd &>/dev/null").expect("parse failed");
1508        assert_eq!(commands, vec!["cmd"]);
1509    }
1510
1511    #[test]
1512    fn test_append_output_and_error_redirect_to_dev_null_skipped() {
1513        let commands = extract_commands("cmd &>>/dev/null").expect("parse failed");
1514        assert_eq!(commands, vec!["cmd"]);
1515    }
1516
1517    #[test]
1518    fn test_quoted_dev_null_redirect_skipped() {
1519        let commands = extract_commands("cmd 2>'/dev/null'").expect("parse failed");
1520        assert_eq!(commands, vec!["cmd"]);
1521    }
1522
1523    #[test]
1524    fn test_redirect_to_real_file_still_included() {
1525        let commands = extract_commands("echo hello > /etc/passwd").expect("parse failed");
1526        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
1527    }
1528
1529    #[test]
1530    fn test_dev_null_redirect_in_chained_command() {
1531        let commands =
1532            extract_commands("git log 2>/dev/null || echo fallback").expect("parse failed");
1533        assert_eq!(commands, vec!["git log", "echo fallback"]);
1534    }
1535
1536    #[test]
1537    fn test_mixed_safe_and_unsafe_redirects() {
1538        let commands = extract_commands("cmd > /tmp/out 2>/dev/null").expect("parse failed");
1539        assert_eq!(commands, vec!["cmd", "> /tmp/out"]);
1540    }
1541
1542    #[test]
1543    fn test_scalar_env_var_prefix_included_in_extracted_command() {
1544        let commands = extract_commands("PAGER=blah git status").expect("parse failed");
1545        assert_eq!(commands, vec!["PAGER=blah git status"]);
1546    }
1547
1548    #[test]
1549    fn test_multiple_scalar_assignments_preserved_in_order() {
1550        let commands = extract_commands("A=1 B=2 git log").expect("parse failed");
1551        assert_eq!(commands, vec!["A=1 B=2 git log"]);
1552    }
1553
1554    #[test]
1555    fn test_assignment_quoting_dropped_when_safe() {
1556        let commands = extract_commands("PAGER='curl' git log").expect("parse failed");
1557        assert_eq!(commands, vec!["PAGER=curl git log"]);
1558    }
1559
1560    #[test]
1561    fn test_assignment_quoting_preserved_for_whitespace() {
1562        let commands = extract_commands("PAGER='less -R' git log").expect("parse failed");
1563        assert_eq!(commands, vec!["PAGER='less -R' git log"]);
1564    }
1565
1566    #[test]
1567    fn test_assignment_quoting_preserved_for_semicolon() {
1568        let commands = extract_commands("PAGER='a;b' git log").expect("parse failed");
1569        assert_eq!(commands, vec!["PAGER='a;b' git log"]);
1570    }
1571
1572    #[test]
1573    fn test_array_assignments_ignored_for_prefix_matching_output() {
1574        let commands = extract_commands("FOO=(a b) git status").expect("parse failed");
1575        assert_eq!(commands, vec!["git status"]);
1576    }
1577
1578    #[test]
1579    fn test_extract_terminal_command_prefix_includes_env_var_prefix_and_subcommand() {
1580        let prefix = extract_terminal_command_prefix("PAGER=blah git log --oneline")
1581            .expect("expected terminal command prefix");
1582
1583        assert_eq!(
1584            prefix,
1585            TerminalCommandPrefix {
1586                normalized: "PAGER=blah git log".to_string(),
1587                display: "PAGER=blah git log".to_string(),
1588                tokens: vec![
1589                    "PAGER=blah".to_string(),
1590                    "git".to_string(),
1591                    "log".to_string(),
1592                ],
1593                command: "git".to_string(),
1594                subcommand: Some("log".to_string()),
1595            }
1596        );
1597    }
1598
1599    #[test]
1600    fn test_extract_terminal_command_prefix_preserves_required_assignment_quotes_in_display_and_normalized()
1601     {
1602        let prefix = extract_terminal_command_prefix("PAGER='less -R' git log")
1603            .expect("expected terminal command prefix");
1604
1605        assert_eq!(
1606            prefix,
1607            TerminalCommandPrefix {
1608                normalized: "PAGER='less -R' git log".to_string(),
1609                display: "PAGER='less -R' git log".to_string(),
1610                tokens: vec![
1611                    "PAGER='less -R'".to_string(),
1612                    "git".to_string(),
1613                    "log".to_string(),
1614                ],
1615                command: "git".to_string(),
1616                subcommand: Some("log".to_string()),
1617            }
1618        );
1619    }
1620
1621    #[test]
1622    fn test_extract_terminal_command_prefix_skips_redirects_before_subcommand() {
1623        let prefix = extract_terminal_command_prefix("git 2>/dev/null log --oneline")
1624            .expect("expected terminal command prefix");
1625
1626        assert_eq!(
1627            prefix,
1628            TerminalCommandPrefix {
1629                normalized: "git log".to_string(),
1630                display: "git 2>/dev/null log".to_string(),
1631                tokens: vec!["git".to_string(), "log".to_string()],
1632                command: "git".to_string(),
1633                subcommand: Some("log".to_string()),
1634            }
1635        );
1636    }
1637
1638    #[test]
1639    fn test_validate_terminal_command_rejects_parameter_expansion() {
1640        assert_eq!(
1641            validate_terminal_command("echo $HOME"),
1642            TerminalCommandValidation::Unsafe
1643        );
1644    }
1645
1646    #[test]
1647    fn test_validate_terminal_command_rejects_braced_parameter_expansion() {
1648        assert_eq!(
1649            validate_terminal_command("echo ${HOME}"),
1650            TerminalCommandValidation::Unsafe
1651        );
1652    }
1653
1654    #[test]
1655    fn test_validate_terminal_command_rejects_special_parameters() {
1656        assert_eq!(
1657            validate_terminal_command("echo $?"),
1658            TerminalCommandValidation::Unsafe
1659        );
1660        assert_eq!(
1661            validate_terminal_command("echo $$"),
1662            TerminalCommandValidation::Unsafe
1663        );
1664        assert_eq!(
1665            validate_terminal_command("echo $@"),
1666            TerminalCommandValidation::Unsafe
1667        );
1668    }
1669
1670    #[test]
1671    fn test_validate_terminal_command_rejects_command_substitution() {
1672        assert_eq!(
1673            validate_terminal_command("echo $(whoami)"),
1674            TerminalCommandValidation::Unsafe
1675        );
1676    }
1677
1678    #[test]
1679    fn test_validate_terminal_command_rejects_backticks() {
1680        assert_eq!(
1681            validate_terminal_command("echo `whoami`"),
1682            TerminalCommandValidation::Unsafe
1683        );
1684    }
1685
1686    #[test]
1687    fn test_validate_terminal_command_rejects_arithmetic_expansion() {
1688        assert_eq!(
1689            validate_terminal_command("echo $((1 + 1))"),
1690            TerminalCommandValidation::Unsafe
1691        );
1692    }
1693
1694    #[test]
1695    fn test_validate_terminal_command_rejects_process_substitution() {
1696        assert_eq!(
1697            validate_terminal_command("cat <(ls)"),
1698            TerminalCommandValidation::Unsafe
1699        );
1700        assert_eq!(
1701            validate_terminal_command("ls >(cat)"),
1702            TerminalCommandValidation::Unsafe
1703        );
1704    }
1705
1706    #[test]
1707    fn test_validate_terminal_command_rejects_forbidden_constructs_in_env_var_assignments() {
1708        assert_eq!(
1709            validate_terminal_command("PAGER=$HOME git log"),
1710            TerminalCommandValidation::Unsafe
1711        );
1712        assert_eq!(
1713            validate_terminal_command("PAGER=$(whoami) git log"),
1714            TerminalCommandValidation::Unsafe
1715        );
1716    }
1717
1718    #[test]
1719    fn test_validate_terminal_command_returns_unsupported_for_parse_failure() {
1720        assert_eq!(
1721            validate_terminal_command("echo $(ls &&)"),
1722            TerminalCommandValidation::Unsupported
1723        );
1724    }
1725
1726    #[test]
1727    fn test_validate_terminal_command_rejects_substitution_in_case_pattern() {
1728        assert_ne!(
1729            validate_terminal_command("case x in $(echo y)) echo z;; esac"),
1730            TerminalCommandValidation::Safe
1731        );
1732    }
1733
1734    #[test]
1735    fn test_validate_terminal_command_safe_case_clause_without_substitutions() {
1736        assert_eq!(
1737            validate_terminal_command("case x in foo) echo hello;; esac"),
1738            TerminalCommandValidation::Safe
1739        );
1740    }
1741
1742    #[test]
1743    fn test_validate_terminal_command_rejects_substitution_in_arithmetic_for_clause() {
1744        assert_ne!(
1745            validate_terminal_command("for ((i=$(echo 0); i<3; i++)); do echo hello; done"),
1746            TerminalCommandValidation::Safe
1747        );
1748    }
1749
1750    #[test]
1751    fn test_validate_terminal_command_rejects_arithmetic_for_clause_unconditionally() {
1752        assert_eq!(
1753            validate_terminal_command("for ((i=0; i<3; i++)); do echo hello; done"),
1754            TerminalCommandValidation::Unsafe
1755        );
1756    }
1757}