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>,
147 pub proxy_jump: Option<String>,
149 pub user_known_hosts_files: Vec<PathBuf>,
151 pub strict_host_key_checking: Option<StrictHostKeyChecking>,
153 pub host_key_algorithms: Option<AlgList>,
155 pub kex_algorithms: Option<AlgList>,
157 pub ciphers: Option<AlgList>,
159 pub macs: Option<AlgList>,
161 pub connect_timeout: Option<Duration>,
164 pub connection_attempts: Option<u32>,
166 pub provenance: Vec<DirectiveSource>,
170}
171
172pub fn resolve(host: &str, paths: &SshConfigPaths) -> Result<ResolvedSshConfig, AnvilError> {
192 let mut all_blocks: Vec<HostBlock> = Vec::new();
193
194 if let Some(user) = &paths.user {
195 let path = expand_path_for_read(user);
196 all_blocks.extend(read_and_parse(&path)?);
197 }
198 if let Some(system) = &paths.system {
199 let path = expand_path_for_read(system);
200 all_blocks.extend(read_and_parse(&path)?);
201 }
202
203 let mut resolved = ResolvedSshConfig::default();
204 if all_blocks.is_empty() {
205 return Ok(resolved);
206 }
207
208 for d in directives_for_host(&all_blocks, host) {
209 apply_directive(d, &mut resolved)?;
210 }
211
212 Ok(resolved)
213}
214
215fn expand_path_for_read(path: &Path) -> PathBuf {
217 let s = path.to_string_lossy();
218 PathBuf::from(expand_tilde(&s))
219}
220
221fn read_and_parse(path: &Path) -> Result<Vec<HostBlock>, AnvilError> {
224 let content = match std::fs::read_to_string(path) {
225 Ok(c) => c,
226 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
227 Err(e) => {
228 return Err(AnvilError::invalid_config(format!(
229 "ssh_config: failed to read {}: {e}",
230 path.display(),
231 )));
232 }
233 };
234 let tokens = tokenize(&content, path)?;
235 let expanded = expand_includes(path, tokens)?;
236 parse(expanded)
237}
238
239#[allow(
242 clippy::too_many_lines,
243 reason = "directive dispatch is intentionally one big match for clarity \
244 and easy review; each arm is a few lines and there is no \
245 meaningful sub-grouping"
246)]
247fn apply_directive(d: &Directive, resolved: &mut ResolvedSshConfig) -> Result<(), AnvilError> {
248 let mut recorded = true;
249
250 match d.keyword.as_str() {
251 "hostname" => {
252 if resolved.hostname.is_none() {
253 resolved.hostname = Some(first_arg_required(d)?);
254 }
255 }
256 "user" => {
257 if resolved.user.is_none() {
258 resolved.user = Some(first_arg_required(d)?);
259 }
260 }
261 "port" => {
262 if resolved.port.is_none() {
263 let s = first_arg_required(d)?;
264 resolved.port = Some(s.parse::<u16>().map_err(|e| {
265 AnvilError::invalid_config(format!(
266 "ssh_config: invalid Port '{s}' at {}:{}: {e}",
267 d.file.display(),
268 d.line_no,
269 ))
270 })?);
271 }
272 }
273 "identityfile" => {
274 require_at_least_one(d)?;
275 for arg in &d.args {
276 resolved.identity_files.push(expand_path_value(arg));
277 }
278 }
279 "identitiesonly" => {
280 if resolved.identities_only.is_none() {
281 resolved.identities_only = Some(parse_yes_no(d)?);
282 }
283 }
284 "identityagent" => {
285 if resolved.identity_agent.is_none() {
286 let s = first_arg_required(d)?;
287 resolved.identity_agent = Some(expand_path_value(&s));
288 }
289 }
290 "certificatefile" => {
291 require_at_least_one(d)?;
292 for arg in &d.args {
293 resolved.certificate_files.push(expand_path_value(arg));
294 }
295 }
296 "proxycommand" => {
297 if resolved.proxy_command.is_none() {
298 if d.args.is_empty() {
299 return Err(missing_value_err(d));
300 }
301 resolved.proxy_command = Some(d.args.join(" "));
304 }
305 }
306 "proxyjump" => {
307 if resolved.proxy_jump.is_none() {
308 resolved.proxy_jump = Some(first_arg_required(d)?);
309 }
310 }
311 "userknownhostsfile" => {
312 require_at_least_one(d)?;
313 for arg in &d.args {
314 resolved.user_known_hosts_files.push(expand_path_value(arg));
315 }
316 }
317 "stricthostkeychecking" => {
318 if resolved.strict_host_key_checking.is_none() {
319 let s = first_arg_required(d)?;
320 let v = match s.to_ascii_lowercase().as_str() {
321 "yes" | "ask" => StrictHostKeyChecking::Yes,
324 "no" | "off" => StrictHostKeyChecking::No,
325 "accept-new" => StrictHostKeyChecking::AcceptNew,
326 other => {
327 return Err(AnvilError::invalid_config(format!(
328 "ssh_config: invalid StrictHostKeyChecking '{other}' at {}:{}",
329 d.file.display(),
330 d.line_no,
331 )));
332 }
333 };
334 resolved.strict_host_key_checking = Some(v);
335 }
336 }
337 "hostkeyalgorithms" => {
338 if resolved.host_key_algorithms.is_none() {
339 resolved.host_key_algorithms = Some(AlgList(first_arg_required(d)?));
340 }
341 }
342 "kexalgorithms" => {
343 if resolved.kex_algorithms.is_none() {
344 resolved.kex_algorithms = Some(AlgList(first_arg_required(d)?));
345 }
346 }
347 "ciphers" => {
348 if resolved.ciphers.is_none() {
349 resolved.ciphers = Some(AlgList(first_arg_required(d)?));
350 }
351 }
352 "macs" => {
353 if resolved.macs.is_none() {
354 resolved.macs = Some(AlgList(first_arg_required(d)?));
355 }
356 }
357 "connecttimeout" => {
358 if resolved.connect_timeout.is_none() {
359 let s = first_arg_required(d)?;
360 let secs: u64 = s.parse().map_err(|e| {
361 AnvilError::invalid_config(format!(
362 "ssh_config: invalid ConnectTimeout '{s}' at {}:{}: {e}",
363 d.file.display(),
364 d.line_no,
365 ))
366 })?;
367 resolved.connect_timeout = Some(Duration::from_secs(secs));
368 }
369 }
370 "connectionattempts" => {
371 if resolved.connection_attempts.is_none() {
372 let s = first_arg_required(d)?;
373 resolved.connection_attempts = Some(s.parse::<u32>().map_err(|e| {
374 AnvilError::invalid_config(format!(
375 "ssh_config: invalid ConnectionAttempts '{s}' at {}:{}: {e}",
376 d.file.display(),
377 d.line_no,
378 ))
379 })?);
380 }
381 }
382 _ => {
383 log::trace!(
387 "ssh_config: ignoring unhandled directive '{}' at {}:{}",
388 d.keyword,
389 d.file.display(),
390 d.line_no,
391 );
392 recorded = false;
393 }
394 }
395
396 if recorded {
397 resolved.provenance.push(DirectiveSource {
398 directive: d.keyword.clone(),
399 file: d.file.clone(),
400 line: d.line_no,
401 });
402 }
403
404 Ok(())
405}
406
407fn first_arg_required(d: &Directive) -> Result<String, AnvilError> {
408 d.args.first().cloned().ok_or_else(|| missing_value_err(d))
409}
410
411fn require_at_least_one(d: &Directive) -> Result<(), AnvilError> {
412 if d.args.is_empty() {
413 Err(missing_value_err(d))
414 } else {
415 Ok(())
416 }
417}
418
419fn missing_value_err(d: &Directive) -> AnvilError {
420 AnvilError::invalid_config(format!(
421 "ssh_config: directive '{}' at {}:{} has no value",
422 d.keyword,
423 d.file.display(),
424 d.line_no,
425 ))
426}
427
428fn parse_yes_no(d: &Directive) -> Result<bool, AnvilError> {
429 let s = first_arg_required(d)?;
430 match s.to_ascii_lowercase().as_str() {
431 "yes" | "true" => Ok(true),
432 "no" | "false" => Ok(false),
433 other => Err(AnvilError::invalid_config(format!(
434 "ssh_config: expected yes/no for '{}' at {}:{}, got '{other}'",
435 d.keyword,
436 d.file.display(),
437 d.line_no,
438 ))),
439 }
440}
441
442fn expand_path_value(value: &str) -> PathBuf {
444 PathBuf::from(expand_tilde(&expand_env(value)))
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use std::fs;
451 use tempfile::tempdir;
452
453 fn write_config(content: &str) -> (tempfile::TempDir, PathBuf) {
456 let dir = tempdir().expect("tempdir");
457 let path = dir.path().join("config");
458 fs::write(&path, content).expect("write config");
459 (dir, path)
460 }
461
462 fn paths_user_only(p: PathBuf) -> SshConfigPaths {
463 SshConfigPaths {
464 user: Some(p),
465 system: None,
466 }
467 }
468
469 #[test]
470 fn empty_paths_returns_default() {
471 let resolved = resolve("anyhost", &SshConfigPaths::none()).expect("resolve with no files");
472 assert_eq!(resolved.hostname, None);
473 assert!(resolved.identity_files.is_empty());
474 assert!(resolved.provenance.is_empty());
475 }
476
477 #[test]
478 fn missing_file_is_silently_ignored() {
479 let paths = SshConfigPaths {
480 user: Some(PathBuf::from("/this/path/definitely/does/not/exist")),
481 system: None,
482 };
483 let resolved = resolve("anyhost", &paths).expect("resolve");
484 assert_eq!(resolved.hostname, None);
485 }
486
487 #[test]
488 fn resolves_basic_block() {
489 let (_g, conf) = write_config("Host gh\n HostName github.com\n User git\n Port 2222\n");
490 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
491 assert_eq!(resolved.hostname.as_deref(), Some("github.com"));
492 assert_eq!(resolved.user.as_deref(), Some("git"));
493 assert_eq!(resolved.port, Some(2222));
494 assert_eq!(resolved.provenance.len(), 3);
495 }
496
497 #[test]
498 fn first_occurrence_wins_for_single_valued_fields() {
499 let (_g, conf) = write_config(
502 "Host gh\n HostName specific.example.com\nHost *\n HostName fallback.example.com\n",
503 );
504 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
505 assert_eq!(resolved.hostname.as_deref(), Some("specific.example.com"));
506 }
507
508 #[test]
509 fn multiple_identity_files_accumulate() {
510 let (_g, conf) =
511 write_config("Host gh\n IdentityFile ~/.ssh/id_a\n IdentityFile ~/.ssh/id_b\n");
512 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
513 assert_eq!(resolved.identity_files.len(), 2);
514 assert!(!resolved.identity_files[0]
516 .to_string_lossy()
517 .starts_with('~'));
518 }
519
520 #[test]
521 fn identityfile_one_line_multiple_args_accumulates() {
522 let (_g, conf) = write_config("Host gh\n IdentityFile a b c\n");
524 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
525 assert_eq!(resolved.identity_files.len(), 3);
526 }
527
528 #[test]
529 fn invalid_port_errors() {
530 let (_g, conf) = write_config("Host gh\n Port not_a_number\n");
531 let err = resolve("gh", &paths_user_only(conf)).expect_err("invalid Port");
532 let msg = format!("{err}");
533 assert!(msg.contains("invalid Port"), "got: {msg}");
534 }
535
536 #[test]
537 fn strict_host_key_checking_variants() {
538 let cases = &[
539 ("yes", StrictHostKeyChecking::Yes),
540 ("ask", StrictHostKeyChecking::Yes), ("no", StrictHostKeyChecking::No),
542 ("off", StrictHostKeyChecking::No),
543 ("accept-new", StrictHostKeyChecking::AcceptNew),
544 ];
545 for (raw, expected) in cases {
546 let (_g, conf) = write_config(&format!("Host gh\n StrictHostKeyChecking {raw}\n"));
547 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
548 assert_eq!(
549 resolved.strict_host_key_checking,
550 Some(*expected),
551 "case `{raw}`",
552 );
553 }
554 }
555
556 #[test]
557 fn algorithm_directives_captured_raw() {
558 let (_g, conf) = write_config(
559 "Host gh\n HostKeyAlgorithms ssh-ed25519,rsa-sha2-512\n KexAlgorithms curve25519-sha256\n",
560 );
561 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
562 assert_eq!(
563 resolved.host_key_algorithms,
564 Some(AlgList("ssh-ed25519,rsa-sha2-512".to_owned())),
565 );
566 assert_eq!(
567 resolved.kex_algorithms,
568 Some(AlgList("curve25519-sha256".to_owned())),
569 );
570 }
571
572 #[test]
573 fn connect_timeout_parses_to_duration() {
574 let (_g, conf) = write_config("Host gh\n ConnectTimeout 30\n");
575 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
576 assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(30)));
577 }
578
579 #[test]
580 fn connection_attempts_parses() {
581 let (_g, conf) = write_config("Host gh\n ConnectionAttempts 5\n");
582 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
583 assert_eq!(resolved.connection_attempts, Some(5));
584 }
585
586 #[test]
587 fn proxy_command_joined_with_spaces() {
588 let (_g, conf) = write_config("Host gh\n ProxyCommand ssh -W %h:%p bastion\n");
589 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
590 assert_eq!(
592 resolved.proxy_command.as_deref(),
593 Some("ssh -W %h:%p bastion"),
594 );
595 }
596
597 #[test]
598 fn proxy_jump_captured() {
599 let (_g, conf) = write_config("Host gh\n ProxyJump bastion.example.com\n");
600 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
601 assert_eq!(resolved.proxy_jump.as_deref(), Some("bastion.example.com"),);
602 }
603
604 #[test]
605 fn user_known_hosts_files_accumulate() {
606 let (_g, conf) = write_config(
607 "Host gh\n UserKnownHostsFile /etc/known\n UserKnownHostsFile /home/u/known\n",
608 );
609 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
610 assert_eq!(resolved.user_known_hosts_files.len(), 2);
611 }
612
613 #[test]
614 fn user_known_hosts_files_one_line_multi_args() {
615 let (_g, conf) = write_config("Host gh\n UserKnownHostsFile /a /b /c\n");
616 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
617 assert_eq!(resolved.user_known_hosts_files.len(), 3);
618 }
619
620 #[test]
621 fn unknown_directives_ignored() {
622 let (_g, conf) = write_config("Host gh\n ServerAliveInterval 60\n User git\n");
623 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
624 assert_eq!(resolved.user.as_deref(), Some("git"));
626 assert_eq!(resolved.provenance.len(), 1);
627 }
628
629 #[test]
630 fn provenance_records_file_and_line() {
631 let (_g, conf) = write_config("# header\nHost gh\n User git\n");
632 let resolved = resolve("gh", &paths_user_only(conf.clone())).expect("resolve");
633 assert_eq!(resolved.provenance.len(), 1);
634 let prov = &resolved.provenance[0];
635 assert_eq!(prov.directive, "user");
636 assert_eq!(prov.line, 3);
637 let prov_canon = prov.file.canonicalize().unwrap_or(prov.file.clone());
640 let conf_canon = conf.canonicalize().unwrap_or(conf);
641 assert_eq!(prov_canon, conf_canon);
642 }
643
644 #[test]
645 fn user_then_system_first_wins() {
646 let dir = tempdir().expect("tempdir");
647 let user_path = dir.path().join("user_config");
648 let sys_path = dir.path().join("sys_config");
649 fs::write(&user_path, "Host gh\n User from_user\n").expect("write user");
650 fs::write(&sys_path, "Host gh\n User from_system\n").expect("write sys");
651
652 let paths = SshConfigPaths {
653 user: Some(user_path),
654 system: Some(sys_path),
655 };
656 let resolved = resolve("gh", &paths).expect("resolve");
657 assert_eq!(resolved.user.as_deref(), Some("from_user"));
658 }
659
660 #[test]
661 fn no_match_yields_empty_resolved() {
662 let (_g, conf) = write_config("Host other\n User unrelated\n");
663 let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
664 assert_eq!(resolved.user, None);
665 assert!(resolved.provenance.is_empty());
666 }
667}