purple_ssh/
ssh_launcher.rs1use std::process::Command;
9
10use crate::runtime::env::{Env, Paths};
11
12pub const ENV_VAR: &str = "PURPLE_SSH_COMMAND";
14pub const PREF_KEY: &str = "ssh_command";
16pub const DEFAULT_PROGRAM: &str = "ssh";
18
19#[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#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum LauncherError {
37 Empty,
39 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 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 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 pub fn program(&self) -> &str {
98 &self.words[0]
99 }
100
101 pub fn leading_args(&self) -> &[String] {
103 &self.words[1..]
104 }
105
106 pub fn words(&self) -> &[String] {
108 &self.words
109 }
110
111 pub fn is_default(&self) -> bool {
113 self.words.len() == 1 && self.words[0] == DEFAULT_PROGRAM
114 }
115
116 pub fn command(&self) -> Command {
118 let mut cmd = Command::new(self.program());
119 cmd.args(self.leading_args());
120 cmd
121 }
122
123 pub fn display(&self) -> String {
125 self.words.join(" ")
126 }
127
128 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
139fn 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
153fn 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 assert_eq!(words("'a\\b'"), vec!["a\\b"]);
251 assert_eq!(words("\"a\\nb\""), vec!["a\\nb"]);
253 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 assert!(SshLauncher::resolve_from(None, Some(paths)).is_default());
317
318 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 let l = SshLauncher::resolve_from(Some("mosh-wrapper ssh"), Some(paths));
325 assert_eq!(l.words(), ["mosh-wrapper", "ssh"]);
326
327 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 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 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}