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 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
135pub fn split_shell_words(command: &str) -> Option<Vec<String>> {
137 split_shell_words_with(command, ShellQuotingStyle::host())
138}
139
140pub 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
160pub 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
166pub 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
172pub 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
186fn quote_for_display(arg: &str) -> String {
188 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
199pub 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
221pub fn command_name(command: &str) -> String {
223 to_shell_words(command)
224 .into_iter()
225 .next()
226 .unwrap_or_default()
227}
228
229pub fn detect_shell_in_environment(environment: &str, options: &IsolationOptions) -> String {
233 let shell_preference = &options.shell;
234
235 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 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 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}