mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-27 08:34:11 +00:00
Closes #47823 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] No UI changes Release Notes: - Solves the issue where env variables failed to load when a project with dir path had `'`(single quote) in them, for example: `C:\Temp\O'Brien\project_1`. - added a zed pre-quote for directory paths and zed executable - handled pwsh, nushell, cmd, fish, posix, csh, tcsh, rc, xonsh, elvish based terminals - uses `try_quote` for quoting shell paths
1051 lines
35 KiB
Rust
1051 lines
35 KiB
Rust
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{borrow::Cow, fmt, path::Path, sync::LazyLock};
|
|
|
|
/// Shell configuration to open the terminal with.
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Shell {
|
|
/// Use the system's default terminal configuration in /etc/passwd
|
|
#[default]
|
|
System,
|
|
/// Use a specific program with no arguments.
|
|
Program(String),
|
|
/// Use a specific program with arguments.
|
|
WithArguments {
|
|
/// The program to run.
|
|
program: String,
|
|
/// The arguments to pass to the program.
|
|
args: Vec<String>,
|
|
/// An optional string to override the title of the terminal tab
|
|
title_override: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl Shell {
|
|
pub fn program(&self) -> String {
|
|
match self {
|
|
Shell::Program(program) => program.clone(),
|
|
Shell::WithArguments { program, .. } => program.clone(),
|
|
Shell::System => get_system_shell(),
|
|
}
|
|
}
|
|
|
|
pub fn program_and_args(&self) -> (String, &[String]) {
|
|
match self {
|
|
Shell::Program(program) => (program.clone(), &[]),
|
|
Shell::WithArguments { program, args, .. } => (program.clone(), args),
|
|
Shell::System => (get_system_shell(), &[]),
|
|
}
|
|
}
|
|
|
|
pub fn shell_kind(&self, is_windows: bool) -> ShellKind {
|
|
match self {
|
|
Shell::Program(program) => ShellKind::new(program, is_windows),
|
|
Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows),
|
|
Shell::System => ShellKind::system(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum ShellKind {
|
|
#[default]
|
|
Posix,
|
|
Csh,
|
|
Tcsh,
|
|
Rc,
|
|
Fish,
|
|
/// Pre-installed "legacy" powershell for windows
|
|
PowerShell,
|
|
/// PowerShell 7.x
|
|
Pwsh,
|
|
Nushell,
|
|
Cmd,
|
|
Xonsh,
|
|
Elvish,
|
|
}
|
|
|
|
pub fn get_system_shell() -> String {
|
|
if cfg!(windows) {
|
|
get_windows_system_shell()
|
|
} else {
|
|
std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
|
|
}
|
|
}
|
|
|
|
pub fn get_default_system_shell() -> String {
|
|
if cfg!(windows) {
|
|
get_windows_system_shell()
|
|
} else {
|
|
"/bin/sh".to_string()
|
|
}
|
|
}
|
|
|
|
/// Get the default system shell, preferring bash on Windows.
|
|
pub fn get_default_system_shell_preferring_bash() -> String {
|
|
if cfg!(windows) {
|
|
get_windows_bash().unwrap_or_else(|| get_windows_system_shell())
|
|
} else {
|
|
"/bin/sh".to_string()
|
|
}
|
|
}
|
|
|
|
pub fn get_windows_bash() -> Option<String> {
|
|
use std::path::PathBuf;
|
|
|
|
fn find_bash_in_scoop() -> Option<PathBuf> {
|
|
let bash_exe =
|
|
PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\bash.exe");
|
|
bash_exe.exists().then_some(bash_exe)
|
|
}
|
|
|
|
fn find_bash_in_git() -> Option<PathBuf> {
|
|
// /path/to/git/cmd/git.exe/../../bin/bash.exe
|
|
let git = which::which("git").ok()?;
|
|
let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
|
|
git_bash.exists().then_some(git_bash)
|
|
}
|
|
|
|
static BASH: LazyLock<Option<String>> = LazyLock::new(|| {
|
|
let bash = find_bash_in_scoop()
|
|
.or_else(|| find_bash_in_git())
|
|
.map(|p| p.to_string_lossy().into_owned());
|
|
if let Some(ref path) = bash {
|
|
log::info!("Found bash at {}", path);
|
|
}
|
|
bash
|
|
});
|
|
|
|
(*BASH).clone()
|
|
}
|
|
|
|
pub fn get_windows_system_shell() -> String {
|
|
use std::path::PathBuf;
|
|
|
|
fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
|
|
#[cfg(target_pointer_width = "64")]
|
|
let env_var = if find_alternate {
|
|
"ProgramFiles(x86)"
|
|
} else {
|
|
"ProgramFiles"
|
|
};
|
|
|
|
#[cfg(target_pointer_width = "32")]
|
|
let env_var = if find_alternate {
|
|
"ProgramW6432"
|
|
} else {
|
|
"ProgramFiles"
|
|
};
|
|
|
|
let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
|
|
install_base_dir
|
|
.read_dir()
|
|
.ok()?
|
|
.filter_map(Result::ok)
|
|
.filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
|
|
.filter_map(|entry| {
|
|
let dir_name = entry.file_name();
|
|
let dir_name = dir_name.to_string_lossy();
|
|
|
|
let version = if find_preview {
|
|
let dash_index = dir_name.find('-')?;
|
|
if &dir_name[dash_index + 1..] != "preview" {
|
|
return None;
|
|
};
|
|
dir_name[..dash_index].parse::<u32>().ok()?
|
|
} else {
|
|
dir_name.parse::<u32>().ok()?
|
|
};
|
|
|
|
let exe_path = entry.path().join("pwsh.exe");
|
|
if exe_path.exists() {
|
|
Some((version, exe_path))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.max_by_key(|(version, _)| *version)
|
|
.map(|(_, path)| path)
|
|
}
|
|
|
|
fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
|
|
let msix_app_dir =
|
|
PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
|
|
if !msix_app_dir.exists() {
|
|
return None;
|
|
}
|
|
|
|
let prefix = if find_preview {
|
|
"Microsoft.PowerShellPreview_"
|
|
} else {
|
|
"Microsoft.PowerShell_"
|
|
};
|
|
msix_app_dir
|
|
.read_dir()
|
|
.ok()?
|
|
.filter_map(|entry| {
|
|
let entry = entry.ok()?;
|
|
if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
|
|
return None;
|
|
}
|
|
|
|
if !entry.file_name().to_string_lossy().starts_with(prefix) {
|
|
return None;
|
|
}
|
|
|
|
let exe_path = entry.path().join("pwsh.exe");
|
|
exe_path.exists().then_some(exe_path)
|
|
})
|
|
.next()
|
|
}
|
|
|
|
fn find_pwsh_in_scoop() -> Option<PathBuf> {
|
|
let pwsh_exe =
|
|
PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
|
|
pwsh_exe.exists().then_some(pwsh_exe)
|
|
}
|
|
|
|
static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
|
|
let locations = [
|
|
|| find_pwsh_in_programfiles(false, false),
|
|
|| find_pwsh_in_programfiles(true, false),
|
|
|| find_pwsh_in_msix(false),
|
|
|| find_pwsh_in_programfiles(false, true),
|
|
|| find_pwsh_in_msix(true),
|
|
|| find_pwsh_in_programfiles(true, true),
|
|
|| find_pwsh_in_scoop(),
|
|
|| which::which_global("pwsh.exe").ok(),
|
|
|| which::which_global("powershell.exe").ok(),
|
|
];
|
|
|
|
locations
|
|
.into_iter()
|
|
.find_map(|f| f())
|
|
.map(|p| p.to_string_lossy().trim().to_owned())
|
|
.inspect(|shell| log::info!("Found powershell in: {}", shell))
|
|
.unwrap_or_else(|| {
|
|
log::warn!("Powershell not found, falling back to `cmd`");
|
|
"cmd.exe".to_string()
|
|
})
|
|
});
|
|
|
|
(*SYSTEM_SHELL).clone()
|
|
}
|
|
|
|
impl fmt::Display for ShellKind {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
ShellKind::Posix => write!(f, "sh"),
|
|
ShellKind::Csh => write!(f, "csh"),
|
|
ShellKind::Tcsh => write!(f, "tcsh"),
|
|
ShellKind::Fish => write!(f, "fish"),
|
|
ShellKind::PowerShell => write!(f, "powershell"),
|
|
ShellKind::Pwsh => write!(f, "pwsh"),
|
|
ShellKind::Nushell => write!(f, "nu"),
|
|
ShellKind::Cmd => write!(f, "cmd"),
|
|
ShellKind::Rc => write!(f, "rc"),
|
|
ShellKind::Xonsh => write!(f, "xonsh"),
|
|
ShellKind::Elvish => write!(f, "elvish"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ShellKind {
|
|
pub fn system() -> Self {
|
|
Self::new(&get_system_shell(), cfg!(windows))
|
|
}
|
|
|
|
/// Returns whether this shell's command chaining syntax can be parsed by brush-parser.
|
|
///
|
|
/// This is used to determine if we can safely parse shell commands to extract sub-commands
|
|
/// for security purposes (e.g., preventing shell injection in "always allow" patterns).
|
|
///
|
|
/// The brush-parser handles `;` (sequential execution) and `|` (piping), which are
|
|
/// supported by all common shells. It also handles `&&` and `||` for conditional
|
|
/// execution, `$()` and backticks for command substitution, and process substitution.
|
|
///
|
|
/// # Shell Notes
|
|
///
|
|
/// - **Nushell**: Uses `;` for sequential execution. The `and`/`or` keywords are boolean
|
|
/// operators on values (e.g., `$true and $false`), not command chaining operators.
|
|
/// - **Elvish**: Uses `;` to separate pipelines, which brush-parser handles. Elvish does
|
|
/// not have `&&` or `||` operators. Its `and`/`or` are special commands that operate
|
|
/// on values, not command chaining (e.g., `and $true $false`).
|
|
/// - **Rc (Plan 9)**: Uses `;` for sequential execution and `|` for piping. Does not
|
|
/// have `&&`/`||` operators for conditional chaining.
|
|
/// All current shell variants are listed here because brush-parser can handle
|
|
/// their syntax. If a new `ShellKind` variant is added, evaluate whether
|
|
/// brush-parser can safely parse its command chaining syntax before including
|
|
/// it. Omitting a variant will cause `tool_permissions::from_input` to deny
|
|
/// terminal commands that have `always_allow` patterns configured.
|
|
pub fn supports_posix_chaining(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
ShellKind::Posix
|
|
| ShellKind::Fish
|
|
| ShellKind::PowerShell
|
|
| ShellKind::Pwsh
|
|
| ShellKind::Cmd
|
|
| ShellKind::Xonsh
|
|
| ShellKind::Csh
|
|
| ShellKind::Tcsh
|
|
| ShellKind::Nushell
|
|
| ShellKind::Elvish
|
|
| ShellKind::Rc
|
|
)
|
|
}
|
|
|
|
pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
|
|
let program = program.as_ref();
|
|
let program = program
|
|
.file_stem()
|
|
.unwrap_or_else(|| program.as_os_str())
|
|
.to_string_lossy();
|
|
|
|
match &*program {
|
|
"powershell" => ShellKind::PowerShell,
|
|
"pwsh" => ShellKind::Pwsh,
|
|
"cmd" => ShellKind::Cmd,
|
|
"nu" => ShellKind::Nushell,
|
|
"fish" => ShellKind::Fish,
|
|
"csh" => ShellKind::Csh,
|
|
"tcsh" => ShellKind::Tcsh,
|
|
"rc" => ShellKind::Rc,
|
|
"xonsh" => ShellKind::Xonsh,
|
|
"elvish" => ShellKind::Elvish,
|
|
"sh" | "bash" | "zsh" => ShellKind::Posix,
|
|
_ if is_windows => ShellKind::PowerShell,
|
|
// Some other shell detected, the user might install and use a
|
|
// unix-like shell.
|
|
_ => ShellKind::Posix,
|
|
}
|
|
}
|
|
|
|
pub fn to_shell_variable(self, input: &str) -> String {
|
|
match self {
|
|
Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),
|
|
Self::Cmd => Self::to_cmd_variable(input),
|
|
Self::Posix => input.to_owned(),
|
|
Self::Fish => input.to_owned(),
|
|
Self::Csh => input.to_owned(),
|
|
Self::Tcsh => input.to_owned(),
|
|
Self::Rc => input.to_owned(),
|
|
Self::Nushell => Self::to_nushell_variable(input),
|
|
Self::Xonsh => input.to_owned(),
|
|
Self::Elvish => input.to_owned(),
|
|
}
|
|
}
|
|
|
|
fn to_cmd_variable(input: &str) -> String {
|
|
if let Some(var_str) = input.strip_prefix("${") {
|
|
if var_str.find(':').is_none() {
|
|
// If the input starts with "${", remove the trailing "}"
|
|
format!("%{}%", &var_str[..var_str.len() - 1])
|
|
} else {
|
|
// `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
|
|
// which will result in the task failing to run in such cases.
|
|
input.into()
|
|
}
|
|
} else if let Some(var_str) = input.strip_prefix('$') {
|
|
// If the input starts with "$", directly append to "$env:"
|
|
format!("%{}%", var_str)
|
|
} else {
|
|
// If no prefix is found, return the input as is
|
|
input.into()
|
|
}
|
|
}
|
|
|
|
fn to_powershell_variable(input: &str) -> String {
|
|
if let Some(var_str) = input.strip_prefix("${") {
|
|
if var_str.find(':').is_none() {
|
|
// If the input starts with "${", remove the trailing "}"
|
|
format!("$env:{}", &var_str[..var_str.len() - 1])
|
|
} else {
|
|
// `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
|
|
// which will result in the task failing to run in such cases.
|
|
input.into()
|
|
}
|
|
} else if let Some(var_str) = input.strip_prefix('$') {
|
|
// If the input starts with "$", directly append to "$env:"
|
|
format!("$env:{}", var_str)
|
|
} else {
|
|
// If no prefix is found, return the input as is
|
|
input.into()
|
|
}
|
|
}
|
|
|
|
fn to_nushell_variable(input: &str) -> String {
|
|
let mut result = String::new();
|
|
let mut source = input;
|
|
let mut is_start = true;
|
|
|
|
loop {
|
|
match source.chars().next() {
|
|
None => return result,
|
|
Some('$') => {
|
|
source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
|
|
is_start = false;
|
|
}
|
|
Some(_) => {
|
|
is_start = false;
|
|
let chunk_end = source.find('$').unwrap_or(source.len());
|
|
let (chunk, rest) = source.split_at(chunk_end);
|
|
result.push_str(chunk);
|
|
source = rest;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
|
|
if source.starts_with("env.") {
|
|
text.push('$');
|
|
return source;
|
|
}
|
|
|
|
match source.chars().next() {
|
|
Some('{') => {
|
|
let source = &source[1..];
|
|
if let Some(end) = source.find('}') {
|
|
let var_name = &source[..end];
|
|
if !var_name.is_empty() {
|
|
if !is_start {
|
|
text.push_str("(");
|
|
}
|
|
text.push_str("$env.");
|
|
text.push_str(var_name);
|
|
if !is_start {
|
|
text.push_str(")");
|
|
}
|
|
&source[end + 1..]
|
|
} else {
|
|
text.push_str("${}");
|
|
&source[end + 1..]
|
|
}
|
|
} else {
|
|
text.push_str("${");
|
|
source
|
|
}
|
|
}
|
|
Some(c) if c.is_alphabetic() || c == '_' => {
|
|
let end = source
|
|
.find(|c: char| !c.is_alphanumeric() && c != '_')
|
|
.unwrap_or(source.len());
|
|
let var_name = &source[..end];
|
|
if !is_start {
|
|
text.push_str("(");
|
|
}
|
|
text.push_str("$env.");
|
|
text.push_str(var_name);
|
|
if !is_start {
|
|
text.push_str(")");
|
|
}
|
|
&source[end..]
|
|
}
|
|
_ => {
|
|
text.push('$');
|
|
source
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
|
|
match self {
|
|
ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
|
|
ShellKind::Cmd => vec![
|
|
"/S".to_owned(),
|
|
"/C".to_owned(),
|
|
format!("\"{combined_command}\""),
|
|
],
|
|
ShellKind::Posix
|
|
| ShellKind::Nushell
|
|
| ShellKind::Fish
|
|
| ShellKind::Csh
|
|
| ShellKind::Tcsh
|
|
| ShellKind::Rc
|
|
| ShellKind::Xonsh
|
|
| ShellKind::Elvish => interactive
|
|
.then(|| "-i".to_owned())
|
|
.into_iter()
|
|
.chain(["-c".to_owned(), combined_command])
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
pub const fn command_prefix(&self) -> Option<char> {
|
|
match self {
|
|
ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
|
|
ShellKind::Nushell => Some('^'),
|
|
ShellKind::Posix
|
|
| ShellKind::Csh
|
|
| ShellKind::Tcsh
|
|
| ShellKind::Rc
|
|
| ShellKind::Fish
|
|
| ShellKind::Cmd
|
|
| ShellKind::Xonsh
|
|
| ShellKind::Elvish => None,
|
|
}
|
|
}
|
|
|
|
pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
|
|
match self.command_prefix() {
|
|
Some(prefix) if !command.starts_with(prefix) => {
|
|
Cow::Owned(format!("{prefix}{command}"))
|
|
}
|
|
_ => Cow::Borrowed(command),
|
|
}
|
|
}
|
|
|
|
pub const fn sequential_commands_separator(&self) -> char {
|
|
match self {
|
|
ShellKind::Cmd => '&',
|
|
ShellKind::Posix
|
|
| ShellKind::Csh
|
|
| ShellKind::Tcsh
|
|
| ShellKind::Rc
|
|
| ShellKind::Fish
|
|
| ShellKind::PowerShell
|
|
| ShellKind::Pwsh
|
|
| ShellKind::Nushell
|
|
| ShellKind::Xonsh
|
|
| ShellKind::Elvish => ';',
|
|
}
|
|
}
|
|
|
|
pub const fn sequential_and_commands_separator(&self) -> &'static str {
|
|
match self {
|
|
ShellKind::Cmd
|
|
| ShellKind::Posix
|
|
| ShellKind::Csh
|
|
| ShellKind::Tcsh
|
|
| ShellKind::Rc
|
|
| ShellKind::Fish
|
|
| ShellKind::Pwsh
|
|
| ShellKind::Xonsh => "&&",
|
|
ShellKind::PowerShell | ShellKind::Nushell | ShellKind::Elvish => ";",
|
|
}
|
|
}
|
|
|
|
pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
|
|
match self {
|
|
ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
|
|
ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
|
|
ShellKind::Cmd => Some(Self::quote_cmd(arg)),
|
|
ShellKind::Posix
|
|
| ShellKind::Csh
|
|
| ShellKind::Tcsh
|
|
| ShellKind::Rc
|
|
| ShellKind::Fish
|
|
| ShellKind::Nushell
|
|
| ShellKind::Xonsh
|
|
| ShellKind::Elvish => shlex::try_quote(arg).ok(),
|
|
}
|
|
}
|
|
|
|
fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> {
|
|
if arg.is_empty() {
|
|
return Cow::Borrowed("\"\"");
|
|
}
|
|
|
|
let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
|
|
if !needs_quoting {
|
|
return Cow::Borrowed(arg);
|
|
}
|
|
|
|
let mut result = String::with_capacity(arg.len() + 2);
|
|
|
|
if enclose {
|
|
result.push('"');
|
|
}
|
|
|
|
let chars: Vec<char> = arg.chars().collect();
|
|
let mut i = 0;
|
|
|
|
while i < chars.len() {
|
|
if chars[i] == '\\' {
|
|
let mut num_backslashes = 0;
|
|
while i < chars.len() && chars[i] == '\\' {
|
|
num_backslashes += 1;
|
|
i += 1;
|
|
}
|
|
|
|
if i < chars.len() && chars[i] == '"' {
|
|
// Backslashes followed by quote: double the backslashes and escape the quote
|
|
for _ in 0..(num_backslashes * 2 + 1) {
|
|
result.push('\\');
|
|
}
|
|
result.push('"');
|
|
i += 1;
|
|
} else if i >= chars.len() {
|
|
// Trailing backslashes: double them (they precede the closing quote)
|
|
for _ in 0..(num_backslashes * 2) {
|
|
result.push('\\');
|
|
}
|
|
} else {
|
|
// Backslashes not followed by quote: output as-is
|
|
for _ in 0..num_backslashes {
|
|
result.push('\\');
|
|
}
|
|
}
|
|
} else if chars[i] == '"' {
|
|
// Quote not preceded by backslash: escape it
|
|
result.push('\\');
|
|
result.push('"');
|
|
i += 1;
|
|
} else {
|
|
result.push(chars[i]);
|
|
i += 1;
|
|
}
|
|
}
|
|
|
|
if enclose {
|
|
result.push('"');
|
|
}
|
|
Cow::Owned(result)
|
|
}
|
|
|
|
fn needs_quoting_powershell(s: &str) -> bool {
|
|
s.is_empty()
|
|
|| s.chars().any(|c| {
|
|
c.is_whitespace()
|
|
|| matches!(
|
|
c,
|
|
'"' | '`'
|
|
| '$'
|
|
| '&'
|
|
| '|'
|
|
| '<'
|
|
| '>'
|
|
| ';'
|
|
| '('
|
|
| ')'
|
|
| '['
|
|
| ']'
|
|
| '{'
|
|
| '}'
|
|
| ','
|
|
| '\''
|
|
| '@'
|
|
)
|
|
})
|
|
}
|
|
|
|
fn need_quotes_powershell(arg: &str) -> bool {
|
|
let mut quote_count = 0;
|
|
for c in arg.chars() {
|
|
if c == '"' {
|
|
quote_count += 1;
|
|
} else if c.is_whitespace() && (quote_count % 2 == 0) {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn escape_powershell_quotes(s: &str) -> String {
|
|
let mut result = String::with_capacity(s.len() + 4);
|
|
result.push('\'');
|
|
for c in s.chars() {
|
|
if c == '\'' {
|
|
result.push('\'');
|
|
}
|
|
result.push(c);
|
|
}
|
|
result.push('\'');
|
|
result
|
|
}
|
|
|
|
pub fn quote_powershell(arg: &str) -> Cow<'_, str> {
|
|
let ps_will_quote = Self::need_quotes_powershell(arg);
|
|
let crt_quoted = Self::quote_windows(arg, !ps_will_quote);
|
|
|
|
if !Self::needs_quoting_powershell(arg) {
|
|
return crt_quoted;
|
|
}
|
|
|
|
Cow::Owned(Self::escape_powershell_quotes(&crt_quoted))
|
|
}
|
|
|
|
pub fn quote_pwsh(arg: &str) -> Cow<'_, str> {
|
|
if arg.is_empty() {
|
|
return Cow::Borrowed("''");
|
|
}
|
|
|
|
if !Self::needs_quoting_powershell(arg) {
|
|
return Cow::Borrowed(arg);
|
|
}
|
|
|
|
Cow::Owned(Self::escape_powershell_quotes(arg))
|
|
}
|
|
|
|
pub fn quote_cmd(arg: &str) -> Cow<'_, str> {
|
|
let crt_quoted = Self::quote_windows(arg, true);
|
|
|
|
let needs_cmd_escaping = crt_quoted.contains(['"', '%', '^', '<', '>', '&', '|', '(', ')']);
|
|
|
|
if !needs_cmd_escaping {
|
|
return crt_quoted;
|
|
}
|
|
|
|
let mut result = String::with_capacity(crt_quoted.len() * 2);
|
|
for c in crt_quoted.chars() {
|
|
match c {
|
|
'^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => {
|
|
result.push('^');
|
|
result.push(c);
|
|
}
|
|
'%' => {
|
|
result.push_str("%%cd:~,%");
|
|
}
|
|
_ => result.push(c),
|
|
}
|
|
}
|
|
Cow::Owned(result)
|
|
}
|
|
|
|
/// Quotes the given argument if necessary, taking into account the command prefix.
|
|
///
|
|
/// In other words, this will consider quoting arg without its command prefix to not break the command.
|
|
/// You should use this over `try_quote` when you want to quote a shell command.
|
|
pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
|
|
if let Some(char) = self.command_prefix() {
|
|
if let Some(arg) = arg.strip_prefix(char) {
|
|
// we have a command that is prefixed
|
|
for quote in ['\'', '"'] {
|
|
if let Some(arg) = arg
|
|
.strip_prefix(quote)
|
|
.and_then(|arg| arg.strip_suffix(quote))
|
|
{
|
|
// and the command itself is wrapped as a literal, that
|
|
// means the prefix exists to interpret a literal as a
|
|
// command. So strip the quotes, quote the command, and
|
|
// re-add the quotes if they are missing after requoting
|
|
let quoted = self.try_quote(arg)?;
|
|
return Some(if quoted.starts_with(['\'', '"']) {
|
|
Cow::Owned(self.prepend_command_prefix("ed).into_owned())
|
|
} else {
|
|
Cow::Owned(
|
|
self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
|
|
.into_owned(),
|
|
)
|
|
});
|
|
}
|
|
}
|
|
return self
|
|
.try_quote(arg)
|
|
.map(|quoted| Cow::Owned(self.prepend_command_prefix("ed).into_owned()));
|
|
}
|
|
}
|
|
self.try_quote(arg).map(|quoted| match quoted {
|
|
unquoted @ Cow::Borrowed(_) => unquoted,
|
|
Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix("ed).into_owned()),
|
|
})
|
|
}
|
|
|
|
pub fn split(&self, input: &str) -> Option<Vec<String>> {
|
|
shlex::split(input)
|
|
}
|
|
|
|
pub const fn activate_keyword(&self) -> &'static str {
|
|
match self {
|
|
ShellKind::Cmd => "",
|
|
ShellKind::Nushell => "overlay use",
|
|
ShellKind::PowerShell | ShellKind::Pwsh => ".",
|
|
ShellKind::Fish
|
|
| ShellKind::Csh
|
|
| ShellKind::Tcsh
|
|
| ShellKind::Posix
|
|
| ShellKind::Rc
|
|
| ShellKind::Xonsh
|
|
| ShellKind::Elvish => "source",
|
|
}
|
|
}
|
|
|
|
pub const fn clear_screen_command(&self) -> &'static str {
|
|
match self {
|
|
ShellKind::Cmd => "cls",
|
|
ShellKind::Posix
|
|
| ShellKind::Csh
|
|
| ShellKind::Tcsh
|
|
| ShellKind::Rc
|
|
| ShellKind::Fish
|
|
| ShellKind::PowerShell
|
|
| ShellKind::Pwsh
|
|
| ShellKind::Nushell
|
|
| ShellKind::Xonsh
|
|
| ShellKind::Elvish => "clear",
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
/// We do not want to escape arguments if we are using CMD as our shell.
|
|
/// If we do we end up with too many quotes/escaped quotes for CMD to handle.
|
|
pub const fn tty_escape_args(&self) -> bool {
|
|
match self {
|
|
ShellKind::Cmd => false,
|
|
ShellKind::Posix
|
|
| ShellKind::Csh
|
|
| ShellKind::Tcsh
|
|
| ShellKind::Rc
|
|
| ShellKind::Fish
|
|
| ShellKind::PowerShell
|
|
| ShellKind::Pwsh
|
|
| ShellKind::Nushell
|
|
| ShellKind::Xonsh
|
|
| ShellKind::Elvish => true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Examples
|
|
// WSL
|
|
// wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
|
|
// wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
|
|
// wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
|
|
// PowerShell from Nushell
|
|
// nu -c overlay use "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\activate.nu"; ^"C:\Program Files\PowerShell\7\pwsh.exe" -C "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\python.exe -m pytest \"test_foo.py::test_foo\""
|
|
// PowerShell from CMD
|
|
// cmd /C \" \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\activate.bat\"& \"C:\\\\Program Files\\\\PowerShell\\\\7\\\\pwsh.exe\" -C \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"\"
|
|
|
|
#[test]
|
|
fn test_try_quote_powershell() {
|
|
let shell_kind = ShellKind::PowerShell;
|
|
assert_eq!(
|
|
shell_kind
|
|
.try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
|
|
.unwrap()
|
|
.into_owned(),
|
|
"'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_try_quote_cmd() {
|
|
let shell_kind = ShellKind::Cmd;
|
|
assert_eq!(
|
|
shell_kind
|
|
.try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
|
|
.unwrap()
|
|
.into_owned(),
|
|
"^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_try_quote_powershell_edge_cases() {
|
|
let shell_kind = ShellKind::PowerShell;
|
|
|
|
// Empty string
|
|
assert_eq!(
|
|
shell_kind.try_quote("").unwrap().into_owned(),
|
|
"'\"\"'".to_string()
|
|
);
|
|
|
|
// String without special characters (no quoting needed)
|
|
assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
|
|
|
|
// String with spaces
|
|
assert_eq!(
|
|
shell_kind.try_quote("hello world").unwrap().into_owned(),
|
|
"'hello world'".to_string()
|
|
);
|
|
|
|
// String with dollar signs
|
|
assert_eq!(
|
|
shell_kind.try_quote("$variable").unwrap().into_owned(),
|
|
"'$variable'".to_string()
|
|
);
|
|
|
|
// String with backticks
|
|
assert_eq!(
|
|
shell_kind.try_quote("test`command").unwrap().into_owned(),
|
|
"'test`command'".to_string()
|
|
);
|
|
|
|
// String with multiple special characters
|
|
assert_eq!(
|
|
shell_kind
|
|
.try_quote("test `\"$var`\" end")
|
|
.unwrap()
|
|
.into_owned(),
|
|
"'test `\\\"$var`\\\" end'".to_string()
|
|
);
|
|
|
|
// String with backslashes and colon (path without spaces doesn't need quoting)
|
|
assert_eq!(
|
|
shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
|
|
"C:\\path\\to\\file"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_try_quote_cmd_edge_cases() {
|
|
let shell_kind = ShellKind::Cmd;
|
|
|
|
// Empty string
|
|
assert_eq!(
|
|
shell_kind.try_quote("").unwrap().into_owned(),
|
|
"^\"^\"".to_string()
|
|
);
|
|
|
|
// String without special characters (no quoting needed)
|
|
assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
|
|
|
|
// String with spaces
|
|
assert_eq!(
|
|
shell_kind.try_quote("hello world").unwrap().into_owned(),
|
|
"^\"hello world^\"".to_string()
|
|
);
|
|
|
|
// String with space and backslash (backslash not at end, so not doubled)
|
|
assert_eq!(
|
|
shell_kind.try_quote("path\\ test").unwrap().into_owned(),
|
|
"^\"path\\ test^\"".to_string()
|
|
);
|
|
|
|
// String ending with backslash (must be doubled before closing quote)
|
|
assert_eq!(
|
|
shell_kind.try_quote("test path\\").unwrap().into_owned(),
|
|
"^\"test path\\\\^\"".to_string()
|
|
);
|
|
|
|
// String ending with multiple backslashes (all doubled before closing quote)
|
|
assert_eq!(
|
|
shell_kind.try_quote("test path\\\\").unwrap().into_owned(),
|
|
"^\"test path\\\\\\\\^\"".to_string()
|
|
);
|
|
|
|
// String with embedded quote (quote is escaped, backslash before it is doubled)
|
|
assert_eq!(
|
|
shell_kind.try_quote("test\\\"quote").unwrap().into_owned(),
|
|
"^\"test\\\\\\^\"quote^\"".to_string()
|
|
);
|
|
|
|
// String with multiple backslashes before embedded quote (all doubled)
|
|
assert_eq!(
|
|
shell_kind
|
|
.try_quote("test\\\\\"quote")
|
|
.unwrap()
|
|
.into_owned(),
|
|
"^\"test\\\\\\\\\\^\"quote^\"".to_string()
|
|
);
|
|
|
|
// String with backslashes not before quotes (path without spaces doesn't need quoting)
|
|
assert_eq!(
|
|
shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
|
|
"C:\\path\\to\\file"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_try_quote_nu_command() {
|
|
let shell_kind = ShellKind::Nushell;
|
|
assert_eq!(
|
|
shell_kind.try_quote("'uname'").unwrap().into_owned(),
|
|
"\"'uname'\"".to_string()
|
|
);
|
|
assert_eq!(
|
|
shell_kind
|
|
.try_quote_prefix_aware("'uname'")
|
|
.unwrap()
|
|
.into_owned(),
|
|
"^\"'uname'\"".to_string()
|
|
);
|
|
assert_eq!(
|
|
shell_kind.try_quote("^uname").unwrap().into_owned(),
|
|
"'^uname'".to_string()
|
|
);
|
|
assert_eq!(
|
|
shell_kind
|
|
.try_quote_prefix_aware("^uname")
|
|
.unwrap()
|
|
.into_owned(),
|
|
"^uname".to_string()
|
|
);
|
|
assert_eq!(
|
|
shell_kind.try_quote("^'uname'").unwrap().into_owned(),
|
|
"'^'\"'uname\'\"".to_string()
|
|
);
|
|
assert_eq!(
|
|
shell_kind
|
|
.try_quote_prefix_aware("^'uname'")
|
|
.unwrap()
|
|
.into_owned(),
|
|
"^'uname'".to_string()
|
|
);
|
|
assert_eq!(
|
|
shell_kind.try_quote("'uname a'").unwrap().into_owned(),
|
|
"\"'uname a'\"".to_string()
|
|
);
|
|
assert_eq!(
|
|
shell_kind
|
|
.try_quote_prefix_aware("'uname a'")
|
|
.unwrap()
|
|
.into_owned(),
|
|
"^\"'uname a'\"".to_string()
|
|
);
|
|
assert_eq!(
|
|
shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
|
|
"'^'\"'uname a'\"".to_string()
|
|
);
|
|
assert_eq!(
|
|
shell_kind
|
|
.try_quote_prefix_aware("^'uname a'")
|
|
.unwrap()
|
|
.into_owned(),
|
|
"^'uname a'".to_string()
|
|
);
|
|
assert_eq!(
|
|
shell_kind.try_quote("uname").unwrap().into_owned(),
|
|
"uname".to_string()
|
|
);
|
|
assert_eq!(
|
|
shell_kind
|
|
.try_quote_prefix_aware("uname")
|
|
.unwrap()
|
|
.into_owned(),
|
|
"uname".to_string()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_try_quote_single_quote_paths() {
|
|
let path_with_quote = r"C:\Temp\O'Brien\repo";
|
|
let shlex_shells = [
|
|
ShellKind::Posix,
|
|
ShellKind::Fish,
|
|
ShellKind::Csh,
|
|
ShellKind::Tcsh,
|
|
ShellKind::Rc,
|
|
ShellKind::Xonsh,
|
|
ShellKind::Elvish,
|
|
ShellKind::Nushell,
|
|
];
|
|
|
|
for shell_kind in shlex_shells {
|
|
let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
|
|
assert_ne!(quoted, path_with_quote);
|
|
assert_eq!(
|
|
shlex::split("ed),
|
|
Some(vec![path_with_quote.to_string()])
|
|
);
|
|
|
|
if shell_kind == ShellKind::Nushell {
|
|
let prefixed = shell_kind.prepend_command_prefix("ed);
|
|
assert!(prefixed.starts_with('^'));
|
|
}
|
|
}
|
|
|
|
for shell_kind in [ShellKind::PowerShell, ShellKind::Pwsh] {
|
|
let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
|
|
assert!(quoted.starts_with('\''));
|
|
assert!(quoted.ends_with('\''));
|
|
assert!(quoted.contains("O''Brien"));
|
|
}
|
|
}
|
|
}
|