Skip to main content

start_command/
isolation_shell.rs

1//! Shell command classification and argv rebuilding shared by isolation backends.
2
3const SHELL_NAMES: [&str; 8] = ["bash", "zsh", "sh", "fish", "ksh", "csh", "tcsh", "dash"];
4
5/// Quoting dialect of the shell that will run a rebuilt command line.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ShellQuotingStyle {
8    /// `sh`, `bash`, `zsh`: a quote is escaped by closing, escaping and reopening.
9    Posix,
10    /// PowerShell: a quote inside a literal string is escaped by doubling it.
11    PowerShell,
12}
13
14impl ShellQuotingStyle {
15    /// The dialect of the host shell (`powershell.exe` on Windows, `sh` elsewhere).
16    pub fn host() -> Self {
17        if cfg!(windows) {
18            ShellQuotingStyle::PowerShell
19        } else {
20            ShellQuotingStyle::Posix
21        }
22    }
23}
24
25/// True when every character is read literally by the target shell, so no quoting is needed.
26///
27/// The PowerShell set is narrower than the POSIX one: `,` builds an array, `@`
28/// splats and `%` is an alias for ForEach-Object.
29fn is_safe_arg(arg: &str, style: ShellQuotingStyle) -> bool {
30    let extra = match style {
31        ShellQuotingStyle::Posix => "_@%+=:,./^-",
32        ShellQuotingStyle::PowerShell => "_=:./\\-",
33    };
34    !arg.is_empty()
35        && arg
36            .chars()
37            .all(|c| c.is_ascii_alphanumeric() || extra.contains(c))
38}
39
40/// Quote one argv element so the target shell parses it back as exactly that element (issue #164).
41pub fn quote_shell_arg_with(arg: &str, style: ShellQuotingStyle) -> String {
42    if is_safe_arg(arg, style) {
43        return arg.to_string();
44    }
45    match style {
46        ShellQuotingStyle::Posix => format!("'{}'", arg.replace('\'', "'\\''")),
47        ShellQuotingStyle::PowerShell => format!("'{}'", arg.replace('\'', "''")),
48    }
49}
50
51/// Quote one argv element for the host shell (issue #164).
52pub fn quote_shell_arg(arg: &str) -> String {
53    quote_shell_arg_with(arg, ShellQuotingStyle::host())
54}
55
56/// Rebuild a shell command line from the argv the user typed, for a given shell dialect.
57///
58/// A single element is a shell script the user quoted as a whole (`$ "ls | wc -l"`)
59/// and is kept verbatim; multiple elements were split by the outer shell, so each
60/// one is quoted to survive the inner shell unchanged.
61pub fn build_command_string_with(argv: &[String], style: ShellQuotingStyle) -> String {
62    match argv {
63        [] => String::new(),
64        [single] => single.clone(),
65        _ => argv
66            .iter()
67            .map(|arg| quote_shell_arg_with(arg, style))
68            .collect::<Vec<_>>()
69            .join(" "),
70    }
71}
72
73/// Rebuild a shell command line from the argv the user typed, for the host shell (issue #164).
74pub fn build_command_string(argv: &[String]) -> String {
75    build_command_string_with(argv, ShellQuotingStyle::host())
76}
77
78/// Split a command line into shell words, reversing `quote_shell_arg_with` for
79/// the same dialect: a POSIX shell escapes with a backslash, PowerShell has no
80/// backslash escape (it is a path separator) and writes a literal quote as a
81/// doubled one.
82///
83/// Returns `None` when quoting is unbalanced.
84pub fn split_shell_words_with(command: &str, style: ShellQuotingStyle) -> Option<Vec<String>> {
85    let is_powershell = style == ShellQuotingStyle::PowerShell;
86    let mut words: Vec<String> = Vec::new();
87    let mut current = String::new();
88    let mut started = false;
89    let mut quote: Option<char> = None;
90    let mut chars = command.chars().peekable();
91
92    while let Some(c) = chars.next() {
93        if quote.is_none() && c.is_whitespace() {
94            if started {
95                words.push(std::mem::take(&mut current));
96                started = false;
97            }
98            continue;
99        }
100        started = true;
101        if !is_powershell && c == '\\' && quote != Some('\'') {
102            let escaped = chars.next()?;
103            current.push(escaped);
104            continue;
105        }
106        if quote.is_none() && (c == '\'' || c == '"') {
107            quote = Some(c);
108            continue;
109        }
110        if Some(c) == quote {
111            if is_powershell && chars.peek() == Some(&c) {
112                chars.next();
113                current.push(c);
114                continue;
115            }
116            quote = None;
117            continue;
118        }
119        current.push(c);
120    }
121
122    if quote.is_some() {
123        return None;
124    }
125    if started {
126        words.push(current);
127    }
128    Some(words)
129}
130
131/// Split a command line into shell words for the host shell dialect.
132pub fn split_shell_words(command: &str) -> Option<Vec<String>> {
133    split_shell_words_with(command, ShellQuotingStyle::host())
134}
135
136/// Split a command into words, falling back to whitespace splitting when quoting is unbalanced.
137pub fn to_shell_words(command: &str) -> Vec<String> {
138    split_shell_words(command).unwrap_or_else(|| {
139        command
140            .split_whitespace()
141            .map(|part| part.to_string())
142            .collect()
143    })
144}
145
146fn basename(part: &str) -> &str {
147    part.rsplit('/').next().unwrap_or(part)
148}
149
150fn is_shell_command(parts: &[String]) -> bool {
151    parts
152        .first()
153        .is_some_and(|first| SHELL_NAMES.contains(&basename(first)))
154}
155
156/// True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84).
157pub fn is_interactive_shell_command(command: &str) -> bool {
158    let parts = to_shell_words(command);
159    is_shell_command(&parts) && !parts.iter().any(|part| part == "-c")
160}
161
162/// True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91).
163pub fn is_shell_invocation_with_args(command: &str) -> bool {
164    let parts = to_shell_words(command);
165    is_shell_command(&parts) && parts.iter().any(|part| part == "-c")
166}
167
168/// Build argv for a shell-with-c command; everything after -c is one script argument.
169pub fn build_shell_with_args_cmd_args(command: &str) -> Vec<String> {
170    let parts = to_shell_words(command);
171    let Some(index) = parts.iter().position(|part| part == "-c") else {
172        return parts;
173    };
174    let script = parts[index + 1..].join(" ");
175    let mut result = parts[..=index].to_vec();
176    if !script.is_empty() {
177        result.push(script);
178    }
179    result
180}
181
182/// Quote an argument for display only, keeping the user-facing double-quoted form (issue #91).
183fn quote_for_display(arg: &str) -> String {
184    // Display is cosmetic, so it always uses the POSIX set: the rendered command
185    // must look the same on every platform.
186    if is_safe_arg(arg, ShellQuotingStyle::Posix) {
187        arg.to_string()
188    } else if arg.contains('"') {
189        format!("'{}'", arg)
190    } else {
191        format!("\"{}\"", arg)
192    }
193}
194
195/// Build a display string that shows the argument boundaries the user typed (issues #91, #164).
196///
197/// A command the user quoted as one shell script is shown verbatim, because its
198/// metacharacters are meant for the shell, not for display.
199pub fn build_display_command(command: &str) -> String {
200    if is_shell_invocation_with_args(command) {
201        return build_shell_with_args_cmd_args(command)
202            .iter()
203            .map(|arg| quote_for_display(arg))
204            .collect::<Vec<_>>()
205            .join(" ");
206    }
207    match split_shell_words(command) {
208        Some(words) if build_command_string(&words) == command => words
209            .iter()
210            .map(|arg| quote_for_display(arg))
211            .collect::<Vec<_>>()
212            .join(" "),
213        _ => command.to_string(),
214    }
215}
216
217/// First word of a command line, used for failure reports and log headers.
218pub fn command_name(command: &str) -> String {
219    to_shell_words(command)
220        .into_iter()
221        .next()
222        .unwrap_or_default()
223}