Skip to main content

purple_ssh/
ssh_launcher.rs

1//! Which program runs an interactive SSH login.
2//!
3//! Plain `ssh` by default. `PURPLE_SSH_COMMAND` in the environment or
4//! `ssh_command` in `~/.purple/preferences` swaps it for a wrapper such as
5//! kitty's `kitten ssh`. Only the interactive login paths use this;
6//! tunnels, snippets, key push, scp and the MCP server keep calling `ssh`.
7
8use std::process::Command;
9
10use crate::runtime::env::{Env, Paths};
11
12/// Environment variable that overrides the preference.
13pub const ENV_VAR: &str = "PURPLE_SSH_COMMAND";
14/// Preference key in `~/.purple/preferences`.
15pub const PREF_KEY: &str = "ssh_command";
16/// What runs when nothing is configured.
17pub const DEFAULT_PROGRAM: &str = "ssh";
18
19/// A resolved launcher: the program plus any leading arguments that come
20/// before purple's own `-F <config> ... -- <alias>`.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct SshLauncher {
23    words: Vec<String>,
24}
25
26impl Default for SshLauncher {
27    fn default() -> Self {
28        Self {
29            words: vec![DEFAULT_PROGRAM.to_string()],
30        }
31    }
32}
33
34/// Why a configured value was rejected.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum LauncherError {
37    /// The value was empty or whitespace only.
38    Empty,
39    /// A quote was opened and never closed.
40    UnterminatedQuote,
41}
42
43impl std::fmt::Display for LauncherError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            LauncherError::Empty => write!(f, "empty command"),
47            LauncherError::UnterminatedQuote => write!(f, "unterminated quote"),
48        }
49    }
50}
51
52impl SshLauncher {
53    /// Parse a command line such as `kitten ssh` or `"/opt/kitty/bin/kitten" ssh`.
54    /// Splits on whitespace, honors single and double quotes and a backslash
55    /// escape outside single quotes.
56    pub fn parse(spec: &str) -> Result<Self, LauncherError> {
57        let words = split_words(spec)?;
58        if words.is_empty() {
59            return Err(LauncherError::Empty);
60        }
61        Ok(Self { words })
62    }
63
64    /// Resolve from the environment first, then the preference, then the
65    /// default. A value that fails to parse is logged and ignored so a typo
66    /// never locks the user out of connecting.
67    pub fn resolve(env: &Env) -> Self {
68        Self::resolve_from(env.var(ENV_VAR), env.paths())
69    }
70
71    fn resolve_from(env_value: Option<&str>, paths: Option<&Paths>) -> Self {
72        if let Some(spec) = env_value.map(str::trim).filter(|s| !s.is_empty()) {
73            match Self::parse(spec) {
74                Ok(l) => {
75                    log::debug!("[purple] ssh launcher from {ENV_VAR}: {}", l.display());
76                    return l;
77                }
78                Err(e) => log::warn!("[config] ignoring {ENV_VAR}={spec:?}: {e}"),
79            }
80        }
81        if let Some(spec) = crate::preferences::load_ssh_command(paths) {
82            match Self::parse(&spec) {
83                Ok(l) => {
84                    log::debug!(
85                        "[purple] ssh launcher from preference {PREF_KEY}: {}",
86                        l.display()
87                    );
88                    return l;
89                }
90                Err(e) => log::warn!("[config] ignoring preference {PREF_KEY}={spec:?}: {e}"),
91            }
92        }
93        Self::default()
94    }
95
96    /// The program to spawn.
97    pub fn program(&self) -> &str {
98        &self.words[0]
99    }
100
101    /// Arguments that precede purple's own ssh arguments.
102    pub fn leading_args(&self) -> &[String] {
103        &self.words[1..]
104    }
105
106    /// Every word, program first. Handy for argv lists such as `tmux new-window`.
107    pub fn words(&self) -> &[String] {
108        &self.words
109    }
110
111    /// True when nothing but plain `ssh` is configured.
112    pub fn is_default(&self) -> bool {
113        self.words.len() == 1 && self.words[0] == DEFAULT_PROGRAM
114    }
115
116    /// A `Command` for the program with the leading arguments applied.
117    pub fn command(&self) -> Command {
118        let mut cmd = Command::new(self.program());
119        cmd.args(self.leading_args());
120        cmd
121    }
122
123    /// Space-joined form for logs.
124    pub fn display(&self) -> String {
125        self.words.join(" ")
126    }
127
128    /// Shell-safe form for pasting: words with spaces or shell metacharacters
129    /// are single-quoted.
130    pub fn shell_display(&self) -> String {
131        self.words
132            .iter()
133            .map(|w| shell_quote(w))
134            .collect::<Vec<_>>()
135            .join(" ")
136    }
137}
138
139/// Single-quote `word` unless it only holds characters a POSIX shell passes
140/// through untouched.
141fn shell_quote(word: &str) -> String {
142    let safe = !word.is_empty()
143        && word
144            .chars()
145            .all(|c| c.is_ascii_alphanumeric() || "_-./:=+@%,".contains(c));
146    if safe {
147        word.to_string()
148    } else {
149        format!("'{}'", word.replace('\'', "'\\''"))
150    }
151}
152
153/// Shell-style word split: whitespace separates, quotes group, a backslash
154/// escapes the next character outside single quotes.
155fn split_words(spec: &str) -> Result<Vec<String>, LauncherError> {
156    let mut words = Vec::new();
157    let mut current = String::new();
158    let mut in_word = false;
159    let mut quote: Option<char> = None;
160    let mut chars = spec.chars().peekable();
161    while let Some(c) = chars.next() {
162        match quote {
163            Some('\'') => {
164                if c == '\'' {
165                    quote = None;
166                } else {
167                    current.push(c);
168                }
169            }
170            Some(_) => {
171                if c == '"' {
172                    quote = None;
173                } else if c == '\\' {
174                    match chars.next() {
175                        Some(n) if n == '"' || n == '\\' => current.push(n),
176                        Some(n) => {
177                            current.push('\\');
178                            current.push(n);
179                        }
180                        None => current.push('\\'),
181                    }
182                } else {
183                    current.push(c);
184                }
185            }
186            None => {
187                if c.is_whitespace() {
188                    if in_word {
189                        words.push(std::mem::take(&mut current));
190                        in_word = false;
191                    }
192                } else if c == '\'' || c == '"' {
193                    quote = Some(c);
194                    in_word = true;
195                } else if c == '\\' {
196                    in_word = true;
197                    if let Some(n) = chars.next() {
198                        current.push(n);
199                    }
200                } else {
201                    in_word = true;
202                    current.push(c);
203                }
204            }
205        }
206    }
207    if quote.is_some() {
208        return Err(LauncherError::UnterminatedQuote);
209    }
210    if in_word {
211        words.push(current);
212    }
213    Ok(words)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn words(spec: &str) -> Vec<String> {
221        SshLauncher::parse(spec).unwrap().words().to_vec()
222    }
223
224    #[test]
225    fn default_is_plain_ssh() {
226        let l = SshLauncher::default();
227        assert_eq!(l.program(), "ssh");
228        assert!(l.leading_args().is_empty());
229        assert!(l.is_default());
230        assert_eq!(l.display(), "ssh");
231    }
232
233    #[test]
234    fn parse_splits_on_whitespace() {
235        assert_eq!(words("kitten ssh"), vec!["kitten", "ssh"]);
236        assert_eq!(words("  kitten   ssh  "), vec!["kitten", "ssh"]);
237        assert_eq!(words("ssh"), vec!["ssh"]);
238    }
239
240    #[test]
241    fn parse_honors_quotes_and_escapes() {
242        assert_eq!(
243            words("\"/Applications/kitty.app/Contents/MacOS/kitten\" ssh"),
244            vec!["/Applications/kitty.app/Contents/MacOS/kitten", "ssh"]
245        );
246        assert_eq!(words("'/my dir/kitten' ssh"), vec!["/my dir/kitten", "ssh"]);
247        assert_eq!(words("/my\\ dir/kitten ssh"), vec!["/my dir/kitten", "ssh"]);
248        assert_eq!(words("\"a \\\"b\\\"\" c"), vec!["a \"b\"", "c"]);
249        // Backslash inside single quotes is literal.
250        assert_eq!(words("'a\\b'"), vec!["a\\b"]);
251        // Backslash before an ordinary char inside double quotes stays.
252        assert_eq!(words("\"a\\nb\""), vec!["a\\nb"]);
253        // Empty quoted word survives as an empty argument.
254        assert_eq!(words("kitten '' ssh"), vec!["kitten", "", "ssh"]);
255    }
256
257    #[test]
258    fn parse_rejects_empty_and_unterminated() {
259        assert_eq!(SshLauncher::parse(""), Err(LauncherError::Empty));
260        assert_eq!(SshLauncher::parse("   "), Err(LauncherError::Empty));
261        assert_eq!(
262            SshLauncher::parse("kitten 'ssh"),
263            Err(LauncherError::UnterminatedQuote)
264        );
265        assert_eq!(
266            SshLauncher::parse("\"kitten ssh"),
267            Err(LauncherError::UnterminatedQuote)
268        );
269    }
270
271    #[test]
272    fn program_and_leading_args_and_display() {
273        let l = SshLauncher::parse("kitten ssh --kitten interpreter=python").unwrap();
274        assert_eq!(l.program(), "kitten");
275        assert_eq!(l.leading_args(), ["ssh", "--kitten", "interpreter=python"]);
276        assert!(!l.is_default());
277        assert_eq!(l.display(), "kitten ssh --kitten interpreter=python");
278    }
279
280    #[test]
281    fn shell_display_quotes_only_when_needed() {
282        assert_eq!(SshLauncher::default().shell_display(), "ssh");
283        assert_eq!(
284            SshLauncher::parse("kitten ssh").unwrap().shell_display(),
285            "kitten ssh"
286        );
287        assert_eq!(
288            SshLauncher::parse("'/my dir/kitten' ssh")
289                .unwrap()
290                .shell_display(),
291            "'/my dir/kitten' ssh"
292        );
293        assert_eq!(
294            SshLauncher::parse("\"it's\" ssh").unwrap().shell_display(),
295            "'it'\\''s' ssh"
296        );
297        assert_eq!(
298            SshLauncher::parse("kitten '' ssh").unwrap().shell_display(),
299            "kitten '' ssh"
300        );
301    }
302
303    #[test]
304    fn command_carries_program_and_leading_args() {
305        let l = SshLauncher::parse("kitten ssh").unwrap();
306        let cmd = l.command();
307        assert_eq!(cmd.get_program(), "kitten");
308        let args: Vec<_> = cmd.get_args().collect();
309        assert_eq!(args, ["ssh"]);
310    }
311
312    #[test]
313    fn resolve_prefers_env_over_preference_over_default() {
314        crate::preferences::tests_helpers::with_temp_prefs(|paths| {
315            // Nothing configured: default.
316            assert!(SshLauncher::resolve_from(None, Some(paths)).is_default());
317
318            // Preference only.
319            crate::preferences::save_ssh_command(Some(paths), "kitten ssh").unwrap();
320            let l = SshLauncher::resolve_from(None, Some(paths));
321            assert_eq!(l.words(), ["kitten", "ssh"]);
322
323            // Env wins over preference.
324            let l = SshLauncher::resolve_from(Some("mosh-wrapper ssh"), Some(paths));
325            assert_eq!(l.words(), ["mosh-wrapper", "ssh"]);
326
327            // Empty or blank env value is treated as unset.
328            let l = SshLauncher::resolve_from(Some("   "), Some(paths));
329            assert_eq!(l.words(), ["kitten", "ssh"]);
330        });
331    }
332
333    #[test]
334    fn resolve_ignores_unparseable_values() {
335        crate::preferences::tests_helpers::with_temp_prefs(|paths| {
336            // Bad env value falls through to the preference.
337            crate::preferences::save_ssh_command(Some(paths), "kitten ssh").unwrap();
338            let l = SshLauncher::resolve_from(Some("kitten 'ssh"), Some(paths));
339            assert_eq!(l.words(), ["kitten", "ssh"]);
340
341            // Bad preference falls through to the default.
342            crate::preferences::save_ssh_command(Some(paths), "\"broken").unwrap();
343            assert!(SshLauncher::resolve_from(None, Some(paths)).is_default());
344        });
345    }
346
347    #[test]
348    fn resolve_reads_env_var_through_env() {
349        let env = Env::sandboxed().with_var(ENV_VAR, "kitten ssh");
350        assert_eq!(SshLauncher::resolve(&env).words(), ["kitten", "ssh"]);
351        let env = Env::sandboxed();
352        assert!(SshLauncher::resolve(&env).is_default());
353    }
354}