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:
+12
-22
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user