Use ShellKind::try_quote whenever we need to quote shell args (#41104)

Re-reverts
https://github.com/zed-industries/zed/commit/8f4646d6c357409cfc286104088b4d21f2bea851
with fixes

Release Notes:

- N/A
This commit is contained in:
Jakub Konka
2025-10-24 18:19:53 +02:00
committed by GitHub
parent f213f4bcc8
commit bcbc6a330e
22 changed files with 310 additions and 228 deletions
+12 -22
View File
@@ -15,7 +15,7 @@ use std::{
sync::LazyLock,
};
use crate::rel_path::RelPath;
use crate::{rel_path::RelPath, shell::ShellKind};
static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
@@ -84,9 +84,7 @@ pub trait PathExt {
fn multiple_extensions(&self) -> Option<String>;
/// Try to make a shell-safe representation of the path.
///
/// For Unix, the path is escaped to be safe for POSIX shells
fn try_shell_safe(&self) -> anyhow::Result<String>;
fn try_shell_safe(&self, shell_kind: ShellKind) -> anyhow::Result<String>;
}
impl<T: AsRef<Path>> PathExt for T {
@@ -164,24 +162,16 @@ impl<T: AsRef<Path>> PathExt for T {
Some(parts.into_iter().join("."))
}
fn try_shell_safe(&self) -> anyhow::Result<String> {
#[cfg(target_os = "windows")]
{
Ok(self.as_ref().to_string_lossy().to_string())
}
#[cfg(not(target_os = "windows"))]
{
let path_str = self
.as_ref()
.to_str()
.with_context(|| "Path contains invalid UTF-8")?;
// As of writing, this can only be fail if the path contains a null byte, which shouldn't be possible
// but shlex has annotated the error as #[non_exhaustive] so we can't make it a compile error if other
// errors are introduced in the future :(
Ok(shlex::try_quote(path_str)?.into_owned())
}
fn try_shell_safe(&self, shell_kind: ShellKind) -> anyhow::Result<String> {
let path_str = self
.as_ref()
.to_str()
.with_context(|| "Path contains invalid UTF-8")?;
shell_kind
.try_quote(path_str)
.as_deref()
.map(ToOwned::to_owned)
.context("Failed to quote path")
}
}
+159 -37
View File
@@ -1,6 +1,53 @@
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]
@@ -185,32 +232,20 @@ impl ShellKind {
.unwrap_or_else(|| program.as_os_str())
.to_string_lossy();
if program == "powershell" || program == "pwsh" {
ShellKind::PowerShell
} else if program == "cmd" {
ShellKind::Cmd
} else if program == "nu" {
ShellKind::Nushell
} else if program == "fish" {
ShellKind::Fish
} else if program == "csh" {
ShellKind::Csh
} else if program == "tcsh" {
ShellKind::Tcsh
} else if program == "rc" {
ShellKind::Rc
} else if program == "xonsh" {
ShellKind::Xonsh
} else if program == "sh" || program == "bash" {
ShellKind::Posix
} else {
if is_windows {
ShellKind::PowerShell
} else {
// Some other shell detected, the user might install and use a
// unix-like shell.
ShellKind::Posix
}
match &*program {
"powershell" | "pwsh" => ShellKind::PowerShell,
"cmd" => ShellKind::Cmd,
"nu" => ShellKind::Nushell,
"fish" => ShellKind::Fish,
"csh" => ShellKind::Csh,
"tcsh" => ShellKind::Tcsh,
"rc" => ShellKind::Rc,
"xonsh" => ShellKind::Xonsh,
"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,
}
}
@@ -363,14 +398,27 @@ impl ShellKind {
match self {
ShellKind::PowerShell => Some('&'),
ShellKind::Nushell => Some('^'),
_ => None,
ShellKind::Posix
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::Cmd
| ShellKind::Xonsh => None,
}
}
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::Nushell
| ShellKind::Xonsh => ';',
}
}
@@ -378,29 +426,103 @@ impl ShellKind {
shlex::try_quote(arg).ok().map(|arg| match self {
// If we are running in PowerShell, we want to take extra care when escaping strings.
// In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
// TODO double escaping backslashes is not necessary in PowerShell and probably CMD
ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"")),
_ => arg,
ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")),
ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")),
ShellKind::Posix
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::Nushell
| ShellKind::Xonsh => arg,
})
}
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::Fish => "source",
ShellKind::Csh => "source",
ShellKind::Tcsh => "source",
ShellKind::Posix | ShellKind::Rc => "source",
ShellKind::Xonsh => "source",
ShellKind::Fish
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Posix
| ShellKind::Rc
| ShellKind::Xonsh => "source",
}
}
pub const fn clear_screen_command(&self) -> &'static str {
match self {
ShellKind::Cmd => "cls",
_ => "clear",
ShellKind::Posix
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::PowerShell
| ShellKind::Nushell
| ShellKind::Xonsh => "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::Nushell
| ShellKind::Xonsh => 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()
);
}
}
+175
View File
@@ -0,0 +1,175 @@
use crate::shell::get_system_shell;
use crate::shell::{Shell, ShellKind};
/// ShellBuilder is used to turn a user-requested task into a
/// program that can be executed by the shell.
pub struct ShellBuilder {
/// The shell to run
program: String,
args: Vec<String>,
interactive: bool,
/// Whether to redirect stdin to /dev/null for the spawned command as a subshell.
redirect_stdin: bool,
kind: ShellKind,
}
impl ShellBuilder {
/// Create a new ShellBuilder as configured.
pub fn new(shell: &Shell, is_windows: bool) -> Self {
let (program, args) = match shell {
Shell::System => (get_system_shell(), Vec::new()),
Shell::Program(shell) => (shell.clone(), Vec::new()),
Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
};
let kind = ShellKind::new(&program, is_windows);
Self {
program,
args,
interactive: true,
kind,
redirect_stdin: false,
}
}
pub fn non_interactive(mut self) -> Self {
self.interactive = false;
self
}
/// Returns the label to show in the terminal tab
pub fn command_label(&self, command_to_use_in_label: &str) -> String {
if command_to_use_in_label.trim().is_empty() {
self.program.clone()
} else {
match self.kind {
ShellKind::PowerShell => {
format!("{} -C '{}'", self.program, command_to_use_in_label)
}
ShellKind::Cmd => {
format!("{} /C \"{}\"", self.program, command_to_use_in_label)
}
ShellKind::Posix
| ShellKind::Nushell
| ShellKind::Fish
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Xonsh => {
let interactivity = self.interactive.then_some("-i ").unwrap_or_default();
format!(
"{PROGRAM} {interactivity}-c '{command_to_use_in_label}'",
PROGRAM = self.program
)
}
}
}
}
pub fn redirect_stdin_to_dev_null(mut self) -> Self {
self.redirect_stdin = true;
self
}
/// Returns the program and arguments to run this task in a shell.
pub fn build(
mut self,
task_command: Option<String>,
task_args: &[String],
) -> (String, Vec<String>) {
if let Some(task_command) = task_command {
let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
command.push(' ');
command.push_str(&self.kind.to_shell_variable(arg));
command
});
if self.redirect_stdin {
match self.kind {
ShellKind::Fish => {
combined_command.insert_str(0, "begin; ");
combined_command.push_str("; end </dev/null");
}
ShellKind::Posix
| ShellKind::Nushell
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Xonsh => {
combined_command.insert(0, '(');
combined_command.push_str(") </dev/null");
}
ShellKind::PowerShell => {
combined_command.insert_str(0, "$null | & {");
combined_command.push_str("}");
}
ShellKind::Cmd => {
combined_command.push_str("< NUL");
}
}
}
self.args
.extend(self.kind.args_for_shell(self.interactive, combined_command));
}
(self.program, self.args)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_nu_shell_variable_substitution() {
let shell = Shell::Program("nu".to_owned());
let shell_builder = ShellBuilder::new(&shell, false);
let (program, args) = shell_builder.build(
Some("echo".into()),
&[
"${hello}".to_string(),
"$world".to_string(),
"nothing".to_string(),
"--$something".to_string(),
"$".to_string(),
"${test".to_string(),
],
);
assert_eq!(program, "nu");
assert_eq!(
args,
vec![
"-i",
"-c",
"echo $env.hello $env.world nothing --($env.something) $ ${test"
]
);
}
#[test]
fn redirect_stdin_to_dev_null_precedence() {
let shell = Shell::Program("nu".to_owned());
let shell_builder = ShellBuilder::new(&shell, false);
let (program, args) = shell_builder
.redirect_stdin_to_dev_null()
.build(Some("echo".into()), &["nothing".to_string()]);
assert_eq!(program, "nu");
assert_eq!(args, vec!["-i", "-c", "(echo nothing) </dev/null"]);
}
#[test]
fn redirect_stdin_to_dev_null_fish() {
let shell = Shell::Program("fish".to_owned());
let shell_builder = ShellBuilder::new(&shell, false);
let (program, args) = shell_builder
.redirect_stdin_to_dev_null()
.build(Some("echo".into()), &["test".to_string()]);
assert_eq!(program, "fish");
assert_eq!(args, vec!["-i", "-c", "begin; echo test; end </dev/null"]);
}
}
+1 -1
View File
@@ -35,8 +35,8 @@ async fn capture_unix(
use std::os::unix::process::CommandExt;
use std::process::Stdio;
let zed_path = super::get_shell_safe_zed_path()?;
let shell_kind = ShellKind::new(shell_path, false);
let zed_path = super::get_shell_safe_zed_path(shell_kind)?;
let mut command_string = String::new();
let mut command = std::process::Command::new(shell_path);
+3 -2
View File
@@ -9,6 +9,7 @@ pub mod rel_path;
pub mod schemars;
pub mod serde;
pub mod shell;
pub mod shell_builder;
pub mod shell_env;
pub mod size;
#[cfg(any(test, feature = "test-support"))]
@@ -295,12 +296,12 @@ fn load_shell_from_passwd() -> Result<()> {
}
/// Returns a shell escaped path for the current zed executable
pub fn get_shell_safe_zed_path() -> anyhow::Result<String> {
pub fn get_shell_safe_zed_path(shell_kind: shell::ShellKind) -> anyhow::Result<String> {
let zed_path =
std::env::current_exe().context("Failed to determine current zed executable path.")?;
zed_path
.try_shell_safe()
.try_shell_safe(shell_kind)
.context("Failed to shell-escape Zed executable path.")
}