use std::process::{Command, Stdio};
use super::{is_debug, IsolationOptions};
const SHELL_NAMES: [&str; 8] = ["bash", "zsh", "sh", "fish", "ksh", "csh", "tcsh", "dash"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellQuotingStyle {
Posix,
PowerShell,
}
impl ShellQuotingStyle {
pub fn host() -> Self {
if cfg!(windows) {
ShellQuotingStyle::PowerShell
} else {
ShellQuotingStyle::Posix
}
}
}
fn is_safe_arg(arg: &str, style: ShellQuotingStyle) -> bool {
let extra = match style {
ShellQuotingStyle::Posix => "_@%+=:,./^-",
ShellQuotingStyle::PowerShell => "_=:./\\-",
};
!arg.is_empty()
&& arg
.chars()
.all(|c| c.is_ascii_alphanumeric() || extra.contains(c))
}
pub fn quote_shell_arg_with(arg: &str, style: ShellQuotingStyle) -> String {
if is_safe_arg(arg, style) {
return arg.to_string();
}
match style {
ShellQuotingStyle::Posix => format!("'{}'", arg.replace('\'', "'\\''")),
ShellQuotingStyle::PowerShell => format!("'{}'", arg.replace('\'', "''")),
}
}
pub fn quote_shell_arg(arg: &str) -> String {
quote_shell_arg_with(arg, ShellQuotingStyle::host())
}
pub fn build_command_string_with(argv: &[String], style: ShellQuotingStyle) -> String {
match argv {
[] => String::new(),
[single] => single.clone(),
_ => argv
.iter()
.map(|arg| quote_shell_arg_with(arg, style))
.collect::<Vec<_>>()
.join(" "),
}
}
pub fn build_command_string(argv: &[String]) -> String {
build_command_string_with(argv, ShellQuotingStyle::host())
}
pub fn split_shell_words_with(command: &str, style: ShellQuotingStyle) -> Option<Vec<String>> {
let is_powershell = style == ShellQuotingStyle::PowerShell;
let mut words: Vec<String> = Vec::new();
let mut current = String::new();
let mut started = false;
let mut quote: Option<char> = None;
let mut chars = command.chars().peekable();
while let Some(c) = chars.next() {
if quote.is_none() && c.is_whitespace() {
if started {
words.push(std::mem::take(&mut current));
started = false;
}
continue;
}
started = true;
if !is_powershell && c == '\\' && quote != Some('\'') {
if quote == Some('"') && chars.peek().is_some_and(|next| !"$`\"\\\n".contains(*next)) {
current.push(c);
continue;
}
let escaped = chars.next()?;
current.push(escaped);
continue;
}
if quote.is_none() && (c == '\'' || c == '"') {
quote = Some(c);
continue;
}
if Some(c) == quote {
if is_powershell && chars.peek() == Some(&c) {
chars.next();
current.push(c);
continue;
}
quote = None;
continue;
}
current.push(c);
}
if quote.is_some() {
return None;
}
if started {
words.push(current);
}
Some(words)
}
pub fn split_shell_words(command: &str) -> Option<Vec<String>> {
split_shell_words_with(command, ShellQuotingStyle::host())
}
pub fn to_shell_words(command: &str) -> Vec<String> {
split_shell_words(command).unwrap_or_else(|| {
command
.split_whitespace()
.map(|part| part.to_string())
.collect()
})
}
fn basename(part: &str) -> &str {
part.rsplit('/').next().unwrap_or(part)
}
fn is_shell_command(parts: &[String]) -> bool {
parts
.first()
.is_some_and(|first| SHELL_NAMES.contains(&basename(first)))
}
fn simple_shell_words(command: &str) -> Option<Vec<String>> {
let mut quote = None;
let mut chars = command.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' && quote != Some('\'') {
if chars.peek() == Some(&'\n') {
return None;
}
if quote != Some('"') || chars.peek().is_some_and(|next| "$`\"\\\n".contains(*next)) {
chars.next();
}
continue;
}
if quote == Some('\'') {
if c == '\'' {
quote = None;
}
continue;
}
if c == '$' || c == '`' {
return None;
}
if quote == Some('"') {
if c == '"' {
quote = None;
}
continue;
}
if ";&|<>(){}\n\r*?[]~#".contains(c) || (c.is_whitespace() && !" \t".contains(c)) {
return None;
}
if c == '\'' || c == '"' {
quote = Some(c);
}
}
split_shell_words_with(command, ShellQuotingStyle::Posix)
}
pub fn is_interactive_shell_command(command: &str) -> bool {
let Some(parts) = simple_shell_words(command) else {
return false;
};
is_shell_command(&parts) && !parts.iter().any(|part| part == "-c")
}
pub fn is_shell_invocation_with_args(command: &str) -> bool {
let Some(parts) = simple_shell_words(command) else {
return false;
};
let Some(index) = parts.iter().position(|part| part == "-c") else {
return false;
};
is_shell_command(&parts)
&& index > 0
&& index + 1 < parts.len()
&& parts[1..index].iter().all(|part| {
let flag = part.strip_prefix("--").or_else(|| part.strip_prefix('-'));
flag.is_some_and(|flag| {
!flag.is_empty() && flag.chars().all(|c| c.is_ascii_alphabetic() || c == '-')
})
})
}
pub(crate) fn docker_shell_args(command: &str, shell: &str, interactive: bool) -> Vec<String> {
if is_interactive_shell_command(command) {
let mut args = to_shell_words(command);
if interactive
&& ["bash", "zsh"].contains(&basename(&args[0]))
&& !args.iter().any(|arg| arg == "-i")
{
args.insert(1, "-i".to_string());
}
return args;
}
if is_shell_invocation_with_args(command) {
return build_shell_with_args_cmd_args(command);
}
let mut args = vec![shell.to_string()];
if ["bash", "zsh"].contains(&basename(shell)) {
args.push("-i".to_string());
}
args.extend(["-c".to_string(), command.to_string()]);
args
}
pub fn build_shell_with_args_cmd_args(command: &str) -> Vec<String> {
simple_shell_words(command).unwrap_or_else(|| to_shell_words(command))
}
fn quote_for_display(arg: &str) -> String {
if is_safe_arg(arg, ShellQuotingStyle::Posix) {
arg.to_string()
} else if arg.contains('"') {
format!("'{}'", arg)
} else {
format!("\"{}\"", arg)
}
}
pub fn build_display_command(command: &str) -> String {
if is_shell_invocation_with_args(command) {
return build_shell_with_args_cmd_args(command)
.iter()
.map(|arg| quote_for_display(arg))
.collect::<Vec<_>>()
.join(" ");
}
match split_shell_words(command) {
Some(words) if build_command_string(&words) == command => words
.iter()
.map(|arg| quote_for_display(arg))
.collect::<Vec<_>>()
.join(" "),
_ => command.to_string(),
}
}
pub fn command_name(command: &str) -> String {
to_shell_words(command)
.into_iter()
.next()
.unwrap_or_default()
}
pub fn detect_shell_in_environment(environment: &str, options: &IsolationOptions) -> String {
let shell_preference = &options.shell;
if !shell_preference.is_empty() && shell_preference != "auto" {
if is_debug() {
eprintln!("[DEBUG] Using forced shell: {}", shell_preference);
}
return shell_preference.clone();
}
let shells_to_try = ["bash", "zsh", "sh"];
if environment == "docker" {
let image = match &options.image {
Some(i) => i.clone(),
None => return "sh".to_string(),
};
for shell in &shells_to_try {
let result = Command::new("docker")
.args([
"run",
"--rm",
&image,
"sh",
"-c",
&format!("command -v {}", shell),
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output();
if let Ok(output) = result {
if output.status.success() {
let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !detected.is_empty() {
if is_debug() {
eprintln!(
"[DEBUG] Detected shell in docker image {}: {}",
image, detected
);
}
return detected;
}
}
}
}
if is_debug() {
eprintln!(
"[DEBUG] Could not detect shell in docker image {}, falling back to sh",
image
);
}
return "sh".to_string();
}
if environment == "ssh" {
let endpoint = match &options.endpoint {
Some(e) => e.clone(),
None => return "sh".to_string(),
};
let check_cmd: Vec<String> = shells_to_try
.iter()
.map(|s| format!("command -v {}", s))
.collect();
let check_cmd_str = check_cmd.join(" || ");
let result = Command::new("ssh")
.args([&endpoint, &check_cmd_str])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output();
if let Ok(output) = result {
if output.status.success() {
let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !detected.is_empty() {
if is_debug() {
eprintln!(
"[DEBUG] Detected shell on SSH host {}: {}",
endpoint, detected
);
}
return detected;
}
}
}
if is_debug() {
eprintln!(
"[DEBUG] Could not detect shell on SSH host {}, falling back to sh",
endpoint
);
}
return "sh".to_string();
}
"sh".to_string()
}