use std::path::{Path, PathBuf};
use std::time::Duration;
use super::include::expand_includes;
use super::lexer::{expand_env, expand_tilde, tokenize};
use super::matcher::directives_for_host;
use super::parser::{parse, Directive, HostBlock};
use crate::error::AnvilError;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SshConfigPaths {
pub user: Option<PathBuf>,
pub system: Option<PathBuf>,
}
impl SshConfigPaths {
#[must_use]
pub fn default_paths() -> Self {
let user = dirs::home_dir().map(|h| h.join(".ssh").join("config"));
let system = if cfg!(unix) {
Some(PathBuf::from("/etc/ssh/ssh_config"))
} else if cfg!(windows) {
std::env::var_os("ProgramData").map(|pd| {
let mut p = PathBuf::from(pd);
p.push("ssh");
p.push("ssh_config");
p
})
} else {
None
};
Self { user, system }
}
#[must_use]
pub fn none() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StrictHostKeyChecking {
Yes,
No,
AcceptNew,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlgList(pub String);
#[derive(Debug, Clone)]
pub struct DirectiveSource {
pub directive: String,
pub file: PathBuf,
pub line: u32,
}
#[derive(Debug, Clone, Default)]
pub struct ResolvedSshConfig {
pub hostname: Option<String>,
pub user: Option<String>,
pub port: Option<u16>,
pub identity_files: Vec<PathBuf>,
pub identities_only: Option<bool>,
pub identity_agent: Option<PathBuf>,
pub certificate_files: Vec<PathBuf>,
pub proxy_command: Option<String>,
pub proxy_jump: Option<String>,
pub user_known_hosts_files: Vec<PathBuf>,
pub strict_host_key_checking: Option<StrictHostKeyChecking>,
pub host_key_algorithms: Option<AlgList>,
pub kex_algorithms: Option<AlgList>,
pub ciphers: Option<AlgList>,
pub macs: Option<AlgList>,
pub connect_timeout: Option<Duration>,
pub connection_attempts: Option<u32>,
pub provenance: Vec<DirectiveSource>,
}
pub fn resolve(host: &str, paths: &SshConfigPaths) -> Result<ResolvedSshConfig, AnvilError> {
let mut all_blocks: Vec<HostBlock> = Vec::new();
if let Some(user) = &paths.user {
let path = expand_path_for_read(user);
all_blocks.extend(read_and_parse(&path)?);
}
if let Some(system) = &paths.system {
let path = expand_path_for_read(system);
all_blocks.extend(read_and_parse(&path)?);
}
let mut resolved = ResolvedSshConfig::default();
if all_blocks.is_empty() {
return Ok(resolved);
}
for d in directives_for_host(&all_blocks, host) {
apply_directive(d, &mut resolved)?;
}
Ok(resolved)
}
fn expand_path_for_read(path: &Path) -> PathBuf {
let s = path.to_string_lossy();
PathBuf::from(expand_tilde(&s))
}
fn read_and_parse(path: &Path) -> Result<Vec<HostBlock>, AnvilError> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(AnvilError::invalid_config(format!(
"ssh_config: failed to read {}: {e}",
path.display(),
)));
}
};
let tokens = tokenize(&content, path)?;
let expanded = expand_includes(path, tokens)?;
parse(expanded)
}
#[allow(
clippy::too_many_lines,
reason = "directive dispatch is intentionally one big match for clarity \
and easy review; each arm is a few lines and there is no \
meaningful sub-grouping"
)]
fn apply_directive(d: &Directive, resolved: &mut ResolvedSshConfig) -> Result<(), AnvilError> {
let mut recorded = true;
match d.keyword.as_str() {
"hostname" => {
if resolved.hostname.is_none() {
resolved.hostname = Some(first_arg_required(d)?);
}
}
"user" => {
if resolved.user.is_none() {
resolved.user = Some(first_arg_required(d)?);
}
}
"port" => {
if resolved.port.is_none() {
let s = first_arg_required(d)?;
resolved.port = Some(s.parse::<u16>().map_err(|e| {
AnvilError::invalid_config(format!(
"ssh_config: invalid Port '{s}' at {}:{}: {e}",
d.file.display(),
d.line_no,
))
})?);
}
}
"identityfile" => {
require_at_least_one(d)?;
for arg in &d.args {
resolved.identity_files.push(expand_path_value(arg));
}
}
"identitiesonly" => {
if resolved.identities_only.is_none() {
resolved.identities_only = Some(parse_yes_no(d)?);
}
}
"identityagent" => {
if resolved.identity_agent.is_none() {
let s = first_arg_required(d)?;
resolved.identity_agent = Some(expand_path_value(&s));
}
}
"certificatefile" => {
require_at_least_one(d)?;
for arg in &d.args {
resolved.certificate_files.push(expand_path_value(arg));
}
}
"proxycommand" => {
if resolved.proxy_command.is_none() {
if d.args.is_empty() {
return Err(missing_value_err(d));
}
let value = if d.args.len() == 1 && d.args[0].eq_ignore_ascii_case("none") {
"none".to_owned()
} else {
d.args.join(" ")
};
resolved.proxy_command = Some(value);
}
}
"proxyjump" => {
if resolved.proxy_jump.is_none() {
resolved.proxy_jump = Some(first_arg_required(d)?);
}
}
"userknownhostsfile" => {
require_at_least_one(d)?;
for arg in &d.args {
resolved.user_known_hosts_files.push(expand_path_value(arg));
}
}
"stricthostkeychecking" => {
if resolved.strict_host_key_checking.is_none() {
let s = first_arg_required(d)?;
let v = match s.to_ascii_lowercase().as_str() {
"yes" | "ask" => StrictHostKeyChecking::Yes,
"no" | "off" => StrictHostKeyChecking::No,
"accept-new" => StrictHostKeyChecking::AcceptNew,
other => {
return Err(AnvilError::invalid_config(format!(
"ssh_config: invalid StrictHostKeyChecking '{other}' at {}:{}",
d.file.display(),
d.line_no,
)));
}
};
resolved.strict_host_key_checking = Some(v);
}
}
"hostkeyalgorithms" => {
if resolved.host_key_algorithms.is_none() {
resolved.host_key_algorithms = Some(AlgList(first_arg_required(d)?));
}
}
"kexalgorithms" => {
if resolved.kex_algorithms.is_none() {
resolved.kex_algorithms = Some(AlgList(first_arg_required(d)?));
}
}
"ciphers" => {
if resolved.ciphers.is_none() {
resolved.ciphers = Some(AlgList(first_arg_required(d)?));
}
}
"macs" => {
if resolved.macs.is_none() {
resolved.macs = Some(AlgList(first_arg_required(d)?));
}
}
"connecttimeout" => {
if resolved.connect_timeout.is_none() {
let s = first_arg_required(d)?;
let secs: u64 = s.parse().map_err(|e| {
AnvilError::invalid_config(format!(
"ssh_config: invalid ConnectTimeout '{s}' at {}:{}: {e}",
d.file.display(),
d.line_no,
))
})?;
resolved.connect_timeout = Some(Duration::from_secs(secs));
}
}
"connectionattempts" => {
if resolved.connection_attempts.is_none() {
let s = first_arg_required(d)?;
resolved.connection_attempts = Some(s.parse::<u32>().map_err(|e| {
AnvilError::invalid_config(format!(
"ssh_config: invalid ConnectionAttempts '{s}' at {}:{}: {e}",
d.file.display(),
d.line_no,
))
})?);
}
}
_ => {
log::trace!(
"ssh_config: ignoring unhandled directive '{}' at {}:{}",
d.keyword,
d.file.display(),
d.line_no,
);
recorded = false;
}
}
if recorded {
tracing::trace!(
target: crate::log::CAT_CONFIG,
file = %d.file.display(),
line = d.line_no,
directive = %d.keyword,
value = %d.args.join(" "),
"ssh_config directive applied",
);
resolved.provenance.push(DirectiveSource {
directive: d.keyword.clone(),
file: d.file.clone(),
line: d.line_no,
});
}
Ok(())
}
fn first_arg_required(d: &Directive) -> Result<String, AnvilError> {
d.args.first().cloned().ok_or_else(|| missing_value_err(d))
}
fn require_at_least_one(d: &Directive) -> Result<(), AnvilError> {
if d.args.is_empty() {
Err(missing_value_err(d))
} else {
Ok(())
}
}
fn missing_value_err(d: &Directive) -> AnvilError {
AnvilError::invalid_config(format!(
"ssh_config: directive '{}' at {}:{} has no value",
d.keyword,
d.file.display(),
d.line_no,
))
}
fn parse_yes_no(d: &Directive) -> Result<bool, AnvilError> {
let s = first_arg_required(d)?;
match s.to_ascii_lowercase().as_str() {
"yes" | "true" => Ok(true),
"no" | "false" => Ok(false),
other => Err(AnvilError::invalid_config(format!(
"ssh_config: expected yes/no for '{}' at {}:{}, got '{other}'",
d.keyword,
d.file.display(),
d.line_no,
))),
}
}
fn expand_path_value(value: &str) -> PathBuf {
PathBuf::from(expand_tilde(&expand_env(value)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write_config(content: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("config");
fs::write(&path, content).expect("write config");
(dir, path)
}
fn paths_user_only(p: PathBuf) -> SshConfigPaths {
SshConfigPaths {
user: Some(p),
system: None,
}
}
#[test]
fn empty_paths_returns_default() {
let resolved = resolve("anyhost", &SshConfigPaths::none()).expect("resolve with no files");
assert_eq!(resolved.hostname, None);
assert!(resolved.identity_files.is_empty());
assert!(resolved.provenance.is_empty());
}
#[test]
fn missing_file_is_silently_ignored() {
let paths = SshConfigPaths {
user: Some(PathBuf::from("/this/path/definitely/does/not/exist")),
system: None,
};
let resolved = resolve("anyhost", &paths).expect("resolve");
assert_eq!(resolved.hostname, None);
}
#[test]
fn resolves_basic_block() {
let (_g, conf) = write_config("Host gh\n HostName github.com\n User git\n Port 2222\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.hostname.as_deref(), Some("github.com"));
assert_eq!(resolved.user.as_deref(), Some("git"));
assert_eq!(resolved.port, Some(2222));
assert_eq!(resolved.provenance.len(), 3);
}
#[test]
fn first_occurrence_wins_for_single_valued_fields() {
let (_g, conf) = write_config(
"Host gh\n HostName specific.example.com\nHost *\n HostName fallback.example.com\n",
);
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.hostname.as_deref(), Some("specific.example.com"));
}
#[test]
fn multiple_identity_files_accumulate() {
let (_g, conf) =
write_config("Host gh\n IdentityFile ~/.ssh/id_a\n IdentityFile ~/.ssh/id_b\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.identity_files.len(), 2);
assert!(!resolved.identity_files[0]
.to_string_lossy()
.starts_with('~'));
}
#[test]
fn identityfile_one_line_multiple_args_accumulates() {
let (_g, conf) = write_config("Host gh\n IdentityFile a b c\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.identity_files.len(), 3);
}
#[test]
fn invalid_port_errors() {
let (_g, conf) = write_config("Host gh\n Port not_a_number\n");
let err = resolve("gh", &paths_user_only(conf)).expect_err("invalid Port");
let msg = format!("{err}");
assert!(msg.contains("invalid Port"), "got: {msg}");
}
#[test]
fn strict_host_key_checking_variants() {
let cases = &[
("yes", StrictHostKeyChecking::Yes),
("ask", StrictHostKeyChecking::Yes), ("no", StrictHostKeyChecking::No),
("off", StrictHostKeyChecking::No),
("accept-new", StrictHostKeyChecking::AcceptNew),
];
for (raw, expected) in cases {
let (_g, conf) = write_config(&format!("Host gh\n StrictHostKeyChecking {raw}\n"));
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(
resolved.strict_host_key_checking,
Some(*expected),
"case `{raw}`",
);
}
}
#[test]
fn algorithm_directives_captured_raw() {
let (_g, conf) = write_config(
"Host gh\n HostKeyAlgorithms ssh-ed25519,rsa-sha2-512\n KexAlgorithms curve25519-sha256\n",
);
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(
resolved.host_key_algorithms,
Some(AlgList("ssh-ed25519,rsa-sha2-512".to_owned())),
);
assert_eq!(
resolved.kex_algorithms,
Some(AlgList("curve25519-sha256".to_owned())),
);
}
#[test]
fn connect_timeout_parses_to_duration() {
let (_g, conf) = write_config("Host gh\n ConnectTimeout 30\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(30)));
}
#[test]
fn connection_attempts_parses() {
let (_g, conf) = write_config("Host gh\n ConnectionAttempts 5\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.connection_attempts, Some(5));
}
#[test]
fn proxy_command_joined_with_spaces() {
let (_g, conf) = write_config("Host gh\n ProxyCommand ssh -W %h:%p bastion\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(
resolved.proxy_command.as_deref(),
Some("ssh -W %h:%p bastion"),
);
}
#[test]
fn proxy_jump_captured() {
let (_g, conf) = write_config("Host gh\n ProxyJump bastion.example.com\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.proxy_jump.as_deref(), Some("bastion.example.com"),);
}
#[test]
fn proxy_command_none_preserved_as_literal() {
let (_g, conf) = write_config("Host gh\n ProxyCommand none\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.proxy_command.as_deref(), Some("none"));
}
#[test]
fn proxy_command_none_case_insensitive() {
for raw in ["NONE", "None", "nOnE"] {
let (_g, conf) = write_config(&format!("Host gh\n ProxyCommand {raw}\n"));
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(
resolved.proxy_command.as_deref(),
Some("none"),
"case `{raw}`: should normalize to lowercase `none`",
);
}
}
#[test]
fn proxy_command_none_overrides_later_wildcard() {
let (_g, conf) =
write_config("Host gh\n ProxyCommand none\nHost *\n ProxyCommand /usr/bin/false\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.proxy_command.as_deref(), Some("none"));
}
#[test]
fn proxy_command_with_word_none_in_middle_not_treated_as_disable() {
let (_g, conf) = write_config("Host gh\n ProxyCommand none-yet-a-real-cmd %h\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(
resolved.proxy_command.as_deref(),
Some("none-yet-a-real-cmd %h"),
);
}
#[test]
fn user_known_hosts_files_accumulate() {
let (_g, conf) = write_config(
"Host gh\n UserKnownHostsFile /etc/known\n UserKnownHostsFile /home/u/known\n",
);
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.user_known_hosts_files.len(), 2);
}
#[test]
fn user_known_hosts_files_one_line_multi_args() {
let (_g, conf) = write_config("Host gh\n UserKnownHostsFile /a /b /c\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.user_known_hosts_files.len(), 3);
}
#[test]
fn unknown_directives_ignored() {
let (_g, conf) = write_config("Host gh\n ServerAliveInterval 60\n User git\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.user.as_deref(), Some("git"));
assert_eq!(resolved.provenance.len(), 1);
}
#[test]
fn provenance_records_file_and_line() {
let (_g, conf) = write_config("# header\nHost gh\n User git\n");
let resolved = resolve("gh", &paths_user_only(conf.clone())).expect("resolve");
assert_eq!(resolved.provenance.len(), 1);
let prov = &resolved.provenance[0];
assert_eq!(prov.directive, "user");
assert_eq!(prov.line, 3);
let prov_canon = prov.file.canonicalize().unwrap_or(prov.file.clone());
let conf_canon = conf.canonicalize().unwrap_or(conf);
assert_eq!(prov_canon, conf_canon);
}
#[test]
fn user_then_system_first_wins() {
let dir = tempdir().expect("tempdir");
let user_path = dir.path().join("user_config");
let sys_path = dir.path().join("sys_config");
fs::write(&user_path, "Host gh\n User from_user\n").expect("write user");
fs::write(&sys_path, "Host gh\n User from_system\n").expect("write sys");
let paths = SshConfigPaths {
user: Some(user_path),
system: Some(sys_path),
};
let resolved = resolve("gh", &paths).expect("resolve");
assert_eq!(resolved.user.as_deref(), Some("from_user"));
}
#[test]
fn no_match_yields_empty_resolved() {
let (_g, conf) = write_config("Host other\n User unrelated\n");
let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
assert_eq!(resolved.user, None);
assert!(resolved.provenance.is_empty());
}
}