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 tracing::trace!(
428 target: crate::log::CAT_CONFIG,
429 file = %d.file.display(),
430 line = d.line_no,
431 directive = %d.keyword,
432 value = %d.args.join(" "),
433 "ssh_config directive applied",
434 );
435 resolved.provenance.push(DirectiveSource {
436 directive: d.keyword.clone(),
437 file: d.file.clone(),
438 line: d.line_no,
439 });
440 }
441
442 Ok(())
443}
444
445fn first_arg_required(d: &Directive) -> Result<String, AnvilError> {
446 d.args.first().cloned().ok_or_else(|| missing_value_err(d))
447}
448
449fn require_at_least_one(d: &Directive) -> Result<(), AnvilError> {
450 if d.args.is_empty() {
451 Err(missing_value_err(d))
452 } else {
453 Ok(())
454 }
455}
456
457fn missing_value_err(d: &Directive) -> AnvilError {
458 AnvilError::invalid_config(format!(
459 "ssh_config: directive '{}' at {}:{} has no value",
460 d.keyword,
461 d.file.display(),
462 d.line_no,
463 ))
464}
465
466fn parse_yes_no(d: &Directive) -> Result<bool, AnvilError> {
467 let s = first_arg_required(d)?;
468 match s.to_ascii_lowercase().as_str() {
469 "yes" | "true" => Ok(true),
470 "no" | "false" => Ok(false),
471 other => Err(AnvilError::invalid_config(format!(
472 "ssh_config: expected yes/no for '{}' at {}:{}, got '{other}'",
473 d.keyword,
474 d.file.display(),
475 d.line_no,
476 ))),
477 }
478}
479
480fn expand_path_value(value: &str) -> PathBuf {
482 PathBuf::from(expand_tilde(&expand_env(value)))
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use std::fs;
489 use tempfile::tempdir;
490
491 fn write_config(content: &str) -> (tempfile::TempDir, PathBuf) {
494 let dir = tempdir().expect("tempdir");
495 let path = dir.path().join("config");
496 fs::write(&path, content).expect("write config");
497 (dir, path)
498 }
499
500 fn paths_user_only(p: PathBuf) -> SshConfigPaths {
501 SshConfigPaths {
502 user: Some(p),
503 system: None,
504 }
505 }
506
507 #[test]
508 fn empty_paths_returns_default() {
509 let resolved = resolve("anyhost", &SshConfigPaths::none()).expect("resolve with no files");
510 assert_eq!(resolved.hostname, None);
511 assert!(resolved.identity_files.is_empty());
512 assert!(resolved.provenance.is_empty());
513 }
514
515 #[test]
516 fn missing_file_is_silently_ignored() {
517 let paths = SshConfigPaths {
518 user: Some(PathBuf::from("/this/path/definitely/does/not/exist")),
519 system: None,
520 };
521 let resolved = resolve("anyhost", &paths).expect("resolve");
522 assert_eq!(resolved.hostname, None);
523 }
524
525 #[test]
526 fn resolves_basic_block() {
527 let (_g, conf) = write_config("Host gh\n HostName github.com\n User git\n Port 2222\n");
528 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
529 assert_eq!(resolved.hostname.as_deref(), Some("github.com"));
530 assert_eq!(resolved.user.as_deref(), Some("git"));
531 assert_eq!(resolved.port, Some(2222));
532 assert_eq!(resolved.provenance.len(), 3);
533 }
534
535 #[test]
536 fn first_occurrence_wins_for_single_valued_fields() {
537 let (_g, conf) = write_config(
540 "Host gh\n HostName specific.example.com\nHost *\n HostName fallback.example.com\n",
541 );
542 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
543 assert_eq!(resolved.hostname.as_deref(), Some("specific.example.com"));
544 }
545
546 #[test]
547 fn multiple_identity_files_accumulate() {
548 let (_g, conf) =
549 write_config("Host gh\n IdentityFile ~/.ssh/id_a\n IdentityFile ~/.ssh/id_b\n");
550 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
551 assert_eq!(resolved.identity_files.len(), 2);
552 assert!(!resolved.identity_files[0]
554 .to_string_lossy()
555 .starts_with('~'));
556 }
557
558 #[test]
559 fn identityfile_one_line_multiple_args_accumulates() {
560 let (_g, conf) = write_config("Host gh\n IdentityFile a b c\n");
562 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
563 assert_eq!(resolved.identity_files.len(), 3);
564 }
565
566 #[test]
567 fn invalid_port_errors() {
568 let (_g, conf) = write_config("Host gh\n Port not_a_number\n");
569 let err = resolve("gh", &paths_user_only(conf)).expect_err("invalid Port");
570 let msg = format!("{err}");
571 assert!(msg.contains("invalid Port"), "got: {msg}");
572 }
573
574 #[test]
575 fn strict_host_key_checking_variants() {
576 let cases = &[
577 ("yes", StrictHostKeyChecking::Yes),
578 ("ask", StrictHostKeyChecking::Yes), ("no", StrictHostKeyChecking::No),
580 ("off", StrictHostKeyChecking::No),
581 ("accept-new", StrictHostKeyChecking::AcceptNew),
582 ];
583 for (raw, expected) in cases {
584 let (_g, conf) = write_config(&format!("Host gh\n StrictHostKeyChecking {raw}\n"));
585 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
586 assert_eq!(
587 resolved.strict_host_key_checking,
588 Some(*expected),
589 "case `{raw}`",
590 );
591 }
592 }
593
594 #[test]
595 fn algorithm_directives_captured_raw() {
596 let (_g, conf) = write_config(
597 "Host gh\n HostKeyAlgorithms ssh-ed25519,rsa-sha2-512\n KexAlgorithms curve25519-sha256\n",
598 );
599 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
600 assert_eq!(
601 resolved.host_key_algorithms,
602 Some(AlgList("ssh-ed25519,rsa-sha2-512".to_owned())),
603 );
604 assert_eq!(
605 resolved.kex_algorithms,
606 Some(AlgList("curve25519-sha256".to_owned())),
607 );
608 }
609
610 #[test]
611 fn connect_timeout_parses_to_duration() {
612 let (_g, conf) = write_config("Host gh\n ConnectTimeout 30\n");
613 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
614 assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(30)));
615 }
616
617 #[test]
618 fn connection_attempts_parses() {
619 let (_g, conf) = write_config("Host gh\n ConnectionAttempts 5\n");
620 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
621 assert_eq!(resolved.connection_attempts, Some(5));
622 }
623
624 #[test]
625 fn proxy_command_joined_with_spaces() {
626 let (_g, conf) = write_config("Host gh\n ProxyCommand ssh -W %h:%p bastion\n");
627 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
628 assert_eq!(
630 resolved.proxy_command.as_deref(),
631 Some("ssh -W %h:%p bastion"),
632 );
633 }
634
635 #[test]
636 fn proxy_jump_captured() {
637 let (_g, conf) = write_config("Host gh\n ProxyJump bastion.example.com\n");
638 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
639 assert_eq!(resolved.proxy_jump.as_deref(), Some("bastion.example.com"),);
640 }
641
642 #[test]
643 fn proxy_command_none_preserved_as_literal() {
644 let (_g, conf) = write_config("Host gh\n ProxyCommand none\n");
649 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
650 assert_eq!(resolved.proxy_command.as_deref(), Some("none"));
651 }
652
653 #[test]
654 fn proxy_command_none_case_insensitive() {
655 for raw in ["NONE", "None", "nOnE"] {
657 let (_g, conf) = write_config(&format!("Host gh\n ProxyCommand {raw}\n"));
658 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
659 assert_eq!(
660 resolved.proxy_command.as_deref(),
661 Some("none"),
662 "case `{raw}`: should normalize to lowercase `none`",
663 );
664 }
665 }
666
667 #[test]
668 fn proxy_command_none_overrides_later_wildcard() {
669 let (_g, conf) =
675 write_config("Host gh\n ProxyCommand none\nHost *\n ProxyCommand /usr/bin/false\n");
676 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
677 assert_eq!(resolved.proxy_command.as_deref(), Some("none"));
678 }
679
680 #[test]
681 fn proxy_command_with_word_none_in_middle_not_treated_as_disable() {
682 let (_g, conf) = write_config("Host gh\n ProxyCommand none-yet-a-real-cmd %h\n");
686 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
687 assert_eq!(
688 resolved.proxy_command.as_deref(),
689 Some("none-yet-a-real-cmd %h"),
690 );
691 }
692
693 #[test]
694 fn user_known_hosts_files_accumulate() {
695 let (_g, conf) = write_config(
696 "Host gh\n UserKnownHostsFile /etc/known\n UserKnownHostsFile /home/u/known\n",
697 );
698 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
699 assert_eq!(resolved.user_known_hosts_files.len(), 2);
700 }
701
702 #[test]
703 fn user_known_hosts_files_one_line_multi_args() {
704 let (_g, conf) = write_config("Host gh\n UserKnownHostsFile /a /b /c\n");
705 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
706 assert_eq!(resolved.user_known_hosts_files.len(), 3);
707 }
708
709 #[test]
710 fn unknown_directives_ignored() {
711 let (_g, conf) = write_config("Host gh\n ServerAliveInterval 60\n User git\n");
712 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
713 assert_eq!(resolved.user.as_deref(), Some("git"));
715 assert_eq!(resolved.provenance.len(), 1);
716 }
717
718 #[test]
719 fn provenance_records_file_and_line() {
720 let (_g, conf) = write_config("# header\nHost gh\n User git\n");
721 let resolved = resolve("gh", &paths_user_only(conf.clone())).expect("resolve");
722 assert_eq!(resolved.provenance.len(), 1);
723 let prov = &resolved.provenance[0];
724 assert_eq!(prov.directive, "user");
725 assert_eq!(prov.line, 3);
726 let prov_canon = prov.file.canonicalize().unwrap_or(prov.file.clone());
729 let conf_canon = conf.canonicalize().unwrap_or(conf);
730 assert_eq!(prov_canon, conf_canon);
731 }
732
733 #[test]
734 fn user_then_system_first_wins() {
735 let dir = tempdir().expect("tempdir");
736 let user_path = dir.path().join("user_config");
737 let sys_path = dir.path().join("sys_config");
738 fs::write(&user_path, "Host gh\n User from_user\n").expect("write user");
739 fs::write(&sys_path, "Host gh\n User from_system\n").expect("write sys");
740
741 let paths = SshConfigPaths {
742 user: Some(user_path),
743 system: Some(sys_path),
744 };
745 let resolved = resolve("gh", &paths).expect("resolve");
746 assert_eq!(resolved.user.as_deref(), Some("from_user"));
747 }
748
749 #[test]
750 fn no_match_yields_empty_resolved() {
751 let (_g, conf) = write_config("Host other\n User unrelated\n");
752 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
753 assert_eq!(resolved.user, None);
754 assert!(resolved.provenance.is_empty());
755 }
756}