vtcode_safety/command_safety/
shell_parser.rs1#![expect(
2 clippy::indexing_slicing,
3 clippy::string_slice,
4 reason = "Shell parsing tracks byte offsets at character boundaries while validating operator pairs."
5)]
6
7use std::num::NonZeroUsize;
23use std::sync::Mutex;
24use std::sync::OnceLock;
25
26use anyhow::Result;
27use lru::LruCache;
28
29static BASH_PARSER: OnceLock<Result<Mutex<tree_sitter::Parser>, String>> = OnceLock::new();
31
32const SHELL_TREE_CACHE_CAPACITY: usize = 256;
34
35static SHELL_TREE_CACHE: OnceLock<Mutex<LruCache<String, tree_sitter::Tree, rustc_hash::FxBuildHasher>>> =
49 OnceLock::new();
50
51fn shell_tree_cache() -> &'static Mutex<LruCache<String, tree_sitter::Tree, rustc_hash::FxBuildHasher>> {
57 SHELL_TREE_CACHE.get_or_init(|| {
58 let capacity = NonZeroUsize::new(SHELL_TREE_CACHE_CAPACITY).unwrap_or(NonZeroUsize::MIN);
59 Mutex::new(LruCache::with_hasher(capacity, rustc_hash::FxBuildHasher))
60 })
61}
62
63fn parse_bash_tree(script: &str) -> Result<(tree_sitter::Tree, bool), String> {
72 if let Some(tree) = {
73 let mut cache = shell_tree_cache().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
74 cache.get(script).cloned()
75 } {
76 let has_error = tree.root_node().has_error();
77 return Ok((tree, has_error));
78 }
79
80 let (tree, has_error) = {
81 let parser_guard = get_bash_parser()?;
82 let mut parser = parser_guard.lock().map_err(|e| format!("Failed to lock parser: {e}"))?;
83 let tree = parser.parse(script, None).ok_or_else(|| "Failed to parse script".to_string())?;
84 let has_error = tree.root_node().has_error();
85 (tree, has_error)
86 };
87
88 let mut cache = shell_tree_cache().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
89 drop(cache.put(script.to_string(), tree.clone()));
90 Ok((tree, has_error))
91}
92
93pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
103 enum ShellQuote {
104 Single,
105 Double,
106 }
107
108 let mut quote: Option<ShellQuote> = None;
109 let mut characters = command.chars();
110
111 while let Some(character) = characters.next() {
112 match quote {
113 Some(ShellQuote::Single) => {
114 if character == '\'' {
115 quote = None;
116 }
117 }
118 Some(ShellQuote::Double) => match character {
119 '"' => quote = None,
120 '$' | '`' => return true,
121 '\\'
122 if characters.next().is_none() => {
128 return true;
129 }
130 _ => {}
131 },
132 None => match character {
133 '\'' => quote = Some(ShellQuote::Single),
134 '"' => quote = Some(ShellQuote::Double),
135 '\\' | '$' | '`' | '{' | '}' | '*' | '?' | '[' | ']' => return true,
136 _ => {}
137 },
138 }
139 }
140
141 quote.is_some()
142}
143
144pub fn contains_dynamic_find_syntax(script: &str) -> bool {
147 if let Ok(commands) = parse_shell_commands_tree_sitter(script)
148 && commands.iter().any(|command| {
149 command
150 .first()
151 .map(|program| base_command_name(program) == "find")
152 .unwrap_or(false)
153 && command.iter().any(|word| contains_dynamic_shell_syntax(word))
154 })
155 {
156 return true;
157 }
158
159 let has_find_word = script.split_whitespace().any(|word| {
163 let command = word.trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '/');
164 base_command_name(command) == "find"
165 });
166 has_find_word && contains_dynamic_shell_syntax(script)
167}
168
169fn get_bash_parser() -> Result<&'static Mutex<tree_sitter::Parser>, String> {
171 BASH_PARSER
172 .get_or_init(|| {
173 let mut parser = tree_sitter::Parser::new();
174 let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
175 parser
176 .set_language(&lang)
177 .map_err(|e| format!("Failed to load bash grammar: {e}"))?;
178 Ok(Mutex::new(parser))
179 })
180 .as_ref()
181 .map_err(Clone::clone)
182}
183
184pub fn prewarm_bash_parser() -> Result<(), String> {
186 let _ = get_bash_parser()?;
187 Ok(())
188}
189
190pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
201 match parse_with_tree_sitter(script, false) {
203 Ok(commands) if !commands.is_empty() => return Ok(commands),
204 Ok(_) => {} Err(e) => {
206 tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
207 }
208 }
209
210 parse_with_basic_tokenization(script)
212}
213
214pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
218 parse_with_tree_sitter(script, true)
219}
220
221pub fn has_only_output_redirections(script: &str) -> bool {
225 if contains_dynamic_shell_syntax(script) {
226 return false;
227 }
228 if contains_background_operator(script) {
229 return false;
230 }
231
232 let Ok((tree, has_error)) = parse_bash_tree(script) else {
233 return false;
234 };
235 if has_error {
236 return false;
237 }
238
239 let mut saw_redirection = false;
240 if !collect_output_redirections(tree.root_node(), script, &mut saw_redirection) {
241 return false;
242 }
243 saw_redirection
244}
245
246pub(crate) fn validate_redirection_paths(script: &str) -> Result<()> {
249 use anyhow::{Context, anyhow, ensure};
250 if !script.contains(['<', '>']) {
251 return Ok(());
252 }
253 let (tree, has_error) =
254 parse_bash_tree(script).map_err(|error| anyhow!("failed to parse shell redirections: {error}"))?;
255 ensure!(!has_error, "cannot validate malformed shell redirections");
256 let mut pending = vec![tree.root_node()];
257 while let Some(node) = pending.pop() {
258 if node.kind() == "file_redirect" {
259 let text = node.utf8_text(script.as_bytes()).context("invalid shell redirection text")?;
260 let operator = text.trim_start_matches(|character: char| character.is_ascii_digit());
261 let descriptor_route = operator.starts_with(">&") || operator.starts_with("<&");
262 let mut cursor = node.walk();
263 let destinations = node.children_by_field_name("destination", &mut cursor);
264 for destination in destinations {
265 let raw = destination
266 .utf8_text(script.as_bytes())
267 .context("invalid redirection destination")?;
268 ensure!(!contains_dynamic_shell_syntax(raw), "dynamic redirection destination is not allowed");
269 let words = shell_words::split(raw).context("invalid quoted redirection destination")?;
270 ensure!(words.len() == 1, "redirection destination must be one literal path");
271 let path = words.first().context("missing redirection destination")?;
272 if descriptor_route && (path == "-" || path.chars().all(|character| character.is_ascii_digit())) {
273 continue;
274 }
275 ensure!(!path.starts_with('~'), "home-directory redirection destinations are not allowed");
280 if path == "/dev/null" {
282 continue;
283 }
284 vtcode_commons::paths::validate_path_safety(path)
285 .with_context(|| format!("unsafe shell redirection destination: {path}"))?;
286 }
287 }
288 let mut cursor = node.walk();
289 pending.extend(node.named_children(&mut cursor));
290 }
291 Ok(())
292}
293
294fn contains_background_operator(script: &str) -> bool {
295 let chars = script.chars().collect::<Vec<_>>();
296 let mut index = 0;
297 let mut in_single_quote = false;
298 let mut in_double_quote = false;
299
300 while index < chars.len() {
301 let character = chars[index];
302 if character == '\'' && !in_double_quote {
303 in_single_quote = !in_single_quote;
304 index += 1;
305 continue;
306 }
307 if character == '"' && !in_single_quote {
308 in_double_quote = !in_double_quote;
309 index += 1;
310 continue;
311 }
312 if in_single_quote || in_double_quote {
313 index += 1;
314 continue;
315 }
316
317 if character == '&' {
318 let previous = index.checked_sub(1).and_then(|position| chars.get(position));
319 let next = chars.get(index + 1);
320 if next == Some(&'&') {
321 index += 2;
322 continue;
323 }
324 let part_of_allowed_operator = next == Some(&'&')
325 || next == Some(&'>')
326 || previous == Some(&'>')
327 || previous == Some(&'|')
328 || previous == Some(&'<');
329 if !part_of_allowed_operator {
330 return true;
331 }
332 }
333 index += 1;
334 }
335
336 false
337}
338
339fn collect_output_redirections(node: tree_sitter::Node, source: &str, saw_redirection: &mut bool) -> bool {
340 match node.kind() {
341 "file_redirect" => {
342 *saw_redirection = true;
343 let Ok(text) = node.utf8_text(source.as_bytes()) else {
344 return false;
345 };
346 if !is_output_redirection(text) {
347 return false;
348 }
349 }
350 "heredoc_redirect" | "herestring_redirect" => return false,
351 _ => {}
352 }
353
354 let mut cursor = node.walk();
355 node.children(&mut cursor)
356 .all(|child| collect_output_redirections(child, source, saw_redirection))
357}
358
359fn is_output_redirection(text: &str) -> bool {
360 let redirect = text.trim_start_matches(|character: char| character.is_ascii_digit());
361 if redirect.starts_with("&>") {
362 return !redirect.starts_with("&>-");
363 }
364 if let Some(destination) = redirect.strip_prefix(">&") {
365 return destination.trim().chars().all(|character| character.is_ascii_digit());
366 }
367
368 redirect.starts_with('>') && !redirect.starts_with(">&-")
369}
370
371fn parse_with_tree_sitter(script: &str, reject_syntax_errors: bool) -> Result<Vec<Vec<String>>, String> {
373 let (tree, has_error) = parse_bash_tree(script)?;
374
375 let mut commands = Vec::new();
376 let root = tree.root_node();
377 if reject_syntax_errors && has_error {
378 return Err("Shell script contains syntax errors".to_string());
379 }
380
381 collect_commands_from_node(root, script, &mut commands);
385
386 Ok(commands)
387}
388
389fn collect_commands_from_node(node: tree_sitter::Node, source: &str, commands: &mut Vec<Vec<String>>) {
390 match node.kind() {
391 "command" | "simple_command" => {
392 if let Some(cmd) = extract_command_from_node(node, source)
393 && !cmd.is_empty()
394 {
395 commands.push(cmd);
396 }
397 }
398 _ => {
399 let mut cursor = node.walk();
400 for child in node.children(&mut cursor) {
401 collect_commands_from_node(child, source, commands);
402 }
403 }
404 }
405}
406
407fn extract_command_from_node(node: tree_sitter::Node, source: &str) -> Option<Vec<String>> {
409 let mut command = Vec::new();
410 let mut cursor = node.walk();
411
412 if node.kind() == "pipeline" {
414 for child in node.children(&mut cursor) {
415 if child.kind() == "command" || child.kind() == "simple_command" {
416 return extract_command_from_node(child, source);
417 }
418 }
419 }
420
421 for child in node.children(&mut cursor) {
423 if child.kind() == "command_name" {
424 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
425 let trimmed = arg.trim();
426 if !trimmed.is_empty() {
427 command.push(trimmed.to_string());
428 }
429 }
430 continue;
431 }
432
433 if matches!(child.kind(), "variable_assignment" | "concatenation") {
441 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
442 let trimmed = arg.trim();
443 if !trimmed.is_empty() {
444 command.push(trimmed.to_string());
445 }
446 }
447 continue;
448 }
449
450 if matches!(
451 child.kind(),
452 "word" | "string" | "raw_string" | "ansi_c_string" | "simple_expansion" | "variable_expansion"
453 ) {
454 let text = child.utf8_text(source.as_bytes());
455 if let Ok(arg) = text {
456 let trimmed = arg.trim();
457 if !trimmed.is_empty() {
458 command.push(trimmed.to_string());
459 }
460 }
461 }
462 }
463
464 if command.is_empty() { None } else { Some(command) }
465}
466
467fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
469 let mut commands = Vec::new();
470 let mut current_command = String::new();
471 let mut in_quotes = false;
472 let mut quote_char = ' ';
473 let mut escaped = false;
474
475 for ch in script.chars() {
476 if escaped {
477 current_command.push(ch);
478 escaped = false;
479 continue;
480 }
481
482 match ch {
483 '\\' => {
484 escaped = true;
485 }
486 '\'' | '"' if !in_quotes => {
487 in_quotes = true;
488 quote_char = ch;
489 }
490 c if c == quote_char && in_quotes => {
491 in_quotes = false;
492 }
493 '&' | '|' | ';' if !in_quotes => {
494 if !current_command.trim().is_empty()
495 && let Ok(cmd) = tokenize_command(¤t_command)
496 {
497 commands.push(cmd);
498 }
499 current_command.clear();
500 }
501 _ => current_command.push(ch),
502 }
503 }
504
505 if !current_command.trim().is_empty()
506 && let Ok(cmd) = tokenize_command(¤t_command)
507 {
508 commands.push(cmd);
509 }
510
511 Ok(commands)
512}
513
514fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
517 shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
518}
519
520pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
528 if command.is_empty() {
529 return None;
530 }
531
532 let cmd_name = command[0].as_str();
533 let base_cmd = std::path::Path::new(cmd_name)
534 .file_name()
535 .and_then(|osstr| osstr.to_str())
536 .unwrap_or("");
537
538 if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
539 return None;
540 }
541
542 for window in command.windows(2) {
544 if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
545 let script = &window[1];
546 return parse_shell_commands(script).ok();
547 }
548 }
549
550 None
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
558 fn tokenize_simple_command() {
559 let cmd = "git status";
560 let tokens = tokenize_command(cmd).unwrap();
561 assert_eq!(tokens, vec!["git", "status"]);
562 }
563
564 #[test]
565 fn tokenize_quoted_arguments() {
566 let cmd = r#"echo "hello world""#;
567 let tokens = tokenize_command(cmd).unwrap();
568 assert_eq!(tokens, vec!["echo", "hello world"]);
569 }
570
571 #[test]
572 fn parse_single_command() {
573 let script = "git status";
574 let commands = parse_shell_commands(script).unwrap();
575 assert_eq!(commands.len(), 1);
576 assert_eq!(commands[0][0], "git");
577 }
578
579 #[test]
580 fn parse_chained_commands_with_and() {
581 let script = "git status && cargo check";
582 let commands = parse_shell_commands(script).unwrap();
583 assert_eq!(commands.len(), 2);
584 assert_eq!(commands[0][0], "git");
585 assert_eq!(commands[1][0], "cargo");
586 }
587
588 #[test]
589 fn parse_loop_body_commands() {
590 let script = "cd crates/codegen/vtcode-core/src/tools/registry && for f in *.rs; do echo \"=== $f ===\"; grep -nE '^(pub )?(struct|enum|fn)' \"$f\" | head -50; done";
591 let commands = parse_shell_commands(script).unwrap();
592
593 assert_eq!(commands[0], vec!["cd", "crates/codegen/vtcode-core/src/tools/registry"]);
594 assert!(
595 commands
596 .iter()
597 .any(|command| command.first().is_some_and(|name| name == "echo"))
598 );
599 assert!(
600 commands
601 .iter()
602 .any(|command| command.first().is_some_and(|name| name == "grep"))
603 );
604 assert!(
605 commands
606 .iter()
607 .any(|command| command.first().is_some_and(|name| name == "head"))
608 );
609 }
610
611 #[test]
612 fn parse_chained_commands_with_semicolon() {
613 let script = "git status; cargo check";
614 let commands = parse_shell_commands(script).unwrap();
615 assert_eq!(commands.len(), 2);
616 }
617
618 #[test]
619 fn parse_bash_lc_git_status() {
620 let cmd = vec!["bash".to_string(), "-lc".to_string(), "git status".to_string()];
621 let commands = parse_bash_lc_commands(&cmd);
622 assert!(commands.is_some());
623 let commands = commands.unwrap();
624 assert_eq!(commands.len(), 1);
625 assert_eq!(commands[0][0], "git");
626 }
627
628 #[test]
629 fn parse_bash_lc_chained() {
630 let cmd = vec![
631 "bash".to_string(),
632 "-lc".to_string(),
633 "git status && cargo check".to_string(),
634 ];
635 let commands = parse_bash_lc_commands(&cmd);
636 assert!(commands.is_some());
637 let commands = commands.unwrap();
638 assert_eq!(commands.len(), 2);
639 }
640
641 #[test]
642 fn parse_non_bash_command_returns_none() {
643 let cmd = vec!["echo".to_string(), "hello".to_string()];
644 let commands = parse_bash_lc_commands(&cmd);
645 assert!(commands.is_none());
646 }
647
648 #[test]
649 fn parse_bash_without_lc_returns_none() {
650 let cmd = vec!["bash".to_string(), "script.sh".to_string()];
651 let commands = parse_bash_lc_commands(&cmd);
652 assert!(commands.is_none());
653 }
654
655 #[test]
658 fn parse_complex_pipeline() {
659 let script = "cat file.txt | grep -i pattern | sort";
660 let commands = parse_shell_commands(script).unwrap();
661 assert!(!commands.is_empty());
662 }
663
664 #[test]
665 fn parse_with_pipes_and_redirects() {
666 let script = "ls -la | grep file > output.txt";
667 let commands = parse_shell_commands(script).unwrap();
668 assert!(!commands.is_empty());
669 }
670
671 #[test]
672 fn parse_command_substitution_fallback() {
673 let script = "echo $(git status)";
674 let commands = parse_shell_commands(script).unwrap();
675 assert!(!commands.is_empty());
676 }
677
678 #[test]
679 fn parse_escaped_quotes() {
680 let script = r#"echo "hello \"world\"""#;
681 let commands = parse_shell_commands(script).unwrap();
682 assert!(!commands.is_empty());
683 }
684
685 #[test]
686 fn parse_tree_sitter_preserves_command_name_with_quoted_args() {
687 let script = r#"echo "fish and chips""#;
688 let commands = parse_shell_commands_tree_sitter(script).unwrap();
689 assert!(!commands.is_empty());
690 assert_eq!(commands[0][0], "echo");
691 }
692
693 #[test]
694 fn parse_tree_sitter_preserves_single_and_ansi_quoted_args() {
695 let script = r#"printf '\n' && git diff '--output=out.txt' && printf $'\n'"#;
696 let commands = parse_shell_commands_tree_sitter(script).unwrap();
697 assert!(
698 commands
699 .iter()
700 .any(|command| command.iter().any(|word| word.contains("--output=out.txt")))
701 );
702 assert!(commands.iter().any(|command| command.iter().any(|word| word.contains("\\n"))));
703 }
704
705 #[test]
706 fn dynamic_syntax_allows_literal_escapes_inside_double_quoted_arguments() {
707 assert!(!contains_dynamic_shell_syntax(r#"rg -n "\[profile|lto|codegen-units|strip" Cargo.toml"#));
708 assert!(!contains_dynamic_shell_syntax(r#"printf "\nTop-level:\n""#));
709 assert!(!contains_dynamic_shell_syntax(r#"printf "quoted: \"value\"""#));
710 assert!(contains_dynamic_shell_syntax(r#"echo "safe\"$(id)""#));
711 }
712
713 #[test]
714 fn dynamic_syntax_rejects_unquoted_escapes() {
715 assert!(contains_dynamic_shell_syntax(r"rg -n \[profile Cargo.toml"));
716 }
717
718 #[test]
719 fn output_redirection_guard_rejects_input_and_heredoc_shapes() {
720 assert!(has_only_output_redirections("cargo check > build.log 2>&1"));
721 assert!(has_only_output_redirections("cargo check | head -40 > build.log"));
722 assert!(has_only_output_redirections("cargo check &> build.log"));
723 assert!(has_only_output_redirections("cargo check &>> build.log"));
724 assert!(!has_only_output_redirections("cargo check < build-input.log"));
725 assert!(!has_only_output_redirections("cargo check <<'EOF'\ninput\nEOF"));
726 assert!(!has_only_output_redirections("cargo check > $(printf build.log)"));
727 assert!(!has_only_output_redirections("cargo check > build.log &"));
728 assert!(!has_only_output_redirections("cargo check 2>&-"));
729 }
730
731 #[test]
732 fn strict_tree_sitter_parser_rejects_incomplete_shell_syntax() {
733 assert!(parse_shell_commands_tree_sitter("cargo check &&").is_err());
734 assert!(parse_shell_commands_tree_sitter("echo '").is_err());
735 }
736
737 #[test]
738 fn parse_bash_lc_with_pipe() {
739 let cmd = vec!["bash".to_string(), "-lc".to_string(), "ls -la | head -5".to_string()];
740 let commands = parse_bash_lc_commands(&cmd);
741 assert!(commands.is_some());
742 let cmds = commands.unwrap();
743 assert!(!cmds.is_empty());
744 }
745
746 #[test]
747 fn parse_dangerous_shell_command() {
748 let script = "rm -rf /; echo done";
749 let commands = parse_shell_commands(script).unwrap();
750 assert_eq!(commands.len(), 2);
751 assert_eq!(commands[0][0], "rm");
752 }
753
754 #[test]
755 fn prewarm_bash_parser_initializes_successfully() {
756 prewarm_bash_parser().expect("bash parser should initialize");
757 }
758
759 #[test]
760 fn dynamic_find_syntax_is_detected_without_rejecting_quoted_globs() {
761 assert!(contains_dynamic_find_syntax("find src -maxdepth 0 -exe$''c touch /tmp/VT_BYPASS_POC {} +"));
762 assert!(!contains_dynamic_find_syntax("find src -type f -name '*.rs'"));
763 }
764
765 #[test]
766 fn cached_tree_hit_still_fails_closed_on_malformed_scripts() {
767 let malformed = "cargo check && ";
772 assert!(parse_shell_commands_tree_sitter(malformed).is_err());
773 assert!(
774 parse_shell_commands_tree_sitter(malformed).is_err(),
775 "cached tree must keep rejecting malformed scripts"
776 );
777 }
778
779 #[test]
780 fn cached_tree_hit_preserves_extraction_across_repeated_and_mixed_calls() {
781 let script = "vtcode-cache-extraction-probe && grep -n pattern file | head -5";
785 let first = parse_shell_commands(script).expect("initial parse");
786 let second = parse_shell_commands(script).expect("cached parse");
787 assert_eq!(first, second);
788 assert_eq!(first.len(), 3);
789 assert!(first.iter().any(|command| command.first().is_some_and(|name| name == "grep")));
790
791 let strict = parse_shell_commands_tree_sitter(script).expect("strict parse of cached tree");
792 assert_eq!(strict, first);
793 }
794}
795
796use anyhow::bail;
799
800pub(crate) fn quoted_heredoc_delim(rest: &str) -> Option<(String, usize)> {
806 let mut idx = 0usize;
807 let bytes = rest.as_bytes();
808 if bytes.first() == Some(&b'-') {
809 idx += 1;
810 }
811 while idx < bytes.len() && (bytes[idx] == b' ' || bytes[idx] == b'\t') {
812 idx += 1;
813 }
814 let quote = match bytes.get(idx) {
815 Some(b'\'') | Some(b'"') => bytes[idx],
816 _ => return None,
817 };
818 idx += 1;
819 let delim_start = idx;
820 while idx < bytes.len() && bytes[idx] != quote {
821 idx += 1;
822 }
823 if idx >= bytes.len() || idx == delim_start {
824 return None;
825 }
826 let delim = rest[delim_start..idx].to_string();
827 idx += 1; Some((delim, idx))
829}
830
831pub(crate) fn heredoc_body_skip_len(rest: &str, delim: &str) -> Option<usize> {
836 let bytes = rest.as_bytes();
837 let mut idx = 0usize;
838 while idx <= bytes.len() {
839 let line_end = rest[idx..].find('\n').map(|offset| idx + offset).unwrap_or(bytes.len());
840 let line = rest[idx..line_end].trim_end_matches('\r');
841 let next = if line_end < bytes.len() {
842 line_end + 1
843 } else {
844 bytes.len()
845 };
846 if line == delim {
847 return Some(next);
848 }
849 if line_end >= bytes.len() {
850 return None;
851 }
852 idx = next;
853 }
854 None
855}
856
857fn consume_bytes(
860 chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
861 max_bytes: usize,
862 fallback: usize,
863) -> usize {
864 let mut consumed = 0usize;
865 let mut end = fallback;
866 while consumed < max_bytes
867 && let Some((idx, ch)) = chars.next()
868 {
869 consumed += ch.len_utf8();
870 end = idx + ch.len_utf8();
871 }
872 end
873}
874
875#[derive(Clone, Copy, Eq, PartialEq)]
877enum QuoteState {
878 None,
879 Single,
880 Double,
881}
882
883pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
886 let mut segments = Vec::new();
887 let mut state = QuoteState::None;
888 let mut escaped = false;
889 let mut segment_start = 0usize;
890 let mut pending_heredoc: Option<String> = None;
894 let mut chars = command.char_indices().peekable();
895
896 while let Some((idx, ch)) = chars.next() {
897 match state {
898 QuoteState::Single => {
899 if ch == '\'' {
900 state = QuoteState::None;
901 }
902 }
903 QuoteState::Double => {
904 if escaped {
905 escaped = false;
906 continue;
907 }
908
909 match ch {
910 '\\' => escaped = true,
911 '"' => state = QuoteState::None,
912 '`' => bail!("Command injection pattern detected"),
913 '$' if matches!(chars.peek(), Some((_, '('))) => {
914 bail!("Command injection pattern detected");
915 }
916 _ => {}
917 }
918 }
919 QuoteState::None => {
920 if escaped {
921 escaped = false;
922 continue;
923 }
924
925 match ch {
926 '\\' => escaped = true,
927 '\'' => state = QuoteState::Single,
928 '"' => state = QuoteState::Double,
929 '<' if matches!(chars.peek(), Some((_, '<'))) => {
935 let _ = chars.next(); let rest: String = chars.clone().map(|(_, c)| c).collect();
937 if let Some((delim, token_len)) = quoted_heredoc_delim(&rest) {
938 let _ = consume_bytes(&mut chars, token_len, idx + ch.len_utf8());
939 pending_heredoc = Some(delim);
940 }
941 }
942 '`' => bail!("Command injection pattern detected"),
943 '$' if matches!(chars.peek(), Some((_, '('))) => {
944 bail!("Command injection pattern detected");
945 }
946 ';' => bail!("Unquoted command chaining detected"),
947 '\n' => {
948 if let Some(delim) = pending_heredoc.take() {
953 let rest: String = chars.clone().map(|(_, c)| c).collect();
954 if let Some(skip) = heredoc_body_skip_len(&rest, &delim) {
955 push_segment(command, segment_start, idx, &mut segments);
958 segment_start = consume_bytes(&mut chars, skip, command.len());
959 continue;
960 }
961 }
962 bail!(
963 "multi-line shell commands are not allowed; write the content with `apply_patch` instead of a heredoc"
964 );
965 }
966 '|' | '&' => {
967 push_segment(command, segment_start, idx, &mut segments);
968 segment_start = idx + ch.len_utf8();
969 if let Some((next_idx, next_ch)) = chars.peek().copied()
970 && next_ch == ch
971 {
972 let _next = chars.next();
973 segment_start = next_idx + next_ch.len_utf8();
974 }
975 }
976 _ => {}
977 }
978 }
979 }
980 }
981
982 push_segment(command, segment_start, command.len(), &mut segments);
983 Ok(segments)
984}
985
986fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
987 let segment = command[start..end].trim();
988 if !segment.is_empty() {
989 segments.push(segment.to_string());
990 }
991}
992
993pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
995 let segment_lower = segment.to_ascii_lowercase();
996 if segment_lower.starts_with(":(){:|:&};:") {
997 return Some(":(){:|:&};:");
998 }
999
1000 let tokens =
1001 shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
1002 let first = tokens.first()?;
1003 let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
1004
1005 match command_name.as_str() {
1006 "rmdir" => Some("rmdir"),
1007 "wget" => Some("wget"),
1008 "curl" => Some("curl"),
1009 "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
1010 "chown"
1011 if tokens.iter().skip(1).any(|arg| {
1012 let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
1013 arg == "root" || arg.starts_with("root:")
1014 }) =>
1015 {
1016 Some("chown root")
1017 }
1018 _ => None,
1019 }
1020}
1021
1022fn strip_wrapping_quotes(token: &str) -> &str {
1023 token
1024 .strip_prefix('\'')
1025 .and_then(|token| token.strip_suffix('\''))
1026 .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
1027 .unwrap_or(token)
1028}
1029
1030fn base_command_name(command: &str) -> &str {
1031 std::path::Path::new(command)
1032 .file_name()
1033 .and_then(|name| name.to_str())
1034 .unwrap_or(command)
1035}