From e9e27bdaf719190adebbf58f83e9831c58dfc333 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Wed, 12 Jul 2023 22:05:12 -0500 Subject: [PATCH] Possible implementation of shell execution wrapping --- CHANGELOG.md | 6 ++ Cargo.lock | 1 + Cargo.toml | 1 + src/cli/commands/client.rs | 51 ++++++++-- src/options.rs | 9 ++ src/options/common.rs | 2 + src/options/common/shell.rs | 183 ++++++++++++++++++++++++++++++++++++ 7 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 src/options/common/shell.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 810a437..0125fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support for `--shell` with optional path to an explicit shell as an option + when executing `distant spawn` in order to run the command within a shell + rather than directly + ### Changed - `distant_protocol::PROTOCOL_VERSION` now uses the crate's major, minor, and diff --git a/Cargo.lock b/Cargo.lock index 966cdc5..3fd371b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -850,6 +850,7 @@ dependencies = [ "test-log", "tokio", "toml_edit", + "typed-path", "which", "whoami", "windows-service", diff --git a/Cargo.toml b/Cargo.toml index 2fb3bf8..6a2943a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ tokio = { version = "1.28.2", features = ["full"] } toml_edit = { version = "0.19.10", features = ["serde"] } terminal_size = "0.2.6" termwiz = "0.20.0" +typed-path = "0.3.2" which = "4.4.0" winsplit = "0.1.0" whoami = "1.4.0" diff --git a/src/cli/commands/client.rs b/src/cli/commands/client.rs index 3ef4c01..84981c8 100644 --- a/src/cli/commands/client.rs +++ b/src/cli/commands/client.rs @@ -25,7 +25,10 @@ use crate::cli::common::{ Cache, Client, JsonAuthHandler, MsgReceiver, MsgSender, PromptAuthHandler, }; use crate::constants::MAX_PIPE_CHUNK_SIZE; -use crate::options::{ClientFileSystemSubcommand, ClientSubcommand, Format, NetworkSettings}; +use crate::options::{ + ClientFileSystemSubcommand, ClientSubcommand, Format, NetworkSettings, ParseShellError, + Shell as ShellOption, +}; use crate::{CliError, CliResult}; mod lsp; @@ -369,6 +372,7 @@ async fn async_run(cmd: ClientSubcommand) -> CliResult { environment, lsp, pty, + shell, network, } => { debug!("Connecting to manager"); @@ -383,20 +387,55 @@ async fn async_run(cmd: ClientSubcommand) -> CliResult { use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; debug!("Opening channel to connection {}", connection_id); - let channel = client + let mut channel: DistantChannel = client .open_raw_channel(connection_id) .await - .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; + .with_context(|| format!("Failed to open channel to connection {connection_id}"))? + .into_client() + .into_channel(); // Convert cmd into string let cmd = cmd_str.unwrap_or_else(|| cmd.join(" ")); + // Check if we should attempt to run the command in a shell + let cmd = match shell { + None => cmd, + + // Use default shell, which we need to figure out + Some(None) => { + let system_info = channel + .system_info() + .await + .context("Failed to detect remote operating system")?; + + // If system reports a default shell, use it, otherwise pick a default based on the + // operating system being windows or non-windows + let shell: ShellOption = if !system_info.shell.is_empty() { + system_info.shell.parse() + } else if system_info.family.eq_ignore_ascii_case("windows") { + "cmd.exe".parse() + } else { + "/bin/sh".parse() + } + .map_err(|x: ParseShellError| anyhow::anyhow!(x))?; + + shell + .make_cmd_string(&cmd) + .map_err(|x| anyhow::anyhow!(x))? + } + + // Use explicit shell + Some(Some(shell)) => shell + .make_cmd_string(&cmd) + .map_err(|x| anyhow::anyhow!(x))?, + }; + if let Some(scheme) = lsp { debug!( "Spawning LSP server (pty = {}, cwd = {:?}): {}", pty, current_dir, cmd ); - Lsp::new(channel.into_client().into_channel()) + Lsp::new(channel) .spawn(cmd, current_dir, scheme, pty, MAX_PIPE_CHUNK_SIZE) .await?; } else if pty { @@ -404,7 +443,7 @@ async fn async_run(cmd: ClientSubcommand) -> CliResult { "Spawning pty process (environment = {:?}, cwd = {:?}): {}", environment, current_dir, cmd ); - Shell::new(channel.into_client().into_channel()) + Shell::new(channel) .spawn( cmd, environment.into_map(), @@ -421,7 +460,7 @@ async fn async_run(cmd: ClientSubcommand) -> CliResult { .environment(environment.into_map()) .current_dir(current_dir) .pty(None) - .spawn(channel.into_client().into_channel(), &cmd) + .spawn(channel, &cmd) .await .with_context(|| format!("Failed to spawn {cmd}"))?; diff --git a/src/options.rs b/src/options.rs index 8ac9e54..d77821b 100644 --- a/src/options.rs +++ b/src/options.rs @@ -463,6 +463,11 @@ pub enum ClientSubcommand { #[clap(long)] pty: bool, + /// If specified, will spawn the process in the specified shell, defaulting to the + /// user-configured shell. + #[clap(long, name = "SHELL")] + shell: Option>, + /// Alternative current directory for the remote process #[clap(long)] current_dir: Option, @@ -1938,6 +1943,7 @@ mod tests { current_dir: None, environment: map!(), lsp: Some(None), + shell: Some(None), pty: true, cmd_str: None, cmd: vec![String::from("cmd")], @@ -1977,6 +1983,7 @@ mod tests { current_dir: None, environment: map!(), lsp: Some(None), + shell: Some(None), pty: true, cmd_str: None, cmd: vec![String::from("cmd")], @@ -2003,6 +2010,7 @@ mod tests { current_dir: None, environment: map!(), lsp: Some(None), + shell: Some(None), pty: true, cmd_str: None, cmd: vec![String::from("cmd")], @@ -2042,6 +2050,7 @@ mod tests { current_dir: None, environment: map!(), lsp: Some(None), + shell: Some(None), pty: true, cmd_str: None, cmd: vec![String::from("cmd")], diff --git a/src/options/common.rs b/src/options/common.rs index 6f1efb9..1d34ed5 100644 --- a/src/options/common.rs +++ b/src/options/common.rs @@ -3,6 +3,7 @@ mod cmd; mod logging; mod network; mod search; +mod shell; mod time; mod value; @@ -11,5 +12,6 @@ pub use cmd::*; pub use logging::*; pub use network::*; pub use search::*; +pub use shell::*; pub use time::*; pub use value::*; diff --git a/src/options/common/shell.rs b/src/options/common/shell.rs new file mode 100644 index 0000000..fbee243 --- /dev/null +++ b/src/options/common/shell.rs @@ -0,0 +1,183 @@ +use derive_more::{Display, Error}; +use std::str::FromStr; +use typed_path::{Utf8UnixPath, Utf8WindowsPath}; + +/// Represents a shell to execute on the remote machine. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Shell { + /// Represents the path to the shell on the remote machine. + pub path: String, + + /// Represents the kind of shell. + pub kind: ShellKind, +} + +impl Shell { + #[inline] + pub fn is_posix(&self) -> bool { + self.kind.is_posix() + } + + /// Wraps a `cmd` such that it is invoked by this shell. + /// + /// * For `cmd.exe`, this wraps in double quotes such that it can be invoked by `cmd.exe /S /K "..."`. + /// * For `powershell.exe`, `rc`, and `elvish`, this wraps in single quotes and escapes single quotes by doubling them. + /// * For powershell, this results in `powershell.exe -Command '...'`. + /// * For rc and elvish, this uses `shell -c '...'`. + /// * For **POSIX** shells, this wraps in single quotes and uses the trick of `'\''` to fake escape. + /// * For `nu`, this wraps in single quotes or backticks where possible, but fails if the cmd contains single quotes and backticks. + /// + pub fn make_cmd_string(&self, cmd: &str) -> Result { + let path = self.path.as_str(); + + match self.kind { + ShellKind::CmdExe => Ok(format!("{path} /S /K \"{cmd}\"")), + ShellKind::PowerShell => Ok(format!("{path} -Command '{}'", cmd.replace('\'', "''"))), + ShellKind::Rc | ShellKind::Elvish => { + Ok(format!("{path} -c '{}'", cmd.replace('\'', "''"))) + } + ShellKind::Nu => { + let has_single_quotes = cmd.contains('\''); + let has_backticks = cmd.contains('`'); + + match (has_single_quotes, has_backticks) { + // If we have both single quotes and backticks, fail + (true, true) => { + Err("unable to escape single quotes and backticks at the same time with nu") + } + + // If we only have single quotes, use backticks + (true, false) => Ok(format!("{path} -c `{cmd}`")), + + // Otherwise, we can safely use single quotes + _ => Ok(format!("{path} -c '{cmd}'")), + } + } + + // We assume anything else not specially handled is POSIX + _ => Ok(format!("{path} -c '{}'", cmd.replace('\'', "'\\''"))), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Display, Error)] +pub struct ParseShellError(#[error(not(source))] String); + +impl FromStr for Shell { + type Err = ParseShellError; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + + let kind = ShellKind::identify(s) + .ok_or_else(|| ParseShellError(format!("Unsupported shell: {s}")))?; + + Ok(Self { + path: s.to_string(), + kind, + }) + } +} + +/// Supported types of shells. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ShellKind { + Ash, + Bash, + CmdExe, + Csh, + Dash, + Elvish, + Fish, + Ksh, + Loksh, + Mksh, + Nu, + Pdksh, + PowerShell, + Rc, + Scsh, + Sh, + Tcsh, + Zsh, +} + +impl ShellKind { + /// Returns true if shell represents a POSIX-compliant implementation. + pub fn is_posix(&self) -> bool { + matches!( + self, + Self::Ash + | Self::Bash + | Self::Csh + | Self::Dash + | Self::Fish + | Self::Ksh + | Self::Loksh + | Self::Mksh + | Self::Pdksh + | Self::Scsh + | Self::Sh + | Self::Tcsh + | Self::Zsh + ) + } + + /// Identifies the shell kind from the given string. This string could be a Windows path, Unix + /// path, or solo shell name. + /// + /// The process is handled by these steps: + /// + /// 1. Check if the string matches a shell name verbatim + /// 2. Parse the path as a Unix path and check the file name for a match + /// 3. Parse the path as a Windows path and check the file name for a match + /// + pub fn identify(s: &str) -> Option { + Self::from_name(s) + .or_else(|| Utf8UnixPath::new(s).file_name().and_then(Self::from_name)) + .or_else(|| { + Utf8WindowsPath::new(s) + .file_name() + .and_then(Self::from_name) + }) + } + + fn from_name(name: &str) -> Option { + macro_rules! map_str { + ($($name:literal -> $value:expr),+ $(,)?) => {{ + $( + if name.trim().eq_ignore_ascii_case($name) { + return Some($value); + } + + )+ + + None + }}; + } + + map_str! { + "ash" -> Self::Ash, + "bash" -> Self::Bash, + "cmd" -> Self::CmdExe, + "cmd.exe" -> Self::CmdExe, + "csh" -> Self::Csh, + "dash" -> Self::Dash, + "elvish" -> Self::Elvish, + "fish" -> Self::Fish, + "ksh" -> Self::Ksh, + "loksh" -> Self::Loksh, + "mksh" -> Self::Mksh, + "nu" -> Self::Nu, + "pdksh" -> Self::Pdksh, + "powershell" -> Self::PowerShell, + "powershell.exe" -> Self::PowerShell, + "rc" -> Self::Rc, + "scsh" -> Self::Scsh, + "sh" -> Self::Sh, + "tcsh" -> Self::Tcsh, + "zsh" -> Self::Zsh, + } + } +}