start_command/
isolation_shell.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ShellQuotingStyle {
12 Posix,
14 PowerShell,
16}
17
18impl ShellQuotingStyle {
19 pub fn host() -> Self {
21 if cfg!(windows) {
22 ShellQuotingStyle::PowerShell
23 } else {
24 ShellQuotingStyle::Posix
25 }
26 }
27}
28
29fn 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
44pub 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
55pub fn quote_shell_arg(arg: &str) -> String {
57 quote_shell_arg_with(arg, ShellQuotingStyle::host())
58}
59
60pub 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
77pub fn build_command_string(argv: &[String]) -> String {
79 build_command_string_with(argv, ShellQuotingStyle::host())
80}
81
82pub 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
139pub fn split_shell_words(command: &str) -> Option<Vec<String>> {
141 split_shell_words_with(command, ShellQuotingStyle::host())
142}
143
144pub 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
164fn 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
205pub 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
213pub 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
232pub(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
255pub 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
260fn quote_for_display(arg: &str) -> String {
262 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
273pub 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
295pub fn command_name(command: &str) -> String {
297 to_shell_words(command)
298 .into_iter()
299 .next()
300 .unwrap_or_default()
301}
302
303pub fn detect_shell_in_environment(environment: &str, options: &IsolationOptions) -> String {
307 let shell_preference = &options.shell;
308
309 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 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 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}