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}