1use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17use super::include::expand_includes;
18use super::lexer::{expand_env, expand_tilde, tokenize};
19use super::matcher::directives_for_host;
20use super::parser::{parse, Directive, HostBlock};
21use crate::error::AnvilError;
22
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct SshConfigPaths {
33 pub user: Option<PathBuf>,
36
37 pub system: Option<PathBuf>,
40}
41
42impl SshConfigPaths {
43 #[must_use]
50 pub fn default_paths() -> Self {
51 let user = dirs::home_dir().map(|h| h.join(".ssh").join("config"));
52 let system = if cfg!(unix) {
53 Some(PathBuf::from("/etc/ssh/ssh_config"))
54 } else if cfg!(windows) {
55 std::env::var_os("ProgramData").map(|pd| {
56 let mut p = PathBuf::from(pd);
57 p.push("ssh");
58 p.push("ssh_config");
59 p
60 })
61 } else {
62 None
63 };
64 Self { user, system }
65 }
66
67 #[must_use]
70 pub fn none() -> Self {
71 Self::default()
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum StrictHostKeyChecking {
82 Yes,
84 No,
87 AcceptNew,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct AlgList(pub String);
101
102#[derive(Debug, Clone)]
106pub struct DirectiveSource {
107 pub directive: String,
109 pub file: PathBuf,
111 pub line: u32,
113}
114
115#[derive(Debug, Clone, Default)]
126pub struct ResolvedSshConfig {
127 pub hostname: Option<String>,
130 pub user: Option<String>,
132 pub port: Option<u16>,
134 pub identity_files: Vec<PathBuf>,
137 pub identities_only: Option<bool>,
140 pub identity_agent: Option<PathBuf>,
142 pub certificate_files: Vec<PathBuf>,
144 pub proxy_command: Option<String>,
151 pub proxy_jump: Option<String>,
153 pub user_known_hosts_files: Vec<PathBuf>,
155 pub strict_host_key_checking: Option<StrictHostKeyChecking>,
157 pub host_key_algorithms: Option<AlgList>,
159 pub kex_algorithms: Option<AlgList>,
161 pub ciphers: Option<AlgList>,
163 pub macs: Option<AlgList>,
165 pub connect_timeout: Option<Duration>,
168 pub connection_attempts: Option<u32>,
170 pub provenance: Vec<DirectiveSource>,
174}
175
176pub fn resolve(host: &str, paths: &SshConfigPaths) -> Result<ResolvedSshConfig, AnvilError> {
196 let mut all_blocks: Vec<HostBlock> = Vec::new();
197
198 if let Some(user) = &paths.user {
199 let path = expand_path_for_read(user);
200 all_blocks.extend(read_and_parse(&path)?);
201 }
202 if let Some(system) = &paths.system {
203 let path = expand_path_for_read(system);
204 all_blocks.extend(read_and_parse(&path)?);
205 }
206
207 let mut resolved = ResolvedSshConfig::default();
208 if all_blocks.is_empty() {
209 return Ok(resolved);
210 }
211
212 for d in directives_for_host(&all_blocks, host) {
213 apply_directive(d, &mut resolved)?;
214 }
215
216 Ok(resolved)
217}
218
219fn expand_path_for_read(path: &Path) -> PathBuf {
221 let s = path.to_string_lossy();
222 PathBuf::from(expand_tilde(&s))
223}
224
225fn read_and_parse(path: &Path) -> Result<Vec<HostBlock>, AnvilError> {
228 let content = match std::fs::read_to_string(path) {
229 Ok(c) => c,
230 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
231 Err(e) => {
232 return Err(AnvilError::invalid_config(format!(
233 "ssh_config: failed to read {}: {e}",
234 path.display(),
235 )));
236 }
237 };
238 let tokens = tokenize(&content, path)?;
239 let expanded = expand_includes(path, tokens)?;
240 parse(expanded)
241}
242
243#[allow(
246 clippy::too_many_lines,
247 reason = "directive dispatch is intentionally one big match for clarity \
248 and easy review; each arm is a few lines and there is no \
249 meaningful sub-grouping"
250)]
251fn apply_directive(d: &Directive, resolved: &mut ResolvedSshConfig) -> Result<(), AnvilError> {
252 let mut recorded = true;
253
254 match d.keyword.as_str() {
255 "hostname" => {
256 if resolved.hostname.is_none() {
257 resolved.hostname = Some(first_arg_required(d)?);
258 }
259 }
260 "user" => {
261 if resolved.user.is_none() {
262 resolved.user = Some(first_arg_required(d)?);
263 }
264 }
265 "port" => {
266 if resolved.port.is_none() {
267 let s = first_arg_required(d)?;
268 resolved.port = Some(s.parse::<u16>().map_err(|e| {
269 AnvilError::invalid_config(format!(
270 "ssh_config: invalid Port '{s}' at {}:{}: {e}",
271 d.file.display(),
272 d.line_no,
273 ))
274 })?);
275 }
276 }
277 "identityfile" => {
278 require_at_least_one(d)?;
279 for arg in &d.args {
280 resolved.identity_files.push(expand_path_value(arg));
281 }
282 }
283 "identitiesonly" => {
284 if resolved.identities_only.is_none() {
285 resolved.identities_only = Some(parse_yes_no(d)?);
286 }
287 }
288 "identityagent" => {
289 if resolved.identity_agent.is_none() {
290 let s = first_arg_required(d)?;
291 resolved.identity_agent = Some(expand_path_value(&s));
292 }
293 }
294 "certificatefile" => {
295 require_at_least_one(d)?;
296 for arg in &d.args {
297 resolved.certificate_files.push(expand_path_value(arg));
298 }
299 }
300 "proxycommand" => {
301 if resolved.proxy_command.is_none() {
302 if d.args.is_empty() {
303 return Err(missing_value_err(d));
304 }
305 let value = if d.args.len() == 1 && d.args[0].eq_ignore_ascii_case("none") {
321 "none".to_owned()
322 } else {
323 d.args.join(" ")
327 };
328 resolved.proxy_command = Some(value);
329 }
330 }
331 "proxyjump" => {
332 if resolved.proxy_jump.is_none() {
333 resolved.proxy_jump = Some(first_arg_required(d)?);
334 }
335 }
336 "userknownhostsfile" => {
337 require_at_least_one(d)?;
338 for arg in &d.args {
339 resolved.user_known_hosts_files.push(expand_path_value(arg));
340 }
341 }
342 "stricthostkeychecking" => {
343 if resolved.strict_host_key_checking.is_none() {
344 let s = first_arg_required(d)?;
345 let v = match s.to_ascii_lowercase().as_str() {
346 "yes" | "ask" => StrictHostKeyChecking::Yes,
349 "no" | "off" => StrictHostKeyChecking::No,
350 "accept-new" => StrictHostKeyChecking::AcceptNew,
351 other => {
352 return Err(AnvilError::invalid_config(format!(
353 "ssh_config: invalid StrictHostKeyChecking '{other}' at {}:{}",
354 d.file.display(),
355 d.line_no,
356 )));
357 }
358 };
359 resolved.strict_host_key_checking = Some(v);
360 }
361 }
362 "hostkeyalgorithms" => {
363 if resolved.host_key_algorithms.is_none() {
364 resolved.host_key_algorithms = Some(AlgList(first_arg_required(d)?));
365 }
366 }
367 "kexalgorithms" => {
368 if resolved.kex_algorithms.is_none() {
369 resolved.kex_algorithms = Some(AlgList(first_arg_required(d)?));
370 }
371 }
372 "ciphers" => {
373 if resolved.ciphers.is_none() {
374 resolved.ciphers = Some(AlgList(first_arg_required(d)?));
375 }
376 }
377 "macs" => {
378 if resolved.macs.is_none() {
379 resolved.macs = Some(AlgList(first_arg_required(d)?));
380 }
381 }
382 "connecttimeout" => {
383 if resolved.connect_timeout.is_none() {
384 let s = first_arg_required(d)?;
385 let secs: u64 = s.parse().map_err(|e| {
386 AnvilError::invalid_config(format!(
387 "ssh_config: invalid ConnectTimeout '{s}' at {}:{}: {e}",
388 d.file.display(),
389 d.line_no,
390 ))
391 })?;
392 resolved.connect_timeout = Some(Duration::from_secs(secs));
393 }
394 }
395 "connectionattempts" => {
396 if resolved.connection_attempts.is_none() {
397 let s = first_arg_required(d)?;
398 resolved.connection_attempts = Some(s.parse::<u32>().map_err(|e| {
399 AnvilError::invalid_config(format!(
400 "ssh_config: invalid ConnectionAttempts '{s}' at {}:{}: {e}",
401 d.file.display(),
402 d.line_no,
403 ))
404 })?);
405 }
406 }
407 _ => {
408 log::trace!(
412 "ssh_config: ignoring unhandled directive '{}' at {}:{}",
413 d.keyword,
414 d.file.display(),
415 d.line_no,
416 );
417 recorded = false;
418 }
419 }
420
421 if recorded {
422 resolved.provenance.push(DirectiveSource {
423 directive: d.keyword.clone(),
424 file: d.file.clone(),
425 line: d.line_no,
426 });
427 }
428
429 Ok(())
430}
431
432fn first_arg_required(d: &Directive) -> Result<String, AnvilError> {
433 d.args.first().cloned().ok_or_else(|| missing_value_err(d))
434}
435
436fn require_at_least_one(d: &Directive) -> Result<(), AnvilError> {
437 if d.args.is_empty() {
438 Err(missing_value_err(d))
439 } else {
440 Ok(())
441 }
442}
443
444fn missing_value_err(d: &Directive) -> AnvilError {
445 AnvilError::invalid_config(format!(
446 "ssh_config: directive '{}' at {}:{} has no value",
447 d.keyword,
448 d.file.display(),
449 d.line_no,
450 ))
451}
452
453fn parse_yes_no(d: &Directive) -> Result<bool, AnvilError> {
454 let s = first_arg_required(d)?;
455 match s.to_ascii_lowercase().as_str() {
456 "yes" | "true" => Ok(true),
457 "no" | "false" => Ok(false),
458 other => Err(AnvilError::invalid_config(format!(
459 "ssh_config: expected yes/no for '{}' at {}:{}, got '{other}'",
460 d.keyword,
461 d.file.display(),
462 d.line_no,
463 ))),
464 }
465}
466
467fn expand_path_value(value: &str) -> PathBuf {
469 PathBuf::from(expand_tilde(&expand_env(value)))
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use std::fs;
476 use tempfile::tempdir;
477
478 fn write_config(content: &str) -> (tempfile::TempDir, PathBuf) {
481 let dir = tempdir().expect("tempdir");
482 let path = dir.path().join("config");
483 fs::write(&path, content).expect("write config");
484 (dir, path)
485 }
486
487 fn paths_user_only(p: PathBuf) -> SshConfigPaths {
488 SshConfigPaths {
489 user: Some(p),
490 system: None,
491 }
492 }
493
494 #[test]
495 fn empty_paths_returns_default() {
496 let resolved = resolve("anyhost", &SshConfigPaths::none()).expect("resolve with no files");
497 assert_eq!(resolved.hostname, None);
498 assert!(resolved.identity_files.is_empty());
499 assert!(resolved.provenance.is_empty());
500 }
501
502 #[test]
503 fn missing_file_is_silently_ignored() {
504 let paths = SshConfigPaths {
505 user: Some(PathBuf::from("/this/path/definitely/does/not/exist")),
506 system: None,
507 };
508 let resolved = resolve("anyhost", &paths).expect("resolve");
509 assert_eq!(resolved.hostname, None);
510 }
511
512 #[test]
513 fn resolves_basic_block() {
514 let (_g, conf) = write_config("Host gh\n HostName github.com\n User git\n Port 2222\n");
515 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
516 assert_eq!(resolved.hostname.as_deref(), Some("github.com"));
517 assert_eq!(resolved.user.as_deref(), Some("git"));
518 assert_eq!(resolved.port, Some(2222));
519 assert_eq!(resolved.provenance.len(), 3);
520 }
521
522 #[test]
523 fn first_occurrence_wins_for_single_valued_fields() {
524 let (_g, conf) = write_config(
527 "Host gh\n HostName specific.example.com\nHost *\n HostName fallback.example.com\n",
528 );
529 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
530 assert_eq!(resolved.hostname.as_deref(), Some("specific.example.com"));
531 }
532
533 #[test]
534 fn multiple_identity_files_accumulate() {
535 let (_g, conf) =
536 write_config("Host gh\n IdentityFile ~/.ssh/id_a\n IdentityFile ~/.ssh/id_b\n");
537 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
538 assert_eq!(resolved.identity_files.len(), 2);
539 assert!(!resolved.identity_files[0]
541 .to_string_lossy()
542 .starts_with('~'));
543 }
544
545 #[test]
546 fn identityfile_one_line_multiple_args_accumulates() {
547 let (_g, conf) = write_config("Host gh\n IdentityFile a b c\n");
549 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
550 assert_eq!(resolved.identity_files.len(), 3);
551 }
552
553 #[test]
554 fn invalid_port_errors() {
555 let (_g, conf) = write_config("Host gh\n Port not_a_number\n");
556 let err = resolve("gh", &paths_user_only(conf)).expect_err("invalid Port");
557 let msg = format!("{err}");
558 assert!(msg.contains("invalid Port"), "got: {msg}");
559 }
560
561 #[test]
562 fn strict_host_key_checking_variants() {
563 let cases = &[
564 ("yes", StrictHostKeyChecking::Yes),
565 ("ask", StrictHostKeyChecking::Yes), ("no", StrictHostKeyChecking::No),
567 ("off", StrictHostKeyChecking::No),
568 ("accept-new", StrictHostKeyChecking::AcceptNew),
569 ];
570 for (raw, expected) in cases {
571 let (_g, conf) = write_config(&format!("Host gh\n StrictHostKeyChecking {raw}\n"));
572 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
573 assert_eq!(
574 resolved.strict_host_key_checking,
575 Some(*expected),
576 "case `{raw}`",
577 );
578 }
579 }
580
581 #[test]
582 fn algorithm_directives_captured_raw() {
583 let (_g, conf) = write_config(
584 "Host gh\n HostKeyAlgorithms ssh-ed25519,rsa-sha2-512\n KexAlgorithms curve25519-sha256\n",
585 );
586 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
587 assert_eq!(
588 resolved.host_key_algorithms,
589 Some(AlgList("ssh-ed25519,rsa-sha2-512".to_owned())),
590 );
591 assert_eq!(
592 resolved.kex_algorithms,
593 Some(AlgList("curve25519-sha256".to_owned())),
594 );
595 }
596
597 #[test]
598 fn connect_timeout_parses_to_duration() {
599 let (_g, conf) = write_config("Host gh\n ConnectTimeout 30\n");
600 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
601 assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(30)));
602 }
603
604 #[test]
605 fn connection_attempts_parses() {
606 let (_g, conf) = write_config("Host gh\n ConnectionAttempts 5\n");
607 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
608 assert_eq!(resolved.connection_attempts, Some(5));
609 }
610
611 #[test]
612 fn proxy_command_joined_with_spaces() {
613 let (_g, conf) = write_config("Host gh\n ProxyCommand ssh -W %h:%p bastion\n");
614 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
615 assert_eq!(
617 resolved.proxy_command.as_deref(),
618 Some("ssh -W %h:%p bastion"),
619 );
620 }
621
622 #[test]
623 fn proxy_jump_captured() {
624 let (_g, conf) = write_config("Host gh\n ProxyJump bastion.example.com\n");
625 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
626 assert_eq!(resolved.proxy_jump.as_deref(), Some("bastion.example.com"),);
627 }
628
629 #[test]
630 fn proxy_command_none_preserved_as_literal() {
631 let (_g, conf) = write_config("Host gh\n ProxyCommand none\n");
636 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
637 assert_eq!(resolved.proxy_command.as_deref(), Some("none"));
638 }
639
640 #[test]
641 fn proxy_command_none_case_insensitive() {
642 for raw in ["NONE", "None", "nOnE"] {
644 let (_g, conf) = write_config(&format!("Host gh\n ProxyCommand {raw}\n"));
645 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
646 assert_eq!(
647 resolved.proxy_command.as_deref(),
648 Some("none"),
649 "case `{raw}`: should normalize to lowercase `none`",
650 );
651 }
652 }
653
654 #[test]
655 fn proxy_command_none_overrides_later_wildcard() {
656 let (_g, conf) =
662 write_config("Host gh\n ProxyCommand none\nHost *\n ProxyCommand /usr/bin/false\n");
663 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
664 assert_eq!(resolved.proxy_command.as_deref(), Some("none"));
665 }
666
667 #[test]
668 fn proxy_command_with_word_none_in_middle_not_treated_as_disable() {
669 let (_g, conf) = write_config("Host gh\n ProxyCommand none-yet-a-real-cmd %h\n");
673 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
674 assert_eq!(
675 resolved.proxy_command.as_deref(),
676 Some("none-yet-a-real-cmd %h"),
677 );
678 }
679
680 #[test]
681 fn user_known_hosts_files_accumulate() {
682 let (_g, conf) = write_config(
683 "Host gh\n UserKnownHostsFile /etc/known\n UserKnownHostsFile /home/u/known\n",
684 );
685 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
686 assert_eq!(resolved.user_known_hosts_files.len(), 2);
687 }
688
689 #[test]
690 fn user_known_hosts_files_one_line_multi_args() {
691 let (_g, conf) = write_config("Host gh\n UserKnownHostsFile /a /b /c\n");
692 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
693 assert_eq!(resolved.user_known_hosts_files.len(), 3);
694 }
695
696 #[test]
697 fn unknown_directives_ignored() {
698 let (_g, conf) = write_config("Host gh\n ServerAliveInterval 60\n User git\n");
699 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
700 assert_eq!(resolved.user.as_deref(), Some("git"));
702 assert_eq!(resolved.provenance.len(), 1);
703 }
704
705 #[test]
706 fn provenance_records_file_and_line() {
707 let (_g, conf) = write_config("# header\nHost gh\n User git\n");
708 let resolved = resolve("gh", &paths_user_only(conf.clone())).expect("resolve");
709 assert_eq!(resolved.provenance.len(), 1);
710 let prov = &resolved.provenance[0];
711 assert_eq!(prov.directive, "user");
712 assert_eq!(prov.line, 3);
713 let prov_canon = prov.file.canonicalize().unwrap_or(prov.file.clone());
716 let conf_canon = conf.canonicalize().unwrap_or(conf);
717 assert_eq!(prov_canon, conf_canon);
718 }
719
720 #[test]
721 fn user_then_system_first_wins() {
722 let dir = tempdir().expect("tempdir");
723 let user_path = dir.path().join("user_config");
724 let sys_path = dir.path().join("sys_config");
725 fs::write(&user_path, "Host gh\n User from_user\n").expect("write user");
726 fs::write(&sys_path, "Host gh\n User from_system\n").expect("write sys");
727
728 let paths = SshConfigPaths {
729 user: Some(user_path),
730 system: Some(sys_path),
731 };
732 let resolved = resolve("gh", &paths).expect("resolve");
733 assert_eq!(resolved.user.as_deref(), Some("from_user"));
734 }
735
736 #[test]
737 fn no_match_yields_empty_resolved() {
738 let (_g, conf) = write_config("Host other\n User unrelated\n");
739 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
740 assert_eq!(resolved.user, None);
741 assert!(resolved.provenance.is_empty());
742 }
743}