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::sync::Mutex;
23use std::sync::OnceLock;
24
25use anyhow::Result;
26
27static BASH_PARSER: OnceLock<Result<Mutex<tree_sitter::Parser>, String>> = OnceLock::new();
29
30pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
40 enum ShellQuote {
41 Single,
42 Double,
43 }
44
45 let mut quote: Option<ShellQuote> = None;
46 let mut characters = command.chars();
47
48 while let Some(character) = characters.next() {
49 match quote {
50 Some(ShellQuote::Single) => {
51 if character == '\'' {
52 quote = None;
53 }
54 }
55 Some(ShellQuote::Double) => match character {
56 '"' => quote = None,
57 '$' | '`' => return true,
58 '\\' => {
59 if characters.next().is_none() {
65 return true;
66 }
67 }
68 _ => {}
69 },
70 None => match character {
71 '\'' => quote = Some(ShellQuote::Single),
72 '"' => quote = Some(ShellQuote::Double),
73 '\\' | '$' | '`' | '{' | '}' | '*' | '?' | '[' | ']' => return true,
74 _ => {}
75 },
76 }
77 }
78
79 quote.is_some()
80}
81
82pub fn contains_dynamic_find_syntax(script: &str) -> bool {
85 if let Ok(commands) = parse_shell_commands_tree_sitter(script)
86 && commands.iter().any(|command| {
87 command
88 .first()
89 .map(|program| base_command_name(program) == "find")
90 .unwrap_or(false)
91 && command.iter().any(|word| contains_dynamic_shell_syntax(word))
92 })
93 {
94 return true;
95 }
96
97 let has_find_word = script.split_whitespace().any(|word| {
101 let command = word.trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '/');
102 base_command_name(command) == "find"
103 });
104 has_find_word && contains_dynamic_shell_syntax(script)
105}
106
107fn get_bash_parser() -> Result<&'static Mutex<tree_sitter::Parser>, String> {
109 BASH_PARSER
110 .get_or_init(|| {
111 let mut parser = tree_sitter::Parser::new();
112 let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
113 parser
114 .set_language(&lang)
115 .map_err(|e| format!("Failed to load bash grammar: {e}"))?;
116 Ok(Mutex::new(parser))
117 })
118 .as_ref()
119 .map_err(Clone::clone)
120}
121
122pub fn prewarm_bash_parser() -> Result<(), String> {
124 let _ = get_bash_parser()?;
125 Ok(())
126}
127
128pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
139 match parse_with_tree_sitter(script, false) {
141 Ok(commands) if !commands.is_empty() => return Ok(commands),
142 Ok(_) => {} Err(e) => {
144 tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
145 }
146 }
147
148 parse_with_basic_tokenization(script)
150}
151
152pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
156 parse_with_tree_sitter(script, true)
157}
158
159pub fn has_only_output_redirections(script: &str) -> bool {
163 if contains_dynamic_shell_syntax(script) {
164 return false;
165 }
166 if contains_background_operator(script) {
167 return false;
168 }
169
170 let Ok(parser) = get_bash_parser() else {
171 return false;
172 };
173 let Ok(mut parser) = parser.lock() else {
174 return false;
175 };
176 let Some(tree) = parser.parse(script, None) else {
177 return false;
178 };
179 if tree.root_node().has_error() {
180 return false;
181 }
182
183 let mut saw_redirection = false;
184 if !collect_output_redirections(tree.root_node(), script, &mut saw_redirection) {
185 return false;
186 }
187 saw_redirection
188}
189
190pub(crate) fn validate_redirection_paths(script: &str) -> Result<()> {
193 use anyhow::{Context, anyhow, ensure};
194 if !script.contains(['<', '>']) {
195 return Ok(());
196 }
197 let parser = get_bash_parser().map_err(anyhow::Error::msg)?;
198 let mut parser = parser.lock().map_err(|error| anyhow!("shell parser lock poisoned: {error}"))?;
199 let tree = parser.parse(script, None).context("failed to parse shell redirections")?;
200 ensure!(!tree.root_node().has_error(), "cannot validate malformed shell redirections");
201 let mut pending = vec![tree.root_node()];
202 while let Some(node) = pending.pop() {
203 if node.kind() == "file_redirect" {
204 let text = node.utf8_text(script.as_bytes()).context("invalid shell redirection text")?;
205 let operator = text.trim_start_matches(|character: char| character.is_ascii_digit());
206 let descriptor_route = operator.starts_with(">&") || operator.starts_with("<&");
207 let mut cursor = node.walk();
208 let destinations = node.children_by_field_name("destination", &mut cursor);
209 for destination in destinations {
210 let raw = destination
211 .utf8_text(script.as_bytes())
212 .context("invalid redirection destination")?;
213 ensure!(!contains_dynamic_shell_syntax(raw), "dynamic redirection destination is not allowed");
214 let words = shell_words::split(raw).context("invalid quoted redirection destination")?;
215 ensure!(words.len() == 1, "redirection destination must be one literal path");
216 let path = words.first().context("missing redirection destination")?;
217 if descriptor_route && (path == "-" || path.chars().all(|character| character.is_ascii_digit())) {
218 continue;
219 }
220 ensure!(!path.starts_with('~'), "home-directory redirection destinations are not allowed");
225 if path == "/dev/null" {
227 continue;
228 }
229 vtcode_commons::paths::validate_path_safety(path)
230 .with_context(|| format!("unsafe shell redirection destination: {path}"))?;
231 }
232 }
233 let mut cursor = node.walk();
234 pending.extend(node.named_children(&mut cursor));
235 }
236 Ok(())
237}
238
239fn contains_background_operator(script: &str) -> bool {
240 let chars = script.chars().collect::<Vec<_>>();
241 let mut index = 0;
242 let mut in_single_quote = false;
243 let mut in_double_quote = false;
244
245 while index < chars.len() {
246 let character = chars[index];
247 if character == '\'' && !in_double_quote {
248 in_single_quote = !in_single_quote;
249 index += 1;
250 continue;
251 }
252 if character == '"' && !in_single_quote {
253 in_double_quote = !in_double_quote;
254 index += 1;
255 continue;
256 }
257 if in_single_quote || in_double_quote {
258 index += 1;
259 continue;
260 }
261
262 if character == '&' {
263 let previous = index.checked_sub(1).and_then(|position| chars.get(position));
264 let next = chars.get(index + 1);
265 if next == Some(&'&') {
266 index += 2;
267 continue;
268 }
269 let part_of_allowed_operator = next == Some(&'&')
270 || next == Some(&'>')
271 || previous == Some(&'>')
272 || previous == Some(&'|')
273 || previous == Some(&'<');
274 if !part_of_allowed_operator {
275 return true;
276 }
277 }
278 index += 1;
279 }
280
281 false
282}
283
284fn collect_output_redirections(node: tree_sitter::Node, source: &str, saw_redirection: &mut bool) -> bool {
285 match node.kind() {
286 "file_redirect" => {
287 *saw_redirection = true;
288 let Ok(text) = node.utf8_text(source.as_bytes()) else {
289 return false;
290 };
291 if !is_output_redirection(text) {
292 return false;
293 }
294 }
295 "heredoc_redirect" | "herestring_redirect" => return false,
296 _ => {}
297 }
298
299 let mut cursor = node.walk();
300 node.children(&mut cursor)
301 .all(|child| collect_output_redirections(child, source, saw_redirection))
302}
303
304fn is_output_redirection(text: &str) -> bool {
305 let redirect = text.trim_start_matches(|character: char| character.is_ascii_digit());
306 if redirect.starts_with("&>") {
307 return !redirect.starts_with("&>-");
308 }
309 if let Some(destination) = redirect.strip_prefix(">&") {
310 return destination.trim().chars().all(|character| character.is_ascii_digit());
311 }
312
313 redirect.starts_with('>') && !redirect.starts_with(">&-")
314}
315
316fn parse_with_tree_sitter(script: &str, reject_syntax_errors: bool) -> Result<Vec<Vec<String>>, String> {
318 let parser_guard = get_bash_parser()?;
319 let mut parser = parser_guard.lock().map_err(|e| format!("Failed to lock parser: {e}"))?;
320
321 let tree = parser.parse(script, None).ok_or_else(|| "Failed to parse script".to_string())?;
322
323 let mut commands = Vec::new();
324 let root = tree.root_node();
325 if reject_syntax_errors && root.has_error() {
326 return Err("Shell script contains syntax errors".to_string());
327 }
328
329 collect_commands_from_node(root, script, &mut commands);
333
334 Ok(commands)
335}
336
337fn collect_commands_from_node(node: tree_sitter::Node, source: &str, commands: &mut Vec<Vec<String>>) {
338 match node.kind() {
339 "command" | "simple_command" => {
340 if let Some(cmd) = extract_command_from_node(node, source)
341 && !cmd.is_empty()
342 {
343 commands.push(cmd);
344 }
345 }
346 _ => {
347 let mut cursor = node.walk();
348 for child in node.children(&mut cursor) {
349 collect_commands_from_node(child, source, commands);
350 }
351 }
352 }
353}
354
355fn extract_command_from_node(node: tree_sitter::Node, source: &str) -> Option<Vec<String>> {
357 let mut command = Vec::new();
358 let mut cursor = node.walk();
359
360 if node.kind() == "pipeline" {
362 for child in node.children(&mut cursor) {
363 if child.kind() == "command" || child.kind() == "simple_command" {
364 return extract_command_from_node(child, source);
365 }
366 }
367 }
368
369 for child in node.children(&mut cursor) {
371 if child.kind() == "command_name" {
372 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
373 let trimmed = arg.trim();
374 if !trimmed.is_empty() {
375 command.push(trimmed.to_string());
376 }
377 }
378 continue;
379 }
380
381 if matches!(
382 child.kind(),
383 "word" | "string" | "raw_string" | "ansi_c_string" | "simple_expansion" | "variable_expansion"
384 ) {
385 let text = child.utf8_text(source.as_bytes());
386 if let Ok(arg) = text {
387 let trimmed = arg.trim();
388 if !trimmed.is_empty() {
389 command.push(trimmed.to_string());
390 }
391 }
392 }
393 }
394
395 if command.is_empty() { None } else { Some(command) }
396}
397
398fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
400 let mut commands = Vec::new();
401 let mut current_command = String::new();
402 let mut in_quotes = false;
403 let mut quote_char = ' ';
404 let mut escaped = false;
405
406 for ch in script.chars() {
407 if escaped {
408 current_command.push(ch);
409 escaped = false;
410 continue;
411 }
412
413 match ch {
414 '\\' => {
415 escaped = true;
416 }
417 '\'' | '"' if !in_quotes => {
418 in_quotes = true;
419 quote_char = ch;
420 }
421 c if c == quote_char && in_quotes => {
422 in_quotes = false;
423 }
424 '&' | '|' | ';' if !in_quotes => {
425 if !current_command.trim().is_empty()
426 && let Ok(cmd) = tokenize_command(¤t_command)
427 {
428 commands.push(cmd);
429 }
430 current_command.clear();
431 }
432 _ => current_command.push(ch),
433 }
434 }
435
436 if !current_command.trim().is_empty()
437 && let Ok(cmd) = tokenize_command(¤t_command)
438 {
439 commands.push(cmd);
440 }
441
442 Ok(commands)
443}
444
445fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
448 shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
449}
450
451pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
459 if command.is_empty() {
460 return None;
461 }
462
463 let cmd_name = command[0].as_str();
464 let base_cmd = std::path::Path::new(cmd_name)
465 .file_name()
466 .and_then(|osstr| osstr.to_str())
467 .unwrap_or("");
468
469 if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
470 return None;
471 }
472
473 for window in command.windows(2) {
475 if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
476 let script = &window[1];
477 return parse_shell_commands(script).ok();
478 }
479 }
480
481 None
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn tokenize_simple_command() {
490 let cmd = "git status";
491 let tokens = tokenize_command(cmd).unwrap();
492 assert_eq!(tokens, vec!["git", "status"]);
493 }
494
495 #[test]
496 fn tokenize_quoted_arguments() {
497 let cmd = r#"echo "hello world""#;
498 let tokens = tokenize_command(cmd).unwrap();
499 assert_eq!(tokens, vec!["echo", "hello world"]);
500 }
501
502 #[test]
503 fn parse_single_command() {
504 let script = "git status";
505 let commands = parse_shell_commands(script).unwrap();
506 assert_eq!(commands.len(), 1);
507 assert_eq!(commands[0][0], "git");
508 }
509
510 #[test]
511 fn parse_chained_commands_with_and() {
512 let script = "git status && cargo check";
513 let commands = parse_shell_commands(script).unwrap();
514 assert_eq!(commands.len(), 2);
515 assert_eq!(commands[0][0], "git");
516 assert_eq!(commands[1][0], "cargo");
517 }
518
519 #[test]
520 fn parse_loop_body_commands() {
521 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";
522 let commands = parse_shell_commands(script).unwrap();
523
524 assert_eq!(commands[0], vec!["cd", "crates/codegen/vtcode-core/src/tools/registry"]);
525 assert!(
526 commands
527 .iter()
528 .any(|command| command.first().is_some_and(|name| name == "echo"))
529 );
530 assert!(
531 commands
532 .iter()
533 .any(|command| command.first().is_some_and(|name| name == "grep"))
534 );
535 assert!(
536 commands
537 .iter()
538 .any(|command| command.first().is_some_and(|name| name == "head"))
539 );
540 }
541
542 #[test]
543 fn parse_chained_commands_with_semicolon() {
544 let script = "git status; cargo check";
545 let commands = parse_shell_commands(script).unwrap();
546 assert_eq!(commands.len(), 2);
547 }
548
549 #[test]
550 fn parse_bash_lc_git_status() {
551 let cmd = vec!["bash".to_string(), "-lc".to_string(), "git status".to_string()];
552 let commands = parse_bash_lc_commands(&cmd);
553 assert!(commands.is_some());
554 let commands = commands.unwrap();
555 assert_eq!(commands.len(), 1);
556 assert_eq!(commands[0][0], "git");
557 }
558
559 #[test]
560 fn parse_bash_lc_chained() {
561 let cmd = vec![
562 "bash".to_string(),
563 "-lc".to_string(),
564 "git status && cargo check".to_string(),
565 ];
566 let commands = parse_bash_lc_commands(&cmd);
567 assert!(commands.is_some());
568 let commands = commands.unwrap();
569 assert_eq!(commands.len(), 2);
570 }
571
572 #[test]
573 fn parse_non_bash_command_returns_none() {
574 let cmd = vec!["echo".to_string(), "hello".to_string()];
575 let commands = parse_bash_lc_commands(&cmd);
576 assert!(commands.is_none());
577 }
578
579 #[test]
580 fn parse_bash_without_lc_returns_none() {
581 let cmd = vec!["bash".to_string(), "script.sh".to_string()];
582 let commands = parse_bash_lc_commands(&cmd);
583 assert!(commands.is_none());
584 }
585
586 #[test]
589 fn parse_complex_pipeline() {
590 let script = "cat file.txt | grep -i pattern | sort";
591 let commands = parse_shell_commands(script).unwrap();
592 assert!(!commands.is_empty());
593 }
594
595 #[test]
596 fn parse_with_pipes_and_redirects() {
597 let script = "ls -la | grep file > output.txt";
598 let commands = parse_shell_commands(script).unwrap();
599 assert!(!commands.is_empty());
600 }
601
602 #[test]
603 fn parse_command_substitution_fallback() {
604 let script = "echo $(git status)";
605 let commands = parse_shell_commands(script).unwrap();
606 assert!(!commands.is_empty());
607 }
608
609 #[test]
610 fn parse_escaped_quotes() {
611 let script = r#"echo "hello \"world\"""#;
612 let commands = parse_shell_commands(script).unwrap();
613 assert!(!commands.is_empty());
614 }
615
616 #[test]
617 fn parse_tree_sitter_preserves_command_name_with_quoted_args() {
618 let script = r#"echo "fish and chips""#;
619 let commands = parse_shell_commands_tree_sitter(script).unwrap();
620 assert!(!commands.is_empty());
621 assert_eq!(commands[0][0], "echo");
622 }
623
624 #[test]
625 fn parse_tree_sitter_preserves_single_and_ansi_quoted_args() {
626 let script = r#"printf '\n' && git diff '--output=out.txt' && printf $'\n'"#;
627 let commands = parse_shell_commands_tree_sitter(script).unwrap();
628 assert!(
629 commands
630 .iter()
631 .any(|command| command.iter().any(|word| word.contains("--output=out.txt")))
632 );
633 assert!(commands.iter().any(|command| command.iter().any(|word| word.contains("\\n"))));
634 }
635
636 #[test]
637 fn dynamic_syntax_allows_literal_escapes_inside_double_quoted_arguments() {
638 assert!(!contains_dynamic_shell_syntax(r#"rg -n "\[profile|lto|codegen-units|strip" Cargo.toml"#));
639 assert!(!contains_dynamic_shell_syntax(r#"printf "\nTop-level:\n""#));
640 assert!(!contains_dynamic_shell_syntax(r#"printf "quoted: \"value\"""#));
641 assert!(contains_dynamic_shell_syntax(r#"echo "safe\"$(id)""#));
642 }
643
644 #[test]
645 fn dynamic_syntax_rejects_unquoted_escapes() {
646 assert!(contains_dynamic_shell_syntax(r"rg -n \[profile Cargo.toml"));
647 }
648
649 #[test]
650 fn output_redirection_guard_rejects_input_and_heredoc_shapes() {
651 assert!(has_only_output_redirections("cargo check > build.log 2>&1"));
652 assert!(has_only_output_redirections("cargo check | head -40 > build.log"));
653 assert!(has_only_output_redirections("cargo check &> build.log"));
654 assert!(has_only_output_redirections("cargo check &>> build.log"));
655 assert!(!has_only_output_redirections("cargo check < build-input.log"));
656 assert!(!has_only_output_redirections("cargo check <<'EOF'\ninput\nEOF"));
657 assert!(!has_only_output_redirections("cargo check > $(printf build.log)"));
658 assert!(!has_only_output_redirections("cargo check > build.log &"));
659 assert!(!has_only_output_redirections("cargo check 2>&-"));
660 }
661
662 #[test]
663 fn strict_tree_sitter_parser_rejects_incomplete_shell_syntax() {
664 assert!(parse_shell_commands_tree_sitter("cargo check &&").is_err());
665 assert!(parse_shell_commands_tree_sitter("echo '").is_err());
666 }
667
668 #[test]
669 fn parse_bash_lc_with_pipe() {
670 let cmd = vec!["bash".to_string(), "-lc".to_string(), "ls -la | head -5".to_string()];
671 let commands = parse_bash_lc_commands(&cmd);
672 assert!(commands.is_some());
673 let cmds = commands.unwrap();
674 assert!(!cmds.is_empty());
675 }
676
677 #[test]
678 fn parse_dangerous_shell_command() {
679 let script = "rm -rf /; echo done";
680 let commands = parse_shell_commands(script).unwrap();
681 assert_eq!(commands.len(), 2);
682 assert_eq!(commands[0][0], "rm");
683 }
684
685 #[test]
686 fn prewarm_bash_parser_initializes_successfully() {
687 prewarm_bash_parser().expect("bash parser should initialize");
688 }
689
690 #[test]
691 fn dynamic_find_syntax_is_detected_without_rejecting_quoted_globs() {
692 assert!(contains_dynamic_find_syntax("find src -maxdepth 0 -exe$''c touch /tmp/VT_BYPASS_POC {} +"));
693 assert!(!contains_dynamic_find_syntax("find src -type f -name '*.rs'"));
694 }
695}
696
697use anyhow::bail;
700
701#[derive(Clone, Copy, Eq, PartialEq)]
703enum QuoteState {
704 None,
705 Single,
706 Double,
707}
708
709pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
712 let mut segments = Vec::new();
713 let mut state = QuoteState::None;
714 let mut escaped = false;
715 let mut segment_start = 0usize;
716 let mut chars = command.char_indices().peekable();
717
718 while let Some((idx, ch)) = chars.next() {
719 match state {
720 QuoteState::Single => {
721 if ch == '\'' {
722 state = QuoteState::None;
723 }
724 }
725 QuoteState::Double => {
726 if escaped {
727 escaped = false;
728 continue;
729 }
730
731 match ch {
732 '\\' => escaped = true,
733 '"' => state = QuoteState::None,
734 '`' => bail!("Command injection pattern detected"),
735 '$' if matches!(chars.peek(), Some((_, '('))) => {
736 bail!("Command injection pattern detected");
737 }
738 _ => {}
739 }
740 }
741 QuoteState::None => {
742 if escaped {
743 escaped = false;
744 continue;
745 }
746
747 match ch {
748 '\\' => escaped = true,
749 '\'' => state = QuoteState::Single,
750 '"' => state = QuoteState::Double,
751 '`' => bail!("Command injection pattern detected"),
752 '$' if matches!(chars.peek(), Some((_, '('))) => {
753 bail!("Command injection pattern detected");
754 }
755 ';' => bail!("Unquoted command chaining detected"),
756 '\n' => bail!(
757 "multi-line shell commands are not allowed; use file tools (write_file/apply_patch) for multi-line content instead of heredocs"
758 ),
759 '|' | '&' => {
760 push_segment(command, segment_start, idx, &mut segments);
761 segment_start = idx + ch.len_utf8();
762 if let Some((next_idx, next_ch)) = chars.peek().copied()
763 && next_ch == ch
764 {
765 let _next = chars.next();
766 segment_start = next_idx + next_ch.len_utf8();
767 }
768 }
769 _ => {}
770 }
771 }
772 }
773 }
774
775 push_segment(command, segment_start, command.len(), &mut segments);
776 Ok(segments)
777}
778
779fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
780 let segment = command[start..end].trim();
781 if !segment.is_empty() {
782 segments.push(segment.to_string());
783 }
784}
785
786pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
788 let segment_lower = segment.to_ascii_lowercase();
789 if segment_lower.starts_with(":(){:|:&};:") {
790 return Some(":(){:|:&};:");
791 }
792
793 let tokens =
794 shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
795 let first = tokens.first()?;
796 let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
797
798 match command_name.as_str() {
799 "rmdir" => Some("rmdir"),
800 "wget" => Some("wget"),
801 "curl" => Some("curl"),
802 "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
803 "chown"
804 if tokens.iter().skip(1).any(|arg| {
805 let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
806 arg == "root" || arg.starts_with("root:")
807 }) =>
808 {
809 Some("chown root")
810 }
811 _ => None,
812 }
813}
814
815fn strip_wrapping_quotes(token: &str) -> &str {
816 token
817 .strip_prefix('\'')
818 .and_then(|token| token.strip_suffix('\''))
819 .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
820 .unwrap_or(token)
821}
822
823fn base_command_name(command: &str) -> &str {
824 std::path::Path::new(command)
825 .file_name()
826 .and_then(|name| name.to_str())
827 .unwrap_or(command)
828}