Skip to main content

vtcode_safety/command_safety/
shell_parser.rs

1#![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
7//! Shell script parser for `bash -lc` and similar commands.
8//!
9//! This module parses shell commands like:
10//! ```sh
11//! bash -lc "git status && cargo check"
12//! ```
13//!
14//! Into individual command vectors for independent safety checking:
15//! ```text
16//! [["git", "status"], ["cargo", "check"]]
17//! ```
18//!
19//! **Phase 4 Implementation**: Uses tree-sitter for accurate bash AST parsing.
20//! Falls back to basic tokenization for minimal shell syntax.
21
22use std::sync::Mutex;
23use std::sync::OnceLock;
24
25/// Lazy-initialized tree-sitter bash parser (wrapped in Mutex for mutation)
26static BASH_PARSER: OnceLock<Result<Mutex<tree_sitter::Parser>, String>> = OnceLock::new();
27
28/// Returns whether a shell command contains syntax whose meaning depends on
29/// shell expansion rather than the literal argument text.
30///
31/// Safety-sensitive classification must only operate on static command
32/// shapes. Parameter expansion, command substitution, brace expansion,
33/// globbing, and backslash escapes can otherwise turn a harmless-looking token
34/// into a different executable argument at runtime.
35pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
36    enum ShellQuote {
37        Single,
38        Double,
39    }
40
41    let mut quote: Option<ShellQuote> = None;
42
43    for character in command.chars() {
44        match quote {
45            Some(ShellQuote::Single) => {
46                if character == '\'' {
47                    quote = None;
48                }
49            }
50            Some(ShellQuote::Double) => match character {
51                '"' => quote = None,
52                '\\' | '$' | '`' => return true,
53                _ => {}
54            },
55            None => match character {
56                '\'' => quote = Some(ShellQuote::Single),
57                '"' => quote = Some(ShellQuote::Double),
58                '\\' | '$' | '`' | '{' | '}' | '*' | '?' | '[' | ']' => return true,
59                _ => {}
60            },
61        }
62    }
63
64    quote.is_some()
65}
66
67/// Returns whether a `find` command contains shell syntax that can change the
68/// literal option tokens after approval-time tokenization.
69pub fn contains_dynamic_find_syntax(script: &str) -> bool {
70    if let Ok(commands) = parse_shell_commands_tree_sitter(script)
71        && commands.iter().any(|command| {
72            command
73                .first()
74                .map(|program| base_command_name(program) == "find")
75                .unwrap_or(false)
76                && command.iter().any(|word| contains_dynamic_shell_syntax(word))
77        })
78    {
79        return true;
80    }
81
82    // Be conservative when the grammar cannot identify the command shape: a
83    // raw script containing a find invocation and dynamic syntax must not pass
84    // preflight just because parsing was incomplete.
85    let has_find_word = script.split_whitespace().any(|word| {
86        let command = word.trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '/');
87        base_command_name(command) == "find"
88    });
89    has_find_word && contains_dynamic_shell_syntax(script)
90}
91
92/// Gets or initializes the bash parser
93fn get_bash_parser() -> Result<&'static Mutex<tree_sitter::Parser>, String> {
94    BASH_PARSER
95        .get_or_init(|| {
96            let mut parser = tree_sitter::Parser::new();
97            let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
98            parser
99                .set_language(&lang)
100                .map_err(|e| format!("Failed to load bash grammar: {e}"))?;
101            Ok(Mutex::new(parser))
102        })
103        .as_ref()
104        .map_err(Clone::clone)
105}
106
107/// Ensures the bash tree-sitter parser is initialized.
108pub fn prewarm_bash_parser() -> Result<(), String> {
109    let _ = get_bash_parser()?;
110    Ok(())
111}
112
113/// Parses a shell script into individual commands using tree-sitter bash grammar
114///
115/// # Example
116/// ```text
117/// Input:  "git status && cargo check"
118/// Output: Ok([["git", "status"], ["cargo", "check"]])
119/// ```
120///
121/// # Fallback
122/// If tree-sitter parsing fails, falls back to simple tokenization
123pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
124    // Try tree-sitter parsing first
125    match parse_with_tree_sitter(script, false) {
126        Ok(commands) if !commands.is_empty() => return Ok(commands),
127        Ok(_) => {} // Empty result, fall through to basic parsing
128        Err(e) => {
129            tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
130        }
131    }
132
133    // Fallback to simple tokenization
134    parse_with_basic_tokenization(script)
135}
136
137/// Parses a shell script using tree-sitter bash grammar only (no fallback tokenization).
138///
139/// Use this when caller behavior must be strictly gated on bash grammar validity.
140pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
141    parse_with_tree_sitter(script, true)
142}
143
144/// Returns whether every redirection in a static shell script only routes
145/// command output. Input, heredoc, and descriptor-closing redirections remain
146/// unsupported so progress classification can fail closed.
147pub fn has_only_output_redirections(script: &str) -> bool {
148    if contains_dynamic_shell_syntax(script) {
149        return false;
150    }
151    if contains_background_operator(script) {
152        return false;
153    }
154
155    let Ok(parser) = get_bash_parser() else {
156        return false;
157    };
158    let Ok(mut parser) = parser.lock() else {
159        return false;
160    };
161    let Some(tree) = parser.parse(script, None) else {
162        return false;
163    };
164    if tree.root_node().has_error() {
165        return false;
166    }
167
168    let mut saw_redirection = false;
169    if !collect_output_redirections(tree.root_node(), script, &mut saw_redirection) {
170        return false;
171    }
172    saw_redirection
173}
174
175fn contains_background_operator(script: &str) -> bool {
176    let chars = script.chars().collect::<Vec<_>>();
177    let mut index = 0;
178    let mut in_single_quote = false;
179    let mut in_double_quote = false;
180
181    while index < chars.len() {
182        let character = chars[index];
183        if character == '\'' && !in_double_quote {
184            in_single_quote = !in_single_quote;
185            index += 1;
186            continue;
187        }
188        if character == '"' && !in_single_quote {
189            in_double_quote = !in_double_quote;
190            index += 1;
191            continue;
192        }
193        if in_single_quote || in_double_quote {
194            index += 1;
195            continue;
196        }
197
198        if character == '&' {
199            let previous = index.checked_sub(1).and_then(|position| chars.get(position));
200            let next = chars.get(index + 1);
201            if next == Some(&'&') {
202                index += 2;
203                continue;
204            }
205            let part_of_allowed_operator = next == Some(&'&')
206                || next == Some(&'>')
207                || previous == Some(&'>')
208                || previous == Some(&'|')
209                || previous == Some(&'<');
210            if !part_of_allowed_operator {
211                return true;
212            }
213        }
214        index += 1;
215    }
216
217    false
218}
219
220fn collect_output_redirections(node: tree_sitter::Node, source: &str, saw_redirection: &mut bool) -> bool {
221    match node.kind() {
222        "file_redirect" => {
223            *saw_redirection = true;
224            let Ok(text) = node.utf8_text(source.as_bytes()) else {
225                return false;
226            };
227            if !is_output_redirection(text) {
228                return false;
229            }
230        }
231        "heredoc_redirect" | "herestring_redirect" => return false,
232        _ => {}
233    }
234
235    let mut cursor = node.walk();
236    node.children(&mut cursor)
237        .all(|child| collect_output_redirections(child, source, saw_redirection))
238}
239
240fn is_output_redirection(text: &str) -> bool {
241    let redirect = text.trim_start_matches(|character: char| character.is_ascii_digit());
242    if redirect.starts_with("&>") {
243        return !redirect.starts_with("&>-");
244    }
245    if let Some(destination) = redirect.strip_prefix(">&") {
246        return destination.trim().chars().all(|character| character.is_ascii_digit());
247    }
248
249    redirect.starts_with('>') && !redirect.starts_with(">&-")
250}
251
252/// Parses shell script using tree-sitter bash grammar.
253fn parse_with_tree_sitter(script: &str, reject_syntax_errors: bool) -> Result<Vec<Vec<String>>, String> {
254    let parser_guard = get_bash_parser()?;
255    let mut parser = parser_guard.lock().map_err(|e| format!("Failed to lock parser: {e}"))?;
256
257    let tree = parser.parse(script, None).ok_or_else(|| "Failed to parse script".to_string())?;
258
259    let mut commands = Vec::new();
260    let root = tree.root_node();
261    if reject_syntax_errors && root.has_error() {
262        return Err("Shell script contains syntax errors".to_string());
263    }
264
265    // Walk the full tree so commands inside loops/conditionals are remembered
266    // for approval and checked for safety.  Top-level-only extraction misses
267    // common read loops such as `for f in ...; do grep ...; done`.
268    collect_commands_from_node(root, script, &mut commands);
269
270    Ok(commands)
271}
272
273fn collect_commands_from_node(node: tree_sitter::Node, source: &str, commands: &mut Vec<Vec<String>>) {
274    match node.kind() {
275        "command" | "simple_command" => {
276            if let Some(cmd) = extract_command_from_node(node, source)
277                && !cmd.is_empty()
278            {
279                commands.push(cmd);
280            }
281        }
282        _ => {
283            let mut cursor = node.walk();
284            for child in node.children(&mut cursor) {
285                collect_commands_from_node(child, source, commands);
286            }
287        }
288    }
289}
290
291/// Extracts a command vector from a tree-sitter node
292fn extract_command_from_node(node: tree_sitter::Node, source: &str) -> Option<Vec<String>> {
293    let mut command = Vec::new();
294    let mut cursor = node.walk();
295
296    // For pipeline nodes, extract the first command in the pipeline
297    if node.kind() == "pipeline" {
298        for child in node.children(&mut cursor) {
299            if child.kind() == "command" || child.kind() == "simple_command" {
300                return extract_command_from_node(child, source);
301            }
302        }
303    }
304
305    // Extract arguments from command node
306    for child in node.children(&mut cursor) {
307        if child.kind() == "command_name" {
308            if let Ok(arg) = child.utf8_text(source.as_bytes()) {
309                let trimmed = arg.trim();
310                if !trimmed.is_empty() {
311                    command.push(trimmed.to_string());
312                }
313            }
314            continue;
315        }
316
317        if matches!(
318            child.kind(),
319            "word" | "string" | "raw_string" | "ansi_c_string" | "simple_expansion" | "variable_expansion"
320        ) {
321            let text = child.utf8_text(source.as_bytes());
322            if let Ok(arg) = text {
323                let trimmed = arg.trim();
324                if !trimmed.is_empty() {
325                    command.push(trimmed.to_string());
326                }
327            }
328        }
329    }
330
331    if command.is_empty() { None } else { Some(command) }
332}
333
334/// Fallback: Parses shell script with simple tokenization
335fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
336    let mut commands = Vec::new();
337    let mut current_command = String::new();
338    let mut in_quotes = false;
339    let mut quote_char = ' ';
340    let mut escaped = false;
341
342    for ch in script.chars() {
343        if escaped {
344            current_command.push(ch);
345            escaped = false;
346            continue;
347        }
348
349        match ch {
350            '\\' => {
351                escaped = true;
352            }
353            '\'' | '"' if !in_quotes => {
354                in_quotes = true;
355                quote_char = ch;
356            }
357            c if c == quote_char && in_quotes => {
358                in_quotes = false;
359            }
360            '&' | '|' | ';' if !in_quotes => {
361                if !current_command.trim().is_empty()
362                    && let Ok(cmd) = tokenize_command(&current_command)
363                {
364                    commands.push(cmd);
365                }
366                current_command.clear();
367            }
368            _ => current_command.push(ch),
369        }
370    }
371
372    if !current_command.trim().is_empty()
373        && let Ok(cmd) = tokenize_command(&current_command)
374    {
375        commands.push(cmd);
376    }
377
378    Ok(commands)
379}
380
381/// Splits a command string into arguments
382/// Respects quoted strings and escapes
383fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
384    shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
385}
386
387/// Parses `bash -lc "script"` style invocations
388///
389/// # Example
390/// ```text
391/// Input:  vec!["bash", "-lc", "git status && rm /"]
392/// Output: Some([["git", "status"], ["rm", "/"]])
393/// ```
394pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
395    if command.is_empty() {
396        return None;
397    }
398
399    let cmd_name = command[0].as_str();
400    let base_cmd = std::path::Path::new(cmd_name)
401        .file_name()
402        .and_then(|osstr| osstr.to_str())
403        .unwrap_or("");
404
405    if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
406        return None;
407    }
408
409    // Look for -lc or -c pattern
410    for window in command.windows(2) {
411        if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
412            let script = &window[1];
413            return parse_shell_commands(script).ok();
414        }
415    }
416
417    None
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn tokenize_simple_command() {
426        let cmd = "git status";
427        let tokens = tokenize_command(cmd).unwrap();
428        assert_eq!(tokens, vec!["git", "status"]);
429    }
430
431    #[test]
432    fn tokenize_quoted_arguments() {
433        let cmd = r#"echo "hello world""#;
434        let tokens = tokenize_command(cmd).unwrap();
435        assert_eq!(tokens, vec!["echo", "hello world"]);
436    }
437
438    #[test]
439    fn parse_single_command() {
440        let script = "git status";
441        let commands = parse_shell_commands(script).unwrap();
442        assert_eq!(commands.len(), 1);
443        assert_eq!(commands[0][0], "git");
444    }
445
446    #[test]
447    fn parse_chained_commands_with_and() {
448        let script = "git status && cargo check";
449        let commands = parse_shell_commands(script).unwrap();
450        assert_eq!(commands.len(), 2);
451        assert_eq!(commands[0][0], "git");
452        assert_eq!(commands[1][0], "cargo");
453    }
454
455    #[test]
456    fn parse_loop_body_commands() {
457        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";
458        let commands = parse_shell_commands(script).unwrap();
459
460        assert_eq!(commands[0], vec!["cd", "crates/codegen/vtcode-core/src/tools/registry"]);
461        assert!(
462            commands
463                .iter()
464                .any(|command| command.first().is_some_and(|name| name == "echo"))
465        );
466        assert!(
467            commands
468                .iter()
469                .any(|command| command.first().is_some_and(|name| name == "grep"))
470        );
471        assert!(
472            commands
473                .iter()
474                .any(|command| command.first().is_some_and(|name| name == "head"))
475        );
476    }
477
478    #[test]
479    fn parse_chained_commands_with_semicolon() {
480        let script = "git status; cargo check";
481        let commands = parse_shell_commands(script).unwrap();
482        assert_eq!(commands.len(), 2);
483    }
484
485    #[test]
486    fn parse_bash_lc_git_status() {
487        let cmd = vec!["bash".to_string(), "-lc".to_string(), "git status".to_string()];
488        let commands = parse_bash_lc_commands(&cmd);
489        assert!(commands.is_some());
490        let commands = commands.unwrap();
491        assert_eq!(commands.len(), 1);
492        assert_eq!(commands[0][0], "git");
493    }
494
495    #[test]
496    fn parse_bash_lc_chained() {
497        let cmd = vec![
498            "bash".to_string(),
499            "-lc".to_string(),
500            "git status && cargo check".to_string(),
501        ];
502        let commands = parse_bash_lc_commands(&cmd);
503        assert!(commands.is_some());
504        let commands = commands.unwrap();
505        assert_eq!(commands.len(), 2);
506    }
507
508    #[test]
509    fn parse_non_bash_command_returns_none() {
510        let cmd = vec!["echo".to_string(), "hello".to_string()];
511        let commands = parse_bash_lc_commands(&cmd);
512        assert!(commands.is_none());
513    }
514
515    #[test]
516    fn parse_bash_without_lc_returns_none() {
517        let cmd = vec!["bash".to_string(), "script.sh".to_string()];
518        let commands = parse_bash_lc_commands(&cmd);
519        assert!(commands.is_none());
520    }
521
522    // Phase 4 tests: Tree-sitter based parsing
523
524    #[test]
525    fn parse_complex_pipeline() {
526        let script = "cat file.txt | grep -i pattern | sort";
527        let commands = parse_shell_commands(script).unwrap();
528        assert!(!commands.is_empty());
529    }
530
531    #[test]
532    fn parse_with_pipes_and_redirects() {
533        let script = "ls -la | grep file > output.txt";
534        let commands = parse_shell_commands(script).unwrap();
535        assert!(!commands.is_empty());
536    }
537
538    #[test]
539    fn parse_command_substitution_fallback() {
540        let script = "echo $(git status)";
541        let commands = parse_shell_commands(script).unwrap();
542        assert!(!commands.is_empty());
543    }
544
545    #[test]
546    fn parse_escaped_quotes() {
547        let script = r#"echo "hello \"world\"""#;
548        let commands = parse_shell_commands(script).unwrap();
549        assert!(!commands.is_empty());
550    }
551
552    #[test]
553    fn parse_tree_sitter_preserves_command_name_with_quoted_args() {
554        let script = r#"echo "fish and chips""#;
555        let commands = parse_shell_commands_tree_sitter(script).unwrap();
556        assert!(!commands.is_empty());
557        assert_eq!(commands[0][0], "echo");
558    }
559
560    #[test]
561    fn parse_tree_sitter_preserves_single_and_ansi_quoted_args() {
562        let script = r#"printf '\n' && git diff '--output=out.txt' && printf $'\n'"#;
563        let commands = parse_shell_commands_tree_sitter(script).unwrap();
564        assert!(
565            commands
566                .iter()
567                .any(|command| command.iter().any(|word| word.contains("--output=out.txt")))
568        );
569        assert!(commands.iter().any(|command| command.iter().any(|word| word.contains("\\n"))));
570    }
571
572    #[test]
573    fn output_redirection_guard_rejects_input_and_heredoc_shapes() {
574        assert!(has_only_output_redirections("cargo check > build.log 2>&1"));
575        assert!(has_only_output_redirections("cargo check | head -40 > build.log"));
576        assert!(has_only_output_redirections("cargo check &> build.log"));
577        assert!(has_only_output_redirections("cargo check &>> build.log"));
578        assert!(!has_only_output_redirections("cargo check < build-input.log"));
579        assert!(!has_only_output_redirections("cargo check <<'EOF'\ninput\nEOF"));
580        assert!(!has_only_output_redirections("cargo check > $(printf build.log)"));
581        assert!(!has_only_output_redirections("cargo check > build.log &"));
582        assert!(!has_only_output_redirections("cargo check 2>&-"));
583    }
584
585    #[test]
586    fn strict_tree_sitter_parser_rejects_incomplete_shell_syntax() {
587        assert!(parse_shell_commands_tree_sitter("cargo check &&").is_err());
588        assert!(parse_shell_commands_tree_sitter("echo '").is_err());
589    }
590
591    #[test]
592    fn parse_bash_lc_with_pipe() {
593        let cmd = vec!["bash".to_string(), "-lc".to_string(), "ls -la | head -5".to_string()];
594        let commands = parse_bash_lc_commands(&cmd);
595        assert!(commands.is_some());
596        let cmds = commands.unwrap();
597        assert!(!cmds.is_empty());
598    }
599
600    #[test]
601    fn parse_dangerous_shell_command() {
602        let script = "rm -rf /; echo done";
603        let commands = parse_shell_commands(script).unwrap();
604        assert_eq!(commands.len(), 2);
605        assert_eq!(commands[0][0], "rm");
606    }
607
608    #[test]
609    fn prewarm_bash_parser_initializes_successfully() {
610        prewarm_bash_parser().expect("bash parser should initialize");
611    }
612
613    #[test]
614    fn dynamic_find_syntax_is_detected_without_rejecting_quoted_globs() {
615        assert!(contains_dynamic_find_syntax("find src -maxdepth 0 -exe$''c touch /tmp/VT_BYPASS_POC {} +"));
616        assert!(!contains_dynamic_find_syntax("find src -type f -name '*.rs'"));
617    }
618}
619
620// === Injection detection (moved from tools::validation::commands) ===
621
622use anyhow::{Result, bail};
623
624/// Quote state for shell segment splitting.
625#[derive(Clone, Copy, Eq, PartialEq)]
626enum QuoteState {
627    None,
628    Single,
629    Double,
630}
631
632/// Split a shell command into segments on unquoted `|` and `&` boundaries,
633/// while detecting injection patterns (`;`, backticks, `$()`, newlines).
634pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
635    let mut segments = Vec::new();
636    let mut state = QuoteState::None;
637    let mut escaped = false;
638    let mut segment_start = 0usize;
639    let mut chars = command.char_indices().peekable();
640
641    while let Some((idx, ch)) = chars.next() {
642        match state {
643            QuoteState::Single => {
644                if ch == '\'' {
645                    state = QuoteState::None;
646                }
647            }
648            QuoteState::Double => {
649                if escaped {
650                    escaped = false;
651                    continue;
652                }
653
654                match ch {
655                    '\\' => escaped = true,
656                    '"' => state = QuoteState::None,
657                    '`' => bail!("Command injection pattern detected"),
658                    '$' if matches!(chars.peek(), Some((_, '('))) => {
659                        bail!("Command injection pattern detected");
660                    }
661                    _ => {}
662                }
663            }
664            QuoteState::None => {
665                if escaped {
666                    escaped = false;
667                    continue;
668                }
669
670                match ch {
671                    '\\' => escaped = true,
672                    '\'' => state = QuoteState::Single,
673                    '"' => state = QuoteState::Double,
674                    '`' => bail!("Command injection pattern detected"),
675                    '$' if matches!(chars.peek(), Some((_, '('))) => {
676                        bail!("Command injection pattern detected");
677                    }
678                    ';' => bail!("Unquoted command chaining detected"),
679                    '\n' => bail!("Command injection pattern detected"),
680                    '|' | '&' => {
681                        push_segment(command, segment_start, idx, &mut segments);
682                        segment_start = idx + ch.len_utf8();
683                        if let Some((next_idx, next_ch)) = chars.peek().copied()
684                            && next_ch == ch
685                        {
686                            let _next = chars.next();
687                            segment_start = next_idx + next_ch.len_utf8();
688                        }
689                    }
690                    _ => {}
691                }
692            }
693        }
694    }
695
696    push_segment(command, segment_start, command.len(), &mut segments);
697    Ok(segments)
698}
699
700fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
701    let segment = command[start..end].trim();
702    if !segment.is_empty() {
703        segments.push(segment.to_string());
704    }
705}
706
707/// Check for additional dangerous patterns not covered by the central dangerous-command detector.
708pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
709    let segment_lower = segment.to_ascii_lowercase();
710    if segment_lower.starts_with(":(){:|:&};:") {
711        return Some(":(){:|:&};:");
712    }
713
714    let tokens =
715        shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
716    let first = tokens.first()?;
717    let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
718
719    match command_name.as_str() {
720        "rmdir" => Some("rmdir"),
721        "wget" => Some("wget"),
722        "curl" => Some("curl"),
723        "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
724        "chown"
725            if tokens.iter().skip(1).any(|arg| {
726                let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
727                arg == "root" || arg.starts_with("root:")
728            }) =>
729        {
730            Some("chown root")
731        }
732        _ => None,
733    }
734}
735
736fn strip_wrapping_quotes(token: &str) -> &str {
737    token
738        .strip_prefix('\'')
739        .and_then(|token| token.strip_suffix('\''))
740        .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
741        .unwrap_or(token)
742}
743
744fn base_command_name(command: &str) -> &str {
745    std::path::Path::new(command)
746        .file_name()
747        .and_then(|name| name.to_str())
748        .unwrap_or(command)
749}