Skip to main content

start_command/
isolation_shell.rs

1//! Shell command classification and argv rebuilding shared by isolation backends.
2
3use std::process::{Command, Stdio};
4
5use super::{is_debug, IsolationOptions};
6
7const SHELL_NAMES: [&str; 8] = ["bash", "zsh", "sh", "fish", "ksh", "csh", "tcsh", "dash"];
8
9/// Quoting dialect of the shell that will run a rebuilt command line.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ShellQuotingStyle {
12    /// `sh`, `bash`, `zsh`: a quote is escaped by closing, escaping and reopening.
13    Posix,
14    /// PowerShell: a quote inside a literal string is escaped by doubling it.
15    PowerShell,
16}
17
18impl ShellQuotingStyle {
19    /// The dialect of the host shell (`powershell.exe` on Windows, `sh` elsewhere).
20    pub fn host() -> Self {
21        if cfg!(windows) {
22            ShellQuotingStyle::PowerShell
23        } else {
24            ShellQuotingStyle::Posix
25        }
26    }
27}
28
29/// True when every character is read literally by the target shell, so no quoting is needed.
30///
31/// The PowerShell set is narrower than the POSIX one: `,` builds an array, `@`
32/// splats and `%` is an alias for ForEach-Object.
33fn is_safe_arg(arg: &str, style: ShellQuotingStyle) -> bool {
34    let extra = match style {
35        ShellQuotingStyle::Posix => "_@%+=:,./^-",
36        ShellQuotingStyle::PowerShell => "_=:./\\-",
37    };
38    !arg.is_empty()
39        && arg
40            .chars()
41            .all(|c| c.is_ascii_alphanumeric() || extra.contains(c))
42}
43
44/// Quote one argv element so the target shell parses it back as exactly that element (issue #164).
45pub fn quote_shell_arg_with(arg: &str, style: ShellQuotingStyle) -> String {
46    if is_safe_arg(arg, style) {
47        return arg.to_string();
48    }
49    match style {
50        ShellQuotingStyle::Posix => format!("'{}'", arg.replace('\'', "'\\''")),
51        ShellQuotingStyle::PowerShell => format!("'{}'", arg.replace('\'', "''")),
52    }
53}
54
55/// Quote one argv element for the host shell (issue #164).
56pub fn quote_shell_arg(arg: &str) -> String {
57    quote_shell_arg_with(arg, ShellQuotingStyle::host())
58}
59
60/// Rebuild a shell command line from the argv the user typed, for a given shell dialect.
61///
62/// A single element is a shell script the user quoted as a whole (`$ "ls | wc -l"`)
63/// and is kept verbatim; multiple elements were split by the outer shell, so each
64/// one is quoted to survive the inner shell unchanged.
65pub fn build_command_string_with(argv: &[String], style: ShellQuotingStyle) -> String {
66    match argv {
67        [] => String::new(),
68        [single] => single.clone(),
69        _ => argv
70            .iter()
71            .map(|arg| quote_shell_arg_with(arg, style))
72            .collect::<Vec<_>>()
73            .join(" "),
74    }
75}
76
77/// Rebuild a shell command line from the argv the user typed, for the host shell (issue #164).
78pub fn build_command_string(argv: &[String]) -> String {
79    build_command_string_with(argv, ShellQuotingStyle::host())
80}
81
82/// Split a command line into shell words, reversing `quote_shell_arg_with` for
83/// the same dialect: a POSIX shell escapes with a backslash, PowerShell has no
84/// backslash escape (it is a path separator) and writes a literal quote as a
85/// doubled one.
86///
87/// Returns `None` when quoting is unbalanced.
88pub fn split_shell_words_with(command: &str, style: ShellQuotingStyle) -> Option<Vec<String>> {
89    let is_powershell = style == ShellQuotingStyle::PowerShell;
90    let mut words: Vec<String> = Vec::new();
91    let mut current = String::new();
92    let mut started = false;
93    let mut quote: Option<char> = None;
94    let mut chars = command.chars().peekable();
95
96    while let Some(c) = chars.next() {
97        if quote.is_none() && c.is_whitespace() {
98            if started {
99                words.push(std::mem::take(&mut current));
100                started = false;
101            }
102            continue;
103        }
104        started = true;
105        if !is_powershell && c == '\\' && quote != Some('\'') {
106            if quote == Some('"') && chars.peek().is_some_and(|next| !"$`\"\\\n".contains(*next)) {
107                current.push(c);
108                continue;
109            }
110            let escaped = chars.next()?;
111            current.push(escaped);
112            continue;
113        }
114        if quote.is_none() && (c == '\'' || c == '"') {
115            quote = Some(c);
116            continue;
117        }
118        if Some(c) == quote {
119            if is_powershell && chars.peek() == Some(&c) {
120                chars.next();
121                current.push(c);
122                continue;
123            }
124            quote = None;
125            continue;
126        }
127        current.push(c);
128    }
129
130    if quote.is_some() {
131        return None;
132    }
133    if started {
134        words.push(current);
135    }
136    Some(words)
137}
138
139/// Split a command line into shell words for the host shell dialect.
140pub fn split_shell_words(command: &str) -> Option<Vec<String>> {
141    split_shell_words_with(command, ShellQuotingStyle::host())
142}
143
144/// Split a command into words, falling back to whitespace splitting when quoting is unbalanced.
145pub fn to_shell_words(command: &str) -> Vec<String> {
146    split_shell_words(command).unwrap_or_else(|| {
147        command
148            .split_whitespace()
149            .map(|part| part.to_string())
150            .collect()
151    })
152}
153
154fn basename(part: &str) -> &str {
155    part.rsplit('/').next().unwrap_or(part)
156}
157
158fn is_shell_command(parts: &[String]) -> bool {
159    parts
160        .first()
161        .is_some_and(|first| SHELL_NAMES.contains(&basename(first)))
162}
163
164/// Literal argv are safe only if no outer shell evaluation is required (#202).
165/// Quoted operators inside the script are literals; expansions in double quotes
166/// and operators outside quotes must be evaluated by the outer command shell.
167fn simple_shell_words(command: &str) -> Option<Vec<String>> {
168    let mut quote = None;
169    let mut chars = command.chars().peekable();
170    while let Some(c) = chars.next() {
171        if c == '\\' && quote != Some('\'') {
172            if chars.peek() == Some(&'\n') {
173                return None;
174            }
175            if quote != Some('"') || chars.peek().is_some_and(|next| "$`\"\\\n".contains(*next)) {
176                chars.next();
177            }
178            continue;
179        }
180        if quote == Some('\'') {
181            if c == '\'' {
182                quote = None;
183            }
184            continue;
185        }
186        if c == '$' || c == '`' {
187            return None;
188        }
189        if quote == Some('"') {
190            if c == '"' {
191                quote = None;
192            }
193            continue;
194        }
195        if ";&|<>(){}\n\r*?[]~#".contains(c) || (c.is_whitespace() && !" \t".contains(c)) {
196            return None;
197        }
198        if c == '\'' || c == '"' {
199            quote = Some(c);
200        }
201    }
202    split_shell_words_with(command, ShellQuotingStyle::Posix)
203}
204
205/// True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84).
206pub fn is_interactive_shell_command(command: &str) -> bool {
207    let Some(parts) = simple_shell_words(command) else {
208        return false;
209    };
210    is_shell_command(&parts) && !parts.iter().any(|part| part == "-c")
211}
212
213/// True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91).
214pub fn is_shell_invocation_with_args(command: &str) -> bool {
215    let Some(parts) = simple_shell_words(command) else {
216        return false;
217    };
218    let Some(index) = parts.iter().position(|part| part == "-c") else {
219        return false;
220    };
221    is_shell_command(&parts)
222        && index > 0
223        && index + 1 < parts.len()
224        && parts[1..index].iter().all(|part| {
225            let flag = part.strip_prefix("--").or_else(|| part.strip_prefix('-'));
226            flag.is_some_and(|flag| {
227                !flag.is_empty() && flag.chars().all(|c| c.is_ascii_alphabetic() || c == '-')
228            })
229        })
230}
231
232/// Build Docker argv without folding command operators or positional arguments.
233pub(crate) fn docker_shell_args(command: &str, shell: &str, interactive: bool) -> Vec<String> {
234    if is_interactive_shell_command(command) {
235        let mut args = to_shell_words(command);
236        if interactive
237            && ["bash", "zsh"].contains(&basename(&args[0]))
238            && !args.iter().any(|arg| arg == "-i")
239        {
240            args.insert(1, "-i".to_string());
241        }
242        return args;
243    }
244    if is_shell_invocation_with_args(command) {
245        return build_shell_with_args_cmd_args(command);
246    }
247    let mut args = vec![shell.to_string()];
248    if ["bash", "zsh"].contains(&basename(shell)) {
249        args.push("-i".to_string());
250    }
251    args.extend(["-c".to_string(), command.to_string()]);
252    args
253}
254
255/// Preserve the script word and all positional argv separately (#202).
256pub fn build_shell_with_args_cmd_args(command: &str) -> Vec<String> {
257    simple_shell_words(command).unwrap_or_else(|| to_shell_words(command))
258}
259
260/// Quote an argument for display only, keeping the user-facing double-quoted form (issue #91).
261fn quote_for_display(arg: &str) -> String {
262    // Display is cosmetic, so it always uses the POSIX set: the rendered command
263    // must look the same on every platform.
264    if is_safe_arg(arg, ShellQuotingStyle::Posix) {
265        arg.to_string()
266    } else if arg.contains('"') {
267        format!("'{}'", arg)
268    } else {
269        format!("\"{}\"", arg)
270    }
271}
272
273/// Build a display string that shows the argument boundaries the user typed (issues #91, #164).
274///
275/// A command the user quoted as one shell script is shown verbatim, because its
276/// metacharacters are meant for the shell, not for display.
277pub fn build_display_command(command: &str) -> String {
278    if is_shell_invocation_with_args(command) {
279        return build_shell_with_args_cmd_args(command)
280            .iter()
281            .map(|arg| quote_for_display(arg))
282            .collect::<Vec<_>>()
283            .join(" ");
284    }
285    match split_shell_words(command) {
286        Some(words) if build_command_string(&words) == command => words
287            .iter()
288            .map(|arg| quote_for_display(arg))
289            .collect::<Vec<_>>()
290            .join(" "),
291        _ => command.to_string(),
292    }
293}
294
295/// First word of a command line, used for failure reports and log headers.
296pub fn command_name(command: &str) -> String {
297    to_shell_words(command)
298        .into_iter()
299        .next()
300        .unwrap_or_default()
301}
302
303/// Detect the best available shell in an isolation environment (docker/ssh)
304/// Tries shells in order: bash, zsh, sh
305/// Returns the shell path to use
306pub fn detect_shell_in_environment(environment: &str, options: &IsolationOptions) -> String {
307    let shell_preference = &options.shell;
308
309    // If a specific shell is requested (not auto), use it directly
310    if !shell_preference.is_empty() && shell_preference != "auto" {
311        if is_debug() {
312            eprintln!("[DEBUG] Using forced shell: {}", shell_preference);
313        }
314        return shell_preference.clone();
315    }
316
317    // In auto mode, try shells in order of preference
318    let shells_to_try = ["bash", "zsh", "sh"];
319
320    if environment == "docker" {
321        let image = match &options.image {
322            Some(i) => i.clone(),
323            None => return "sh".to_string(),
324        };
325
326        for shell in &shells_to_try {
327            let result = Command::new("docker")
328                .args([
329                    "run",
330                    "--rm",
331                    &image,
332                    "sh",
333                    "-c",
334                    &format!("command -v {}", shell),
335                ])
336                .stdout(Stdio::piped())
337                .stderr(Stdio::null())
338                .output();
339
340            if let Ok(output) = result {
341                if output.status.success() {
342                    let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
343                    if !detected.is_empty() {
344                        if is_debug() {
345                            eprintln!(
346                                "[DEBUG] Detected shell in docker image {}: {}",
347                                image, detected
348                            );
349                        }
350                        return detected;
351                    }
352                }
353            }
354        }
355
356        if is_debug() {
357            eprintln!(
358                "[DEBUG] Could not detect shell in docker image {}, falling back to sh",
359                image
360            );
361        }
362        return "sh".to_string();
363    }
364
365    if environment == "ssh" {
366        let endpoint = match &options.endpoint {
367            Some(e) => e.clone(),
368            None => return "sh".to_string(),
369        };
370
371        // Run a single SSH command to check for available shells in order
372        let check_cmd: Vec<String> = shells_to_try
373            .iter()
374            .map(|s| format!("command -v {}", s))
375            .collect();
376        let check_cmd_str = check_cmd.join(" || ");
377
378        let result = Command::new("ssh")
379            .args([&endpoint, &check_cmd_str])
380            .stdout(Stdio::piped())
381            .stderr(Stdio::null())
382            .output();
383
384        if let Ok(output) = result {
385            if output.status.success() {
386                let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
387                if !detected.is_empty() {
388                    if is_debug() {
389                        eprintln!(
390                            "[DEBUG] Detected shell on SSH host {}: {}",
391                            endpoint, detected
392                        );
393                    }
394                    return detected;
395                }
396            }
397        }
398
399        if is_debug() {
400            eprintln!(
401                "[DEBUG] Could not detect shell on SSH host {}, falling back to sh",
402                endpoint
403            );
404        }
405        return "sh".to_string();
406    }
407
408    "sh".to_string()
409}