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            let escaped = chars.next()?;
107            current.push(escaped);
108            continue;
109        }
110        if quote.is_none() && (c == '\'' || c == '"') {
111            quote = Some(c);
112            continue;
113        }
114        if Some(c) == quote {
115            if is_powershell && chars.peek() == Some(&c) {
116                chars.next();
117                current.push(c);
118                continue;
119            }
120            quote = None;
121            continue;
122        }
123        current.push(c);
124    }
125
126    if quote.is_some() {
127        return None;
128    }
129    if started {
130        words.push(current);
131    }
132    Some(words)
133}
134
135/// Split a command line into shell words for the host shell dialect.
136pub fn split_shell_words(command: &str) -> Option<Vec<String>> {
137    split_shell_words_with(command, ShellQuotingStyle::host())
138}
139
140/// Split a command into words, falling back to whitespace splitting when quoting is unbalanced.
141pub fn to_shell_words(command: &str) -> Vec<String> {
142    split_shell_words(command).unwrap_or_else(|| {
143        command
144            .split_whitespace()
145            .map(|part| part.to_string())
146            .collect()
147    })
148}
149
150fn basename(part: &str) -> &str {
151    part.rsplit('/').next().unwrap_or(part)
152}
153
154fn is_shell_command(parts: &[String]) -> bool {
155    parts
156        .first()
157        .is_some_and(|first| SHELL_NAMES.contains(&basename(first)))
158}
159
160/// True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84).
161pub fn is_interactive_shell_command(command: &str) -> bool {
162    let parts = to_shell_words(command);
163    is_shell_command(&parts) && !parts.iter().any(|part| part == "-c")
164}
165
166/// True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91).
167pub fn is_shell_invocation_with_args(command: &str) -> bool {
168    let parts = to_shell_words(command);
169    is_shell_command(&parts) && parts.iter().any(|part| part == "-c")
170}
171
172/// Build argv for a shell-with-c command; everything after -c is one script argument.
173pub fn build_shell_with_args_cmd_args(command: &str) -> Vec<String> {
174    let parts = to_shell_words(command);
175    let Some(index) = parts.iter().position(|part| part == "-c") else {
176        return parts;
177    };
178    let script = parts[index + 1..].join(" ");
179    let mut result = parts[..=index].to_vec();
180    if !script.is_empty() {
181        result.push(script);
182    }
183    result
184}
185
186/// Quote an argument for display only, keeping the user-facing double-quoted form (issue #91).
187fn quote_for_display(arg: &str) -> String {
188    // Display is cosmetic, so it always uses the POSIX set: the rendered command
189    // must look the same on every platform.
190    if is_safe_arg(arg, ShellQuotingStyle::Posix) {
191        arg.to_string()
192    } else if arg.contains('"') {
193        format!("'{}'", arg)
194    } else {
195        format!("\"{}\"", arg)
196    }
197}
198
199/// Build a display string that shows the argument boundaries the user typed (issues #91, #164).
200///
201/// A command the user quoted as one shell script is shown verbatim, because its
202/// metacharacters are meant for the shell, not for display.
203pub fn build_display_command(command: &str) -> String {
204    if is_shell_invocation_with_args(command) {
205        return build_shell_with_args_cmd_args(command)
206            .iter()
207            .map(|arg| quote_for_display(arg))
208            .collect::<Vec<_>>()
209            .join(" ");
210    }
211    match split_shell_words(command) {
212        Some(words) if build_command_string(&words) == command => words
213            .iter()
214            .map(|arg| quote_for_display(arg))
215            .collect::<Vec<_>>()
216            .join(" "),
217        _ => command.to_string(),
218    }
219}
220
221/// First word of a command line, used for failure reports and log headers.
222pub fn command_name(command: &str) -> String {
223    to_shell_words(command)
224        .into_iter()
225        .next()
226        .unwrap_or_default()
227}
228
229/// Detect the best available shell in an isolation environment (docker/ssh)
230/// Tries shells in order: bash, zsh, sh
231/// Returns the shell path to use
232pub fn detect_shell_in_environment(environment: &str, options: &IsolationOptions) -> String {
233    let shell_preference = &options.shell;
234
235    // If a specific shell is requested (not auto), use it directly
236    if !shell_preference.is_empty() && shell_preference != "auto" {
237        if is_debug() {
238            eprintln!("[DEBUG] Using forced shell: {}", shell_preference);
239        }
240        return shell_preference.clone();
241    }
242
243    // In auto mode, try shells in order of preference
244    let shells_to_try = ["bash", "zsh", "sh"];
245
246    if environment == "docker" {
247        let image = match &options.image {
248            Some(i) => i.clone(),
249            None => return "sh".to_string(),
250        };
251
252        for shell in &shells_to_try {
253            let result = Command::new("docker")
254                .args([
255                    "run",
256                    "--rm",
257                    &image,
258                    "sh",
259                    "-c",
260                    &format!("command -v {}", shell),
261                ])
262                .stdout(Stdio::piped())
263                .stderr(Stdio::null())
264                .output();
265
266            if let Ok(output) = result {
267                if output.status.success() {
268                    let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
269                    if !detected.is_empty() {
270                        if is_debug() {
271                            eprintln!(
272                                "[DEBUG] Detected shell in docker image {}: {}",
273                                image, detected
274                            );
275                        }
276                        return detected;
277                    }
278                }
279            }
280        }
281
282        if is_debug() {
283            eprintln!(
284                "[DEBUG] Could not detect shell in docker image {}, falling back to sh",
285                image
286            );
287        }
288        return "sh".to_string();
289    }
290
291    if environment == "ssh" {
292        let endpoint = match &options.endpoint {
293            Some(e) => e.clone(),
294            None => return "sh".to_string(),
295        };
296
297        // Run a single SSH command to check for available shells in order
298        let check_cmd: Vec<String> = shells_to_try
299            .iter()
300            .map(|s| format!("command -v {}", s))
301            .collect();
302        let check_cmd_str = check_cmd.join(" || ");
303
304        let result = Command::new("ssh")
305            .args([&endpoint, &check_cmd_str])
306            .stdout(Stdio::piped())
307            .stderr(Stdio::null())
308            .output();
309
310        if let Ok(output) = result {
311            if output.status.success() {
312                let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
313                if !detected.is_empty() {
314                    if is_debug() {
315                        eprintln!(
316                            "[DEBUG] Detected shell on SSH host {}: {}",
317                            endpoint, detected
318                        );
319                    }
320                    return detected;
321                }
322            }
323        }
324
325        if is_debug() {
326            eprintln!(
327                "[DEBUG] Could not detect shell on SSH host {}, falling back to sh",
328                endpoint
329            );
330        }
331        return "sh".to_string();
332    }
333
334    "sh".to_string()
335}