1#![deny(
2 clippy::unwrap_used,
3 clippy::expect_used,
4 clippy::indexing_slicing,
5 clippy::panic
6)]
7use std::io::Read;
8use std::path::{Path, PathBuf};
9
10use globset::Glob;
11use log::debug;
12use thiserror::*;
13
14#[derive(Debug, Error)]
15pub enum Error {
17 #[error("Host not found")]
18 HostNotFound,
19 #[error("No home directory")]
20 NoHome,
21 #[error("{}", 0)]
22 Io(#[from] std::io::Error),
23}
24
25mod proxy;
26pub use proxy::*;
27
28#[derive(Clone, Debug, Default)]
29struct HostConfig {
30 user: Option<String>,
32 hostname: Option<String>,
34 port: Option<u16>,
36 identity_file: Option<Vec<PathBuf>>,
38 proxy_command: Option<String>,
40 proxy_jump: Option<String>,
42 add_keys_to_agent: Option<AddKeysToAgent>,
44 user_known_hosts_file: Option<PathBuf>,
46 strict_host_key_checking: Option<bool>,
48}
49
50impl HostConfig {
51 fn merge(mut left: Self, right: &Self) -> Self {
52 macro_rules! clone_if_none {
53 ($left:ident, $right:ident, $($field:ident),+) => {
54 $(if $left.$field.is_none() {
55 $left.$field = $right.$field.clone();
56 })+
57 };
58 }
59
60 clone_if_none!(
61 left,
62 right,
63 user,
64 hostname,
65 port,
66 proxy_command,
67 proxy_jump,
68 add_keys_to_agent,
69 user_known_hosts_file,
70 strict_host_key_checking
71 );
72
73 if let Some(right_identity_files) = right.identity_file.as_deref() {
75 if let Some(identity_files) = left.identity_file.as_mut() {
76 identity_files.extend(right_identity_files.iter().cloned())
77 } else {
78 left.identity_file = Some(Vec::from_iter(right_identity_files.iter().cloned()))
79 }
80 }
81 left
82 }
83}
84
85#[derive(Clone, Debug)]
87struct HostPattern {
88 pattern: String,
89 negated: bool,
90}
91
92#[derive(Clone, Debug, Default)]
93struct HostEntry {
94 host_patterns: Vec<HostPattern>,
95 host_config: HostConfig,
96}
97
98impl HostEntry {
99 fn matches(&self, host: &str) -> bool {
100 let mut matches = false;
101 for host_pattern in self.host_patterns.iter() {
102 if check_host_against_glob_pattern(host, &host_pattern.pattern) {
103 if host_pattern.negated {
104 return false;
106 }
107 matches = true;
108 }
109 }
110 matches
111 }
112}
113
114struct SshConfig {
115 entries: Vec<HostEntry>,
116}
117
118impl SshConfig {
119 pub fn query(&self, host: &str) -> HostConfig {
120 self.entries
121 .iter()
122 .filter_map(|e| {
123 if e.matches(host) {
124 Some(&e.host_config)
125 } else {
126 None
127 }
128 })
129 .fold(HostConfig::default(), HostConfig::merge)
130 }
131}
132
133#[derive(Clone, Debug)]
134pub struct Config {
135 host_name: String,
136 user: Option<String>,
137 port: Option<u16>,
138 host_config: HostConfig,
139}
140
141impl Config {
142 pub fn default(host: &str) -> Self {
143 Self {
144 host_name: host.to_string(),
145 user: None,
146 port: None,
147 host_config: HostConfig::default(),
148 }
149 }
150
151 pub fn user(&self) -> String {
152 self.user
153 .as_deref()
154 .or(self.host_config.user.as_deref())
155 .map(ToString::to_string)
156 .unwrap_or_else(whoami::username)
157 }
158
159 pub fn port(&self) -> u16 {
160 self.host_config.port.or(self.port).unwrap_or(22)
161 }
162
163 pub fn host(&self) -> &str {
164 self.host_config
165 .hostname
166 .as_ref()
167 .unwrap_or(&self.host_name)
168 }
169
170 fn expand_tokens(&self, original: &str) -> String {
175 let mut string = original.to_string();
176 string = string.replace("%u", &self.user());
177 string = string.replace("%h", self.host()); string = string.replace("%H", self.host()); string = string.replace("%p", &format!("{}", self.port())); string = string.replace("%%", "%");
181 string
182 }
183
184 pub async fn stream(&self) -> Result<Stream, Error> {
185 if let Some(ref proxy_command) = self.host_config.proxy_command {
186 let proxy_command = self.expand_tokens(proxy_command);
187 let cmd: Vec<&str> = proxy_command.split(' ').collect();
188 Stream::proxy_command(cmd.first().unwrap_or(&""), cmd.get(1..).unwrap_or(&[]))
189 .await
190 .map_err(Into::into)
191 } else {
192 Stream::tcp_connect((self.host(), self.port()))
193 .await
194 .map_err(Into::into)
195 }
196 }
197}
198
199fn parse_ssh_config(contents: &str) -> Result<SshConfig, Error> {
200 let mut entries = Vec::new();
201
202 let mut host_patterns: Option<Vec<HostPattern>> = None;
203 let mut config = HostConfig::default();
204 let mut found_params = false;
205
206 for line in contents.lines().map(|line| line.trim()) {
207 if line.is_empty() || line.starts_with('#') {
208 continue;
213 }
214 let tokens = line.splitn(2, ' ').collect::<Vec<&str>>();
215 if tokens.len() == 2 {
216 let (key, value) = (tokens.first().unwrap_or(&""), tokens.get(1).unwrap_or(&""));
217 let lower = key.to_lowercase();
218 if lower != "host" {
219 found_params = true;
220 }
221 match lower.as_str() {
222 "host" => {
223 let patterns = value
224 .split_ascii_whitespace()
225 .filter_map(|pattern| {
226 if pattern.is_empty() {
227 None
228 } else {
229 let (pattern, negated) =
230 if let Some(pattern) = pattern.strip_prefix('!') {
231 (pattern, true)
232 } else {
233 (pattern, false)
234 };
235 Some(HostPattern {
236 pattern: pattern.to_string(),
237 negated,
238 })
239 }
240 })
241 .collect();
242
243 if let Some(host_patterns) = host_patterns.take() {
244 let host_config = std::mem::take(&mut config);
245 entries.push(HostEntry {
246 host_patterns,
247 host_config,
248 });
249 } else if found_params {
250 return Err(Error::HostNotFound);
251 }
252
253 found_params = false;
254 host_patterns = Some(patterns);
255 }
256 "user" => config.user = Some(value.trim_start().to_string()),
257 "hostname" => config.hostname = Some(value.trim_start().to_string()),
258 "port" => {
259 if let Ok(port) = value.trim_start().parse::<u16>() {
260 config.port = Some(port)
261 }
262 }
263 "identityfile" => {
264 let identity_file = value.trim_start().strip_quotes().expand_home()?;
265 if let Some(files) = config.identity_file.as_mut() {
266 files.push(identity_file);
267 } else {
268 config.identity_file = Some(vec![identity_file])
269 }
270 }
271 "proxycommand" => config.proxy_command = Some(value.trim_start().to_string()),
272 "proxyjump" => config.proxy_jump = Some(value.trim_start().to_string()),
273 "addkeystoagent" => {
274 let value = match value.to_lowercase().as_str() {
275 "yes" => AddKeysToAgent::Yes,
276 "confirm" => AddKeysToAgent::Confirm,
277 "ask" => AddKeysToAgent::Ask,
278 _ => AddKeysToAgent::No,
279 };
280 config.add_keys_to_agent = Some(value)
281 }
282 "userknownhostsfile" => {
283 config.user_known_hosts_file =
284 Some(value.trim_start().strip_quotes().expand_home()?);
285 }
286 "stricthostkeychecking" => match value.to_lowercase().as_str() {
287 "no" => config.strict_host_key_checking = Some(false),
288 _ => config.strict_host_key_checking = Some(true),
289 },
290 key => {
291 debug!("{key:?}");
292 }
293 }
294 }
295 }
296
297 if let Some(host_patterns) = host_patterns.take() {
298 let host_config = std::mem::take(&mut config);
299 entries.push(HostEntry {
300 host_patterns,
301 host_config,
302 });
303 } else if found_params {
304 return Err(Error::HostNotFound);
306 }
307
308 Ok(SshConfig { entries })
309}
310
311pub fn parse(file: &str, host: &str) -> Result<Config, Error> {
312 let ssh_config = parse_ssh_config(file)?;
313 let host_config = ssh_config.query(host);
314 Ok(Config {
315 host_name: host.to_string(),
316 user: None,
317 port: None,
318 host_config,
319 })
320}
321
322pub fn parse_home(host: &str) -> Result<Config, Error> {
323 let mut home = if let Some(home) = home::home_dir() {
324 home
325 } else {
326 return Err(Error::NoHome);
327 };
328 home.push(".ssh");
329 home.push("config");
330 parse_path(&home, host)
331}
332
333pub fn parse_path<P: AsRef<Path>>(path: P, host: &str) -> Result<Config, Error> {
334 let mut s = String::new();
335 let mut b = std::fs::File::open(path)?;
336 b.read_to_string(&mut s)?;
337 parse(&s, host)
338}
339
340#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
341pub enum AddKeysToAgent {
342 Yes,
343 Confirm,
344 Ask,
345 #[default]
346 No,
347}
348
349fn check_host_against_glob_pattern(candidate: &str, glob_pattern: &str) -> bool {
350 match Glob::new(glob_pattern) {
351 Ok(glob) => glob.compile_matcher().is_match(candidate),
352 _ => false,
353 }
354}
355
356trait SshConfigStrExt {
357 fn strip_quotes(&self) -> Self;
358 fn expand_home(&self) -> Result<PathBuf, Error>;
359}
360
361impl SshConfigStrExt for &str {
362 fn strip_quotes(&self) -> Self {
363 if self.len() > 1
364 && ((self.starts_with('\'') && self.ends_with('\''))
365 || (self.starts_with('\"') && self.ends_with('\"')))
366 {
367 #[allow(clippy::indexing_slicing)] &self[1..self.len() - 1]
369 } else {
370 self
371 }
372 }
373
374 fn expand_home(&self) -> Result<PathBuf, Error> {
375 if self.starts_with("~/") {
376 if let Some(mut home) = home::home_dir() {
377 home.push(self.split_at(2).1);
378 Ok(home)
379 } else {
380 Err(Error::NoHome)
381 }
382 } else {
383 Ok(self.into())
384 }
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 #![allow(clippy::expect_used)]
391 use std::path::{Path, PathBuf};
392
393 use crate::{AddKeysToAgent, Config, Error, SshConfigStrExt, parse};
394
395 #[test]
396 fn strip_quotes() {
397 let value = "'this is a test'";
398 assert_eq!("this is a test", value.strip_quotes());
399 let value = "\"this is a test\"";
400 assert_eq!("this is a test", value.strip_quotes());
401 let value = "'this is a test\"";
402 assert_eq!("'this is a test\"", value.strip_quotes());
403 let value = "'this is a test";
404 assert_eq!("'this is a test", value.strip_quotes());
405 let value = "this is a test'";
406 assert_eq!("this is a test'", value.strip_quotes());
407 let value = "this is a test";
408 assert_eq!("this is a test", value.strip_quotes());
409 let value = "";
410 assert_eq!("", value.strip_quotes());
411 let value = "'";
412 assert_eq!("'", value.strip_quotes());
413 let value = "''";
414 assert_eq!("", value.strip_quotes());
415 }
416
417 #[test]
418 fn expand_home() {
419 let value = "~/some/folder".expand_home().expect("expand_home");
420 assert_eq!(
421 format!(
422 "{}{}",
423 home::home_dir().expect("homedir").to_str().expect("to_str"),
424 "/some/folder"
425 ),
426 value.to_str().unwrap()
427 );
428 }
429
430 #[test]
431 fn default_config() {
432 let config: Config = Config::default("some_host");
433 assert_eq!(whoami::username(), config.user());
434 assert_eq!("some_host", config.host_name);
435 assert_eq!(22, config.port());
436 assert_eq!(None, config.host_config.identity_file);
437 assert_eq!(None, config.host_config.proxy_command);
438 assert_eq!(None, config.host_config.proxy_jump);
439 assert_eq!(None, config.host_config.add_keys_to_agent);
440 assert_eq!(None, config.host_config.user_known_hosts_file);
441 assert_eq!(None, config.host_config.strict_host_key_checking);
442 }
443
444 #[test]
445 fn basic_config() {
446 let value = r"#
447Host test_host
448 IdentityFile '~/.ssh/id_ed25519'
449 User trinity
450 Hostname foo.com
451 Port 23
452 AddKeysToAgent confirm
453 UserKnownHostsFile /some/special/host_file
454 StrictHostKeyChecking no
455#";
456 let identity_file = PathBuf::from(format!(
457 "{}{}",
458 home::home_dir().expect("homedir").to_str().expect("to_str"),
459 "/.ssh/id_ed25519"
460 ));
461 let config = parse(value, "test_host").expect("parse");
462 assert_eq!("trinity", config.user());
463 assert_eq!("foo.com", config.host());
464 assert_eq!(23, config.port());
465 assert_eq!(Some(vec![identity_file,]), config.host_config.identity_file);
466 assert_eq!(None, config.host_config.proxy_command);
467 assert_eq!(None, config.host_config.proxy_jump);
468 assert_eq!(
469 Some(AddKeysToAgent::Confirm),
470 config.host_config.add_keys_to_agent
471 );
472 assert_eq!(
473 Some(Path::new("/some/special/host_file")),
474 config.host_config.user_known_hosts_file.as_deref()
475 );
476 assert_eq!(Some(false), config.host_config.strict_host_key_checking);
477 }
478
479 #[test]
480 fn multiple_patterns() {
481 let config = parse(
482 r#"
483Host a.test_host
484 Port 42
485 IdentityFile '/path/to/id_ed25519'
486Host b.test_host
487 User invalid
488Host *.test_host
489 Hostname foo.com
490Host *.test_host !a.test_host
491 User invalid
492Host *
493 User trinity
494 Hostname invalid
495 IdentityFile '/path/to/id_rsa'
496 "#,
497 "a.test_host",
498 )
499 .expect("config is valid");
500
501 assert_eq!("trinity", config.user());
502 assert_eq!("foo.com", config.host());
503 assert_eq!(42, config.port());
504 assert_eq!(
505 Some(vec![
506 PathBuf::from("/path/to/id_ed25519"),
507 PathBuf::from("/path/to/id_rsa")
508 ]),
509 config.host_config.identity_file
510 )
511 }
512
513 #[test]
514 fn empty_ssh_config() {
515 let ssh_config = parse("\n\n\n", "test_host").expect("parse");
516 assert_eq!(ssh_config.host(), "test_host");
517 assert_eq!(ssh_config.port(), 22);
518 }
519
520 #[test]
521 fn malformed() {
522 assert!(matches!(
523 parse("Hostname foo.com", "malformed"),
524 Err(Error::HostNotFound)
525 ));
526 assert!(matches!(
527 parse("Hostname foo.com\nHost foo", "malformed"),
528 Err(Error::HostNotFound)
529 ))
530 }
531
532 #[test]
533 fn is_clone() {
534 let config: Config = Config::default("some_host");
535 let _ = config.clone();
536 }
537
538 #[test]
539 fn comment_handling() {
540 const CONFIG: &str = r#"
541# top of the config file
542Host a.test_host
543 # indented comment
544 User a
545 # indented comment between parameters
546 Hostname alias_of_a
547# middle of the config file
548Host b.test_host
549 # multiple line
550 # indented comment
551 User b
552 # multiple line
553 # indented comment between parameters
554 Hostname alias_of_b
555# end of the config file
556 "#;
557 let config = parse(CONFIG, "a.test_host").expect("config is invalid");
558 assert_eq!("a", config.user());
559 assert_eq!("alias_of_a", config.host());
560
561 let config = parse(CONFIG, "b.test_host").expect("config is invalid");
562 assert_eq!("b", config.user());
563 assert_eq!("alias_of_b", config.host());
564 }
565}