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