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 normalize_io_redirect(redirect: &ast::IoRedirect) -> Option<RedirectNormalization> {
236    match redirect {
237        ast::IoRedirect::File(fd, kind, target) => {
238            let target_word = match target {
239                ast::IoFileRedirectTarget::Filename(word) => word,
240                _ => return Some(RedirectNormalization::Skip),
241            };
242            let operator = match kind {
243                ast::IoFileRedirectKind::Read => "<",
244                ast::IoFileRedirectKind::Write => ">",
245                ast::IoFileRedirectKind::Append => ">>",
246                ast::IoFileRedirectKind::ReadAndWrite => "<>",
247                ast::IoFileRedirectKind::Clobber => ">|",
248                // The parser pairs DuplicateInput/DuplicateOutput with
249                // IoFileRedirectTarget::Duplicate (not Filename), so the
250                // target match above will return Skip before we reach here.
251                // These arms are kept for defensiveness.
252                ast::IoFileRedirectKind::DuplicateInput => "<&",
253                ast::IoFileRedirectKind::DuplicateOutput => ">&",
254            };
255            let fd_prefix = match fd {
256                Some(fd) => fd.to_string(),
257                None => String::new(),
258            };
259            let normalized = normalize_word(target_word)?;
260            Some(RedirectNormalization::Normalized(format!(
261                "{}{} {}",
262                fd_prefix, operator, normalized
263            )))
264        }
265        ast::IoRedirect::OutputAndError(word, append) => {
266            let operator = if *append { "&>>" } else { "&>" };
267            let normalized = normalize_word(word)?;
268            Some(RedirectNormalization::Normalized(format!(
269                "{} {}",
270                operator, normalized
271            )))
272        }
273        ast::IoRedirect::HereDocument(_, _) | ast::IoRedirect::HereString(_, _) => {
274            Some(RedirectNormalization::Skip)
275        }
276    }
277}
278
279fn extract_commands_from_command_prefix(
280    prefix: &ast::CommandPrefix,
281    commands: &mut Vec<String>,
282) -> Option<()> {
283    for item in &prefix.0 {
284        extract_commands_from_prefix_or_suffix_item(item, commands)?;
285    }
286    Some(())
287}
288
289fn extract_commands_from_command_suffix(
290    suffix: &ast::CommandSuffix,
291    commands: &mut Vec<String>,
292) -> Option<()> {
293    for item in &suffix.0 {
294        extract_commands_from_prefix_or_suffix_item(item, commands)?;
295    }
296    Some(())
297}
298
299fn extract_commands_from_prefix_or_suffix_item(
300    item: &ast::CommandPrefixOrSuffixItem,
301    commands: &mut Vec<String>,
302) -> Option<()> {
303    match item {
304        ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => {
305            extract_commands_from_io_redirect(redirect, commands)?;
306        }
307        ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, _word) => {
308            extract_commands_from_assignment(assignment, commands)?;
309        }
310        ast::CommandPrefixOrSuffixItem::Word(word) => {
311            extract_commands_from_word(word, commands)?;
312        }
313        ast::CommandPrefixOrSuffixItem::ProcessSubstitution(_kind, subshell) => {
314            extract_commands_from_compound_list(&subshell.list, commands)?;
315        }
316    }
317    Some(())
318}
319
320fn extract_commands_from_io_redirect(
321    redirect: &ast::IoRedirect,
322    commands: &mut Vec<String>,
323) -> Option<()> {
324    match redirect {
325        ast::IoRedirect::File(_fd, _kind, target) => match target {
326            ast::IoFileRedirectTarget::ProcessSubstitution(_kind, subshell) => {
327                extract_commands_from_compound_list(&subshell.list, commands)?;
328            }
329            ast::IoFileRedirectTarget::Filename(word) => {
330                extract_commands_from_word(word, commands)?;
331            }
332            _ => {}
333        },
334        ast::IoRedirect::HereDocument(_fd, here_doc) => {
335            if here_doc.requires_expansion {
336                extract_commands_from_word(&here_doc.doc, commands)?;
337            }
338        }
339        ast::IoRedirect::HereString(_fd, word) => {
340            extract_commands_from_word(word, commands)?;
341        }
342        ast::IoRedirect::OutputAndError(word, _) => {
343            extract_commands_from_word(word, commands)?;
344        }
345    }
346    Some(())
347}
348
349fn extract_commands_from_assignment(
350    assignment: &ast::Assignment,
351    commands: &mut Vec<String>,
352) -> Option<()> {
353    match &assignment.value {
354        ast::AssignmentValue::Scalar(word) => {
355            extract_commands_from_word(word, commands)?;
356        }
357        ast::AssignmentValue::Array(words) => {
358            for (opt_word, word) in words {
359                if let Some(w) = opt_word {
360                    extract_commands_from_word(w, commands)?;
361                }
362                extract_commands_from_word(word, commands)?;
363            }
364        }
365    }
366    Some(())
367}
368
369fn extract_commands_from_word(word: &ast::Word, commands: &mut Vec<String>) -> Option<()> {
370    let options = ParserOptions::default();
371    let pieces = brush_parser::word::parse(&word.value, &options).ok()?;
372    for piece_with_source in pieces {
373        extract_commands_from_word_piece(&piece_with_source.piece, commands)?;
374    }
375    Some(())
376}
377
378fn extract_commands_from_word_piece(piece: &WordPiece, commands: &mut Vec<String>) -> Option<()> {
379    match piece {
380        WordPiece::CommandSubstitution(cmd_str)
381        | WordPiece::BackquotedCommandSubstitution(cmd_str) => {
382            let nested_commands = extract_commands(cmd_str)?;
383            commands.extend(nested_commands);
384        }
385        WordPiece::DoubleQuotedSequence(pieces)
386        | WordPiece::GettextDoubleQuotedSequence(pieces) => {
387            for inner_piece_with_source in pieces {
388                extract_commands_from_word_piece(&inner_piece_with_source.piece, commands)?;
389            }
390        }
391        WordPiece::EscapeSequence(_)
392        | WordPiece::SingleQuotedText(_)
393        | WordPiece::Text(_)
394        | WordPiece::AnsiCQuotedText(_)
395        | WordPiece::TildePrefix(_)
396        | WordPiece::ParameterExpansion(_)
397        | WordPiece::ArithmeticExpression(_) => {}
398    }
399    Some(())
400}
401
402fn extract_commands_from_compound_command(
403    compound_command: &ast::CompoundCommand,
404    commands: &mut Vec<String>,
405) -> Option<usize> {
406    match compound_command {
407        ast::CompoundCommand::BraceGroup(brace_group) => {
408            let body_start = commands.len();
409            extract_commands_from_compound_list(&brace_group.list, commands)?;
410            Some(body_start)
411        }
412        ast::CompoundCommand::Subshell(subshell) => {
413            let body_start = commands.len();
414            extract_commands_from_compound_list(&subshell.list, commands)?;
415            Some(body_start)
416        }
417        ast::CompoundCommand::ForClause(for_clause) => {
418            if let Some(words) = &for_clause.values {
419                for word in words {
420                    extract_commands_from_word(word, commands)?;
421                }
422            }
423            let body_start = commands.len();
424            extract_commands_from_do_group(&for_clause.body, commands)?;
425            Some(body_start)
426        }
427        ast::CompoundCommand::CaseClause(case_clause) => {
428            extract_commands_from_word(&case_clause.value, commands)?;
429            let body_start = commands.len();
430            for item in &case_clause.cases {
431                if let Some(body) = &item.cmd {
432                    extract_commands_from_compound_list(body, commands)?;
433                }
434            }
435            Some(body_start)
436        }
437        ast::CompoundCommand::IfClause(if_clause) => {
438            extract_commands_from_compound_list(&if_clause.condition, commands)?;
439            let body_start = commands.len();
440            extract_commands_from_compound_list(&if_clause.then, commands)?;
441            if let Some(elses) = &if_clause.elses {
442                for else_item in elses {
443                    if let Some(condition) = &else_item.condition {
444                        extract_commands_from_compound_list(condition, commands)?;
445                    }
446                    extract_commands_from_compound_list(&else_item.body, commands)?;
447                }
448            }
449            Some(body_start)
450        }
451        ast::CompoundCommand::WhileClause(while_clause)
452        | ast::CompoundCommand::UntilClause(while_clause) => {
453            extract_commands_from_compound_list(&while_clause.0, commands)?;
454            let body_start = commands.len();
455            extract_commands_from_do_group(&while_clause.1, commands)?;
456            Some(body_start)
457        }
458        ast::CompoundCommand::ArithmeticForClause(arith_for) => {
459            let body_start = commands.len();
460            extract_commands_from_do_group(&arith_for.body, commands)?;
461            Some(body_start)
462        }
463        ast::CompoundCommand::Arithmetic(_arith_cmd) => Some(commands.len()),
464    }
465}
466
467fn extract_commands_from_do_group(
468    do_group: &ast::DoGroupCommand,
469    commands: &mut Vec<String>,
470) -> Option<()> {
471    extract_commands_from_compound_list(&do_group.list, commands)
472}
473
474fn extract_commands_from_function_body(
475    func_body: &ast::FunctionBody,
476    commands: &mut Vec<String>,
477) -> Option<()> {
478    let body_start = extract_commands_from_compound_command(&func_body.0, commands)?;
479    if let Some(redirect_list) = &func_body.1 {
480        let mut normalized_redirects = Vec::new();
481        for redirect in &redirect_list.0 {
482            match normalize_io_redirect(redirect)? {
483                RedirectNormalization::Normalized(s) => normalized_redirects.push(s),
484                RedirectNormalization::Skip => {}
485            }
486        }
487        if !normalized_redirects.is_empty() {
488            if body_start >= commands.len() {
489                return None;
490            }
491            commands.extend(normalized_redirects);
492        }
493        for redirect in &redirect_list.0 {
494            extract_commands_from_io_redirect(redirect, commands)?;
495        }
496    }
497    Some(())
498}
499
500fn extract_commands_from_extended_test_expr(
501    test_expr: &ast::ExtendedTestExprCommand,
502    commands: &mut Vec<String>,
503) -> Option<()> {
504    extract_commands_from_extended_test_expr_inner(&test_expr.expr, commands)
505}
506
507fn extract_commands_from_extended_test_expr_inner(
508    expr: &ast::ExtendedTestExpr,
509    commands: &mut Vec<String>,
510) -> Option<()> {
511    match expr {
512        ast::ExtendedTestExpr::Not(inner) => {
513            extract_commands_from_extended_test_expr_inner(inner, commands)?;
514        }
515        ast::ExtendedTestExpr::And(left, right) | ast::ExtendedTestExpr::Or(left, right) => {
516            extract_commands_from_extended_test_expr_inner(left, commands)?;
517            extract_commands_from_extended_test_expr_inner(right, commands)?;
518        }
519        ast::ExtendedTestExpr::Parenthesized(inner) => {
520            extract_commands_from_extended_test_expr_inner(inner, commands)?;
521        }
522        ast::ExtendedTestExpr::UnaryTest(_, word) => {
523            extract_commands_from_word(word, commands)?;
524        }
525        ast::ExtendedTestExpr::BinaryTest(_, word1, word2) => {
526            extract_commands_from_word(word1, commands)?;
527            extract_commands_from_word(word2, commands)?;
528        }
529    }
530    Some(())
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn test_simple_command() {
539        let commands = extract_commands("ls").expect("parse failed");
540        assert_eq!(commands, vec!["ls"]);
541    }
542
543    #[test]
544    fn test_command_with_args() {
545        let commands = extract_commands("ls -la /tmp").expect("parse failed");
546        assert_eq!(commands, vec!["ls -la /tmp"]);
547    }
548
549    #[test]
550    fn test_single_quoted_argument_is_normalized() {
551        let commands = extract_commands("rm -rf '/'").expect("parse failed");
552        assert_eq!(commands, vec!["rm -rf /"]);
553    }
554
555    #[test]
556    fn test_single_quoted_command_name_is_normalized() {
557        let commands = extract_commands("'rm' -rf /").expect("parse failed");
558        assert_eq!(commands, vec!["rm -rf /"]);
559    }
560
561    #[test]
562    fn test_double_quoted_argument_is_normalized() {
563        let commands = extract_commands("rm -rf \"/\"").expect("parse failed");
564        assert_eq!(commands, vec!["rm -rf /"]);
565    }
566
567    #[test]
568    fn test_double_quoted_command_name_is_normalized() {
569        let commands = extract_commands("\"rm\" -rf /").expect("parse failed");
570        assert_eq!(commands, vec!["rm -rf /"]);
571    }
572
573    #[test]
574    fn test_escaped_argument_is_normalized() {
575        let commands = extract_commands("rm -rf \\/").expect("parse failed");
576        assert_eq!(commands, vec!["rm -rf /"]);
577    }
578
579    #[test]
580    fn test_partial_quoting_command_name_is_normalized() {
581        let commands = extract_commands("r'm' -rf /").expect("parse failed");
582        assert_eq!(commands, vec!["rm -rf /"]);
583    }
584
585    #[test]
586    fn test_partial_quoting_flag_is_normalized() {
587        let commands = extract_commands("rm -r'f' /").expect("parse failed");
588        assert_eq!(commands, vec!["rm -rf /"]);
589    }
590
591    #[test]
592    fn test_quoted_bypass_in_chained_command() {
593        let commands = extract_commands("ls && 'rm' -rf '/'").expect("parse failed");
594        assert_eq!(commands, vec!["ls", "rm -rf /"]);
595    }
596
597    #[test]
598    fn test_tilde_preserved_after_normalization() {
599        let commands = extract_commands("rm -rf ~").expect("parse failed");
600        assert_eq!(commands, vec!["rm -rf ~"]);
601    }
602
603    #[test]
604    fn test_quoted_tilde_normalized() {
605        let commands = extract_commands("rm -rf '~'").expect("parse failed");
606        assert_eq!(commands, vec!["rm -rf ~"]);
607    }
608
609    #[test]
610    fn test_parameter_expansion_preserved() {
611        let commands = extract_commands("rm -rf $HOME").expect("parse failed");
612        assert_eq!(commands, vec!["rm -rf $HOME"]);
613    }
614
615    #[test]
616    fn test_braced_parameter_expansion_preserved() {
617        let commands = extract_commands("rm -rf ${HOME}").expect("parse failed");
618        assert_eq!(commands, vec!["rm -rf ${HOME}"]);
619    }
620
621    #[test]
622    fn test_and_operator() {
623        let commands = extract_commands("ls && rm -rf /").expect("parse failed");
624        assert_eq!(commands, vec!["ls", "rm -rf /"]);
625    }
626
627    #[test]
628    fn test_or_operator() {
629        let commands = extract_commands("ls || rm -rf /").expect("parse failed");
630        assert_eq!(commands, vec!["ls", "rm -rf /"]);
631    }
632
633    #[test]
634    fn test_semicolon() {
635        let commands = extract_commands("ls; rm -rf /").expect("parse failed");
636        assert_eq!(commands, vec!["ls", "rm -rf /"]);
637    }
638
639    #[test]
640    fn test_pipe() {
641        let commands = extract_commands("ls | xargs rm -rf").expect("parse failed");
642        assert_eq!(commands, vec!["ls", "xargs rm -rf"]);
643    }
644
645    #[test]
646    fn test_background() {
647        let commands = extract_commands("ls & rm -rf /").expect("parse failed");
648        assert_eq!(commands, vec!["ls", "rm -rf /"]);
649    }
650
651    #[test]
652    fn test_command_substitution_dollar() {
653        let commands = extract_commands("echo $(whoami)").expect("parse failed");
654        assert!(commands.iter().any(|c| c.contains("echo")));
655        assert!(commands.contains(&"whoami".to_string()));
656    }
657
658    #[test]
659    fn test_command_substitution_backticks() {
660        let commands = extract_commands("echo `whoami`").expect("parse failed");
661        assert!(commands.iter().any(|c| c.contains("echo")));
662        assert!(commands.contains(&"whoami".to_string()));
663    }
664
665    #[test]
666    fn test_process_substitution_input() {
667        let commands = extract_commands("cat <(ls)").expect("parse failed");
668        assert!(commands.iter().any(|c| c.contains("cat")));
669        assert!(commands.contains(&"ls".to_string()));
670    }
671
672    #[test]
673    fn test_process_substitution_output() {
674        let commands = extract_commands("ls >(cat)").expect("parse failed");
675        assert!(commands.iter().any(|c| c.contains("ls")));
676        assert!(commands.contains(&"cat".to_string()));
677    }
678
679    #[test]
680    fn test_newline_separator() {
681        let commands = extract_commands("ls\nrm -rf /").expect("parse failed");
682        assert_eq!(commands, vec!["ls", "rm -rf /"]);
683    }
684
685    #[test]
686    fn test_subshell() {
687        let commands = extract_commands("(ls && rm -rf /)").expect("parse failed");
688        assert_eq!(commands, vec!["ls", "rm -rf /"]);
689    }
690
691    #[test]
692    fn test_mixed_operators() {
693        let commands = extract_commands("ls; echo hello && rm -rf /").expect("parse failed");
694        assert_eq!(commands, vec!["ls", "echo hello", "rm -rf /"]);
695    }
696
697    #[test]
698    fn test_no_spaces_around_operators() {
699        let commands = extract_commands("ls&&rm").expect("parse failed");
700        assert_eq!(commands, vec!["ls", "rm"]);
701    }
702
703    #[test]
704    fn test_nested_command_substitution() {
705        let commands = extract_commands("echo $(cat $(whoami).txt)").expect("parse failed");
706        assert!(commands.iter().any(|c| c.contains("echo")));
707        assert!(commands.iter().any(|c| c.contains("cat")));
708        assert!(commands.contains(&"whoami".to_string()));
709    }
710
711    #[test]
712    fn test_empty_command() {
713        let commands = extract_commands("").expect("parse failed");
714        assert!(commands.is_empty());
715    }
716
717    #[test]
718    fn test_invalid_syntax_returns_none() {
719        let result = extract_commands("ls &&");
720        assert!(result.is_none());
721    }
722
723    #[test]
724    fn test_unparsable_nested_substitution_returns_none() {
725        let result = extract_commands("echo $(ls &&)");
726        assert!(result.is_none());
727    }
728
729    #[test]
730    fn test_unparsable_nested_backtick_substitution_returns_none() {
731        let result = extract_commands("echo `ls &&`");
732        assert!(result.is_none());
733    }
734
735    #[test]
736    fn test_redirect_write_includes_target_path() {
737        let commands = extract_commands("echo hello > /etc/passwd").expect("parse failed");
738        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
739    }
740
741    #[test]
742    fn test_redirect_append_includes_target_path() {
743        let commands = extract_commands("cat file >> /tmp/log").expect("parse failed");
744        assert_eq!(commands, vec!["cat file", ">> /tmp/log"]);
745    }
746
747    #[test]
748    fn test_fd_redirect_handled_gracefully() {
749        let commands = extract_commands("cmd 2>&1").expect("parse failed");
750        assert_eq!(commands, vec!["cmd"]);
751    }
752
753    #[test]
754    fn test_input_redirect() {
755        let commands = extract_commands("sort < /tmp/input").expect("parse failed");
756        assert_eq!(commands, vec!["sort", "< /tmp/input"]);
757    }
758
759    #[test]
760    fn test_multiple_redirects() {
761        let commands = extract_commands("cmd > /tmp/out 2> /tmp/err").expect("parse failed");
762        assert_eq!(commands, vec!["cmd", "> /tmp/out", "2> /tmp/err"]);
763    }
764
765    #[test]
766    fn test_prefix_position_redirect() {
767        let commands = extract_commands("> /tmp/out echo hello").expect("parse failed");
768        assert_eq!(commands, vec!["echo hello", "> /tmp/out"]);
769    }
770
771    #[test]
772    fn test_redirect_with_variable_expansion() {
773        let commands = extract_commands("echo > $HOME/file").expect("parse failed");
774        assert_eq!(commands, vec!["echo", "> $HOME/file"]);
775    }
776
777    #[test]
778    fn test_output_and_error_redirect() {
779        let commands = extract_commands("cmd &> /tmp/all").expect("parse failed");
780        assert_eq!(commands, vec!["cmd", "&> /tmp/all"]);
781    }
782
783    #[test]
784    fn test_append_output_and_error_redirect() {
785        let commands = extract_commands("cmd &>> /tmp/all").expect("parse failed");
786        assert_eq!(commands, vec!["cmd", "&>> /tmp/all"]);
787    }
788
789    #[test]
790    fn test_redirect_in_chained_command() {
791        let commands =
792            extract_commands("echo hello > /tmp/out && cat /tmp/out").expect("parse failed");
793        assert_eq!(commands, vec!["echo hello", "> /tmp/out", "cat /tmp/out"]);
794    }
795
796    #[test]
797    fn test_here_string_dropped_from_normalized_output() {
798        let commands = extract_commands("cat <<< 'hello'").expect("parse failed");
799        assert_eq!(commands, vec!["cat"]);
800    }
801
802    #[test]
803    fn test_brace_group_redirect() {
804        let commands = extract_commands("{ echo hello; } > /etc/passwd").expect("parse failed");
805        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
806    }
807
808    #[test]
809    fn test_subshell_redirect() {
810        let commands = extract_commands("(cmd) > /etc/passwd").expect("parse failed");
811        assert_eq!(commands, vec!["cmd", "> /etc/passwd"]);
812    }
813
814    #[test]
815    fn test_for_loop_redirect() {
816        let commands =
817            extract_commands("for f in *; do cat \"$f\"; done > /tmp/out").expect("parse failed");
818        assert_eq!(commands, vec!["cat $f", "> /tmp/out"]);
819    }
820
821    #[test]
822    fn test_brace_group_multi_command_redirect() {
823        let commands =
824            extract_commands("{ echo hello; cat; } > /etc/passwd").expect("parse failed");
825        assert_eq!(commands, vec!["echo hello", "cat", "> /etc/passwd"]);
826    }
827
828    #[test]
829    fn test_quoted_redirect_target_is_normalized() {
830        let commands = extract_commands("echo hello > '/etc/passwd'").expect("parse failed");
831        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
832    }
833
834    #[test]
835    fn test_redirect_without_space() {
836        let commands = extract_commands("echo hello >/etc/passwd").expect("parse failed");
837        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
838    }
839
840    #[test]
841    fn test_clobber_redirect() {
842        let commands = extract_commands("cmd >| /tmp/file").expect("parse failed");
843        assert_eq!(commands, vec!["cmd", ">| /tmp/file"]);
844    }
845
846    #[test]
847    fn test_fd_to_fd_redirect_skipped() {
848        let commands = extract_commands("cmd 1>&2").expect("parse failed");
849        assert_eq!(commands, vec!["cmd"]);
850    }
851
852    #[test]
853    fn test_bare_redirect_returns_none() {
854        let result = extract_commands("> /etc/passwd");
855        assert!(result.is_none());
856    }
857
858    #[test]
859    fn test_arithmetic_with_redirect_returns_none() {
860        let result = extract_commands("(( x = 1 )) > /tmp/file");
861        assert!(result.is_none());
862    }
863
864    #[test]
865    fn test_redirect_target_with_command_substitution() {
866        let commands = extract_commands("echo > $(mktemp)").expect("parse failed");
867        assert_eq!(commands, vec!["echo", "> $(mktemp)", "mktemp"]);
868    }
869
870    #[test]
871    fn test_nested_compound_redirects() {
872        let commands = extract_commands("{ echo > /tmp/a; } > /tmp/b").expect("parse failed");
873        assert_eq!(commands, vec!["echo", "> /tmp/a", "> /tmp/b"]);
874    }
875
876    #[test]
877    fn test_while_loop_redirect() {
878        let commands =
879            extract_commands("while true; do echo line; done > /tmp/log").expect("parse failed");
880        assert_eq!(commands, vec!["true", "echo line", "> /tmp/log"]);
881    }
882
883    #[test]
884    fn test_if_clause_redirect() {
885        let commands =
886            extract_commands("if true; then echo yes; fi > /tmp/out").expect("parse failed");
887        assert_eq!(commands, vec!["true", "echo yes", "> /tmp/out"]);
888    }
889
890    #[test]
891    fn test_pipe_with_redirect_on_last_command() {
892        let commands = extract_commands("ls | grep foo > /tmp/out").expect("parse failed");
893        assert_eq!(commands, vec!["ls", "grep foo", "> /tmp/out"]);
894    }
895
896    #[test]
897    fn test_pipe_with_stderr_redirect_on_first_command() {
898        let commands = extract_commands("ls 2>/dev/null | grep foo").expect("parse failed");
899        assert_eq!(commands, vec!["ls", "2> /dev/null", "grep foo"]);
900    }
901
902    #[test]
903    fn test_function_definition_redirect() {
904        let commands = extract_commands("f() { echo hi; } > /tmp/out").expect("parse failed");
905        assert_eq!(commands, vec!["echo hi", "> /tmp/out"]);
906    }
907
908    #[test]
909    fn test_read_and_write_redirect() {
910        let commands = extract_commands("cmd <> /dev/tty").expect("parse failed");
911        assert_eq!(commands, vec!["cmd", "<> /dev/tty"]);
912    }
913
914    #[test]
915    fn test_case_clause_with_redirect() {
916        let commands =
917            extract_commands("case $x in a) echo hi;; esac > /tmp/out").expect("parse failed");
918        assert_eq!(commands, vec!["echo hi", "> /tmp/out"]);
919    }
920
921    #[test]
922    fn test_until_loop_with_redirect() {
923        let commands =
924            extract_commands("until false; do echo line; done > /tmp/log").expect("parse failed");
925        assert_eq!(commands, vec!["false", "echo line", "> /tmp/log"]);
926    }
927
928    #[test]
929    fn test_arithmetic_for_clause_with_redirect() {
930        let commands = extract_commands("for ((i=0; i<10; i++)); do echo $i; done > /tmp/out")
931            .expect("parse failed");
932        assert_eq!(commands, vec!["echo $i", "> /tmp/out"]);
933    }
934
935    #[test]
936    fn test_if_elif_else_with_redirect() {
937        let commands = extract_commands(
938            "if true; then echo a; elif false; then echo b; else echo c; fi > /tmp/out",
939        )
940        .expect("parse failed");
941        assert_eq!(
942            commands,
943            vec!["true", "echo a", "false", "echo b", "echo c", "> /tmp/out"]
944        );
945    }
946
947    #[test]
948    fn test_multiple_redirects_on_compound_command() {
949        let commands = extract_commands("{ cmd; } > /tmp/out 2> /tmp/err").expect("parse failed");
950        assert_eq!(commands, vec!["cmd", "> /tmp/out", "2> /tmp/err"]);
951    }
952
953    #[test]
954    fn test_here_document_command_substitution_extracted() {
955        let commands = extract_commands("cat <<EOF\n$(rm -rf /)\nEOF").expect("parse failed");
956        assert!(commands.iter().any(|c| c.contains("cat")));
957        assert!(commands.contains(&"rm -rf /".to_string()));
958    }
959
960    #[test]
961    fn test_here_document_quoted_delimiter_no_extraction() {
962        let commands = extract_commands("cat <<'EOF'\n$(rm -rf /)\nEOF").expect("parse failed");
963        assert_eq!(commands, vec!["cat"]);
964    }
965
966    #[test]
967    fn test_here_document_backtick_substitution_extracted() {
968        let commands = extract_commands("cat <<EOF\n`whoami`\nEOF").expect("parse failed");
969        assert!(commands.iter().any(|c| c.contains("cat")));
970        assert!(commands.contains(&"whoami".to_string()));
971    }
972
973    #[test]
974    fn test_brace_group_redirect_with_command_substitution() {
975        let commands = extract_commands("{ echo hello; } > $(mktemp)").expect("parse failed");
976        assert!(commands.contains(&"echo hello".to_string()));
977        assert!(commands.contains(&"mktemp".to_string()));
978    }
979
980    #[test]
981    fn test_function_definition_redirect_with_command_substitution() {
982        let commands = extract_commands("f() { echo hi; } > $(mktemp)").expect("parse failed");
983        assert!(commands.contains(&"echo hi".to_string()));
984        assert!(commands.contains(&"mktemp".to_string()));
985    }
986
987    #[test]
988    fn test_brace_group_redirect_with_process_substitution() {
989        let commands = extract_commands("{ cat; } > >(tee /tmp/log)").expect("parse failed");
990        assert!(commands.contains(&"cat".to_string()));
991        assert!(commands.contains(&"tee /tmp/log".to_string()));
992    }
993}