use std::process::Command;
use crate::runtime::env::{Env, Paths};
pub const ENV_VAR: &str = "PURPLE_SSH_COMMAND";
pub const PREF_KEY: &str = "ssh_command";
pub const DEFAULT_PROGRAM: &str = "ssh";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshLauncher {
words: Vec<String>,
}
impl Default for SshLauncher {
fn default() -> Self {
Self {
words: vec![DEFAULT_PROGRAM.to_string()],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LauncherError {
Empty,
UnterminatedQuote,
}
impl std::fmt::Display for LauncherError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LauncherError::Empty => write!(f, "empty command"),
LauncherError::UnterminatedQuote => write!(f, "unterminated quote"),
}
}
}
impl SshLauncher {
pub fn parse(spec: &str) -> Result<Self, LauncherError> {
let words = split_words(spec)?;
if words.is_empty() {
return Err(LauncherError::Empty);
}
Ok(Self { words })
}
pub fn resolve(env: &Env) -> Self {
Self::resolve_from(env.var(ENV_VAR), env.paths())
}
fn resolve_from(env_value: Option<&str>, paths: Option<&Paths>) -> Self {
if let Some(spec) = env_value.map(str::trim).filter(|s| !s.is_empty()) {
match Self::parse(spec) {
Ok(l) => {
log::debug!("[purple] ssh launcher from {ENV_VAR}: {}", l.display());
return l;
}
Err(e) => log::warn!("[config] ignoring {ENV_VAR}={spec:?}: {e}"),
}
}
if let Some(spec) = crate::preferences::load_ssh_command(paths) {
match Self::parse(&spec) {
Ok(l) => {
log::debug!(
"[purple] ssh launcher from preference {PREF_KEY}: {}",
l.display()
);
return l;
}
Err(e) => log::warn!("[config] ignoring preference {PREF_KEY}={spec:?}: {e}"),
}
}
Self::default()
}
pub fn program(&self) -> &str {
&self.words[0]
}
pub fn leading_args(&self) -> &[String] {
&self.words[1..]
}
pub fn words(&self) -> &[String] {
&self.words
}
pub fn is_default(&self) -> bool {
self.words.len() == 1 && self.words[0] == DEFAULT_PROGRAM
}
pub fn command(&self) -> Command {
let mut cmd = Command::new(self.program());
cmd.args(self.leading_args());
cmd
}
pub fn display(&self) -> String {
self.words.join(" ")
}
pub fn shell_display(&self) -> String {
self.words
.iter()
.map(|w| shell_quote(w))
.collect::<Vec<_>>()
.join(" ")
}
}
fn shell_quote(word: &str) -> String {
let safe = !word.is_empty()
&& word
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_-./:=+@%,".contains(c));
if safe {
word.to_string()
} else {
format!("'{}'", word.replace('\'', "'\\''"))
}
}
fn split_words(spec: &str) -> Result<Vec<String>, LauncherError> {
let mut words = Vec::new();
let mut current = String::new();
let mut in_word = false;
let mut quote: Option<char> = None;
let mut chars = spec.chars().peekable();
while let Some(c) = chars.next() {
match quote {
Some('\'') => {
if c == '\'' {
quote = None;
} else {
current.push(c);
}
}
Some(_) => {
if c == '"' {
quote = None;
} else if c == '\\' {
match chars.next() {
Some(n) if n == '"' || n == '\\' => current.push(n),
Some(n) => {
current.push('\\');
current.push(n);
}
None => current.push('\\'),
}
} else {
current.push(c);
}
}
None => {
if c.is_whitespace() {
if in_word {
words.push(std::mem::take(&mut current));
in_word = false;
}
} else if c == '\'' || c == '"' {
quote = Some(c);
in_word = true;
} else if c == '\\' {
in_word = true;
if let Some(n) = chars.next() {
current.push(n);
}
} else {
in_word = true;
current.push(c);
}
}
}
}
if quote.is_some() {
return Err(LauncherError::UnterminatedQuote);
}
if in_word {
words.push(current);
}
Ok(words)
}
#[cfg(test)]
mod tests {
use super::*;
fn words(spec: &str) -> Vec<String> {
SshLauncher::parse(spec).unwrap().words().to_vec()
}
#[test]
fn default_is_plain_ssh() {
let l = SshLauncher::default();
assert_eq!(l.program(), "ssh");
assert!(l.leading_args().is_empty());
assert!(l.is_default());
assert_eq!(l.display(), "ssh");
}
#[test]
fn parse_splits_on_whitespace() {
assert_eq!(words("kitten ssh"), vec!["kitten", "ssh"]);
assert_eq!(words(" kitten ssh "), vec!["kitten", "ssh"]);
assert_eq!(words("ssh"), vec!["ssh"]);
}
#[test]
fn parse_honors_quotes_and_escapes() {
assert_eq!(
words("\"/Applications/kitty.app/Contents/MacOS/kitten\" ssh"),
vec!["/Applications/kitty.app/Contents/MacOS/kitten", "ssh"]
);
assert_eq!(words("'/my dir/kitten' ssh"), vec!["/my dir/kitten", "ssh"]);
assert_eq!(words("/my\\ dir/kitten ssh"), vec!["/my dir/kitten", "ssh"]);
assert_eq!(words("\"a \\\"b\\\"\" c"), vec!["a \"b\"", "c"]);
assert_eq!(words("'a\\b'"), vec!["a\\b"]);
assert_eq!(words("\"a\\nb\""), vec!["a\\nb"]);
assert_eq!(words("kitten '' ssh"), vec!["kitten", "", "ssh"]);
}
#[test]
fn parse_rejects_empty_and_unterminated() {
assert_eq!(SshLauncher::parse(""), Err(LauncherError::Empty));
assert_eq!(SshLauncher::parse(" "), Err(LauncherError::Empty));
assert_eq!(
SshLauncher::parse("kitten 'ssh"),
Err(LauncherError::UnterminatedQuote)
);
assert_eq!(
SshLauncher::parse("\"kitten ssh"),
Err(LauncherError::UnterminatedQuote)
);
}
#[test]
fn program_and_leading_args_and_display() {
let l = SshLauncher::parse("kitten ssh --kitten interpreter=python").unwrap();
assert_eq!(l.program(), "kitten");
assert_eq!(l.leading_args(), ["ssh", "--kitten", "interpreter=python"]);
assert!(!l.is_default());
assert_eq!(l.display(), "kitten ssh --kitten interpreter=python");
}
#[test]
fn shell_display_quotes_only_when_needed() {
assert_eq!(SshLauncher::default().shell_display(), "ssh");
assert_eq!(
SshLauncher::parse("kitten ssh").unwrap().shell_display(),
"kitten ssh"
);
assert_eq!(
SshLauncher::parse("'/my dir/kitten' ssh")
.unwrap()
.shell_display(),
"'/my dir/kitten' ssh"
);
assert_eq!(
SshLauncher::parse("\"it's\" ssh").unwrap().shell_display(),
"'it'\\''s' ssh"
);
assert_eq!(
SshLauncher::parse("kitten '' ssh").unwrap().shell_display(),
"kitten '' ssh"
);
}
#[test]
fn command_carries_program_and_leading_args() {
let l = SshLauncher::parse("kitten ssh").unwrap();
let cmd = l.command();
assert_eq!(cmd.get_program(), "kitten");
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(args, ["ssh"]);
}
#[test]
fn resolve_prefers_env_over_preference_over_default() {
crate::preferences::tests_helpers::with_temp_prefs(|paths| {
assert!(SshLauncher::resolve_from(None, Some(paths)).is_default());
crate::preferences::save_ssh_command(Some(paths), "kitten ssh").unwrap();
let l = SshLauncher::resolve_from(None, Some(paths));
assert_eq!(l.words(), ["kitten", "ssh"]);
let l = SshLauncher::resolve_from(Some("mosh-wrapper ssh"), Some(paths));
assert_eq!(l.words(), ["mosh-wrapper", "ssh"]);
let l = SshLauncher::resolve_from(Some(" "), Some(paths));
assert_eq!(l.words(), ["kitten", "ssh"]);
});
}
#[test]
fn resolve_ignores_unparseable_values() {
crate::preferences::tests_helpers::with_temp_prefs(|paths| {
crate::preferences::save_ssh_command(Some(paths), "kitten ssh").unwrap();
let l = SshLauncher::resolve_from(Some("kitten 'ssh"), Some(paths));
assert_eq!(l.words(), ["kitten", "ssh"]);
crate::preferences::save_ssh_command(Some(paths), "\"broken").unwrap();
assert!(SshLauncher::resolve_from(None, Some(paths)).is_default());
});
}
#[test]
fn resolve_reads_env_var_through_env() {
let env = Env::sandboxed().with_var(ENV_VAR, "kitten ssh");
assert_eq!(SshLauncher::resolve(&env).words(), ["kitten", "ssh"]);
let env = Env::sandboxed();
assert!(SshLauncher::resolve(&env).is_default());
}
}