use std::collections::HashSet;
use std::path::{Path, PathBuf};
use super::ast::{self, ConfigAst, ConfigNode};
use toride_ssh_core::Result;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ResolvedHost {
pub alias: String,
pub host_name: Option<String>,
pub user: Option<String>,
pub port: Option<u16>,
pub identity_files: Vec<String>,
pub certificate_files: Vec<String>,
pub proxy_jump: Option<String>,
pub identity_agent: Option<String>,
pub forward_agent: Option<String>,
pub add_keys_to_agent: Option<String>,
pub use_keychain: Option<String>,
pub control_master: Option<String>,
pub control_path: Option<String>,
pub control_persist: Option<String>,
pub local_forwards: Vec<String>,
pub remote_forwards: Vec<String>,
pub dynamic_forwards: Vec<String>,
pub directives: Vec<(String, String)>,
pub user_known_hosts_file: Option<String>,
pub identities_only: Option<bool>,
pub canonicalized: bool,
pub unevaluated_match_warnings: Vec<String>,
pub gssapi_authentication: Option<String>,
pub gssapi_delegate_credentials: Option<String>,
pub gssapi_server_identity: Option<String>,
pub gssapi_client_identity: Option<String>,
}
const TOKEN_EXPANDABLE: &[&str] = &[
"certificatefile",
"controlmaster",
"controlpath",
"controlpersist",
"dynamicforward",
"forwardagent",
"identityagent",
"knownhostscommand",
"localforward",
"remoteforward",
"revokedhostkeys",
"usekeychain",
"userknownhostsfile",
"proxycommand",
];
pub async fn resolve(ssh_dir: &Path, host: &str, user: Option<&str>) -> Result<ResolvedHost> {
let config_path = ssh_dir.join("config");
let mut visited = HashSet::new();
let flat_ast = load_and_flatten(&config_path, &mut visited).await?;
let local_user = user.map_or_else(whoami, str::to_owned);
let mut resolved = resolve_pass(&flat_ast, host, host, &local_user);
expand_resolved(&mut resolved, host, ssh_dir);
if is_canonicalize_enabled(&resolved) {
let canonical_host = resolved.host_name.take().unwrap_or_else(|| host.to_owned());
let mut canon = resolve_pass(&flat_ast, &canonical_host, host, &local_user);
expand_resolved(&mut canon, &canonical_host, ssh_dir);
host.clone_into(&mut canon.alias);
canon.canonicalized = true;
return Ok(canon);
}
Ok(resolved)
}
fn resolve_pass(
flat_ast: &ConfigAst,
target_host: &str,
original_host: &str,
local_user: &str,
) -> ResolvedHost {
let mut resolved = ResolvedHost {
alias: target_host.to_owned(),
host_name: None,
user: None,
port: None,
identity_files: Vec::new(),
certificate_files: Vec::new(),
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: Vec::new(),
remote_forwards: Vec::new(),
dynamic_forwards: Vec::new(),
directives: Vec::new(),
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: Vec::new(),
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
let mut seen_keys = HashSet::new();
for node in &flat_ast.nodes {
match node {
ConfigNode::HostBlock(b) => {
if host_matches(target_host, &b.patterns) {
resolve_block(&b.nodes, &mut resolved, &mut seen_keys);
}
}
ConfigNode::MatchBlock(b) => {
if contains_exec_criteria(&b.criteria) {
let warning = format!(
"Match block contains 'exec' criteria which are not evaluated: {}",
b.criteria,
);
tracing::warn!("{}", &warning);
resolved.unevaluated_match_warnings.push(warning);
}
if match_criteria_host(&b.criteria, target_host, local_user, original_host) {
resolve_block(&b.nodes, &mut resolved, &mut seen_keys);
}
}
_ => {}
}
}
resolved
}
fn load_and_flatten<'a>(
path: &'a Path,
visited: &'a mut HashSet<PathBuf>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ConfigAst>> + 'a>> {
Box::pin(async move {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_owned());
if visited.contains(&canonical) {
return Err(toride_ssh_core::Error::ConfigIncludeCycle(
canonical.display().to_string(),
));
}
visited.insert(canonical);
let content = if path.exists() {
tokio::fs::read_to_string(path).await?
} else {
return Ok(ConfigAst { nodes: Vec::new() });
};
let mut flat = ast::parse(&content);
let original_nodes = std::mem::take(&mut flat.nodes);
let mut new_nodes = Vec::with_capacity(original_nodes.len());
for node in original_nodes {
let pattern_value = match &node {
ConfigNode::Directive(d) if d.keyword.eq_ignore_ascii_case("include") => {
Some(d.value.clone())
}
_ => None,
};
if let Some(include_pattern) = pattern_value {
let expanded = expand_tilde_and_env(&include_pattern);
let base_dir = if Path::new(&expanded).is_absolute() {
PathBuf::new()
} else {
path.parent().unwrap_or_else(|| Path::new(".")).to_owned()
};
let full_pattern = base_dir.join(&expanded);
let pattern_str = full_pattern.display().to_string();
let matched_files = glob_paths(&pattern_str);
for inc_path in matched_files {
let included = load_and_flatten(&inc_path, visited).await?;
new_nodes.extend(included.nodes);
}
} else {
new_nodes.push(node);
}
}
flat.nodes = new_nodes;
Ok(flat)
})
}
fn expand_tilde_and_env(path: &str) -> String {
let mut result = path.to_owned();
if (result.starts_with("~/") || result == "~")
&& let Some(home) = dirs::home_dir()
{
let home_str = home.display().to_string();
result = result.replacen('~', &home_str, 1);
}
result = expand_env_vars(&result);
result
}
fn expand_env_vars(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.char_indices().peekable();
while let Some((i, ch)) = chars.next() {
if ch == '$' {
if let Some((_, '{')) = chars.peek() {
chars.next(); let start = i + 2;
if let Some(end_offset) = s[start..].find('}') {
let var_name = &s[start..start + end_offset];
result.push_str(&std::env::var(var_name).unwrap_or_default());
for _ in 0..=end_offset {
chars.next();
}
continue;
}
result.push(ch);
result.push('{');
continue;
}
let rest = &s[i + 1..];
let end = rest
.find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.unwrap_or(rest.len());
if end > 0 {
let var_name = &rest[..end];
result.push_str(&std::env::var(var_name).unwrap_or_default());
for _ in 0..end {
chars.next();
}
continue;
}
result.push(ch);
} else {
result.push(ch);
}
}
result
}
fn glob_paths(pattern: &str) -> Vec<PathBuf> {
if pattern.contains("**") {
return glob_paths_recursive(pattern);
}
let mut paths = Vec::new();
if let Some(parent) = Path::new(pattern).parent() {
let file_name = Path::new(pattern)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if simple_glob_match(&name_str, &file_name) {
paths.push(entry.path());
}
}
}
}
paths.sort();
paths
}
fn glob_paths_recursive(pattern: &str) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(delim) = pattern.find("**/") {
let prefix = &pattern[..delim];
let suffix = &pattern[delim + 3..];
let base = if prefix.is_empty() || prefix == "/" {
PathBuf::from(if prefix.is_empty() { "." } else { "/" })
} else {
PathBuf::from(prefix)
};
if base.is_dir() {
collect_recursive_glob(&base, suffix, &mut paths);
}
} else if let Some(prefix) = pattern.strip_suffix("**") {
let base = if prefix.is_empty() {
PathBuf::from(".")
} else {
PathBuf::from(prefix)
};
if base.is_dir() {
collect_recursive_glob(&base, "*", &mut paths);
}
}
paths.sort();
paths
}
fn collect_recursive_glob(dir: &Path, suffix: &str, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let collected: Vec<_> = entries.flatten().collect();
for entry in &collected {
let name = entry.file_name();
let name_str = name.to_string_lossy();
let path = entry.path();
if let Some(slash) = suffix.find('/') {
let first = &suffix[..slash];
let rest = &suffix[slash + 1..];
if path.is_dir() && simple_glob_match(&name_str, first) {
walk_subpath(&path, rest, out);
}
} else if simple_glob_match(&name_str, suffix) {
out.push(path.clone());
}
if path.is_dir() {
collect_recursive_glob(&path, suffix, out);
}
}
}
fn walk_subpath(dir: &Path, pattern: &str, out: &mut Vec<PathBuf>) {
let (first, rest) = if let Some(slash) = pattern.find('/') {
(&pattern[..slash], Some(&pattern[slash + 1..]))
} else {
(pattern, None)
};
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !simple_glob_match(&name_str, first) {
continue;
}
if let Some(remaining) = rest {
if entry.path().is_dir() {
walk_subpath(&entry.path(), remaining, out);
}
} else {
out.push(entry.path());
}
}
}
fn simple_glob_match(name: &str, pattern: &str) -> bool {
if pattern == "*" {
return true;
}
if !pattern.contains('*') && !pattern.contains('?') {
return name == pattern;
}
super::directives::glob_matches(name, pattern)
}
fn resolve_block(nodes: &[ConfigNode], resolved: &mut ResolvedHost, seen: &mut HashSet<String>) {
for node in nodes {
if let ConfigNode::Directive(d) = node {
if super::directives::is_accumulative(&d.keyword) {
if d.keyword.eq_ignore_ascii_case("identityfile")
&& !resolved.identity_files.iter().any(|f| f == &d.value)
{
resolved.identity_files.push(d.value.clone());
resolved
.directives
.push((d.keyword.clone(), d.value.clone()));
} else if d.keyword.eq_ignore_ascii_case("certificatefile")
&& !resolved.certificate_files.iter().any(|f| f == &d.value)
{
resolved.certificate_files.push(d.value.clone());
resolved
.directives
.push((d.keyword.clone(), d.value.clone()));
} else if d.keyword.eq_ignore_ascii_case("localforward")
&& !resolved.local_forwards.iter().any(|f| f == &d.value)
{
resolved.local_forwards.push(d.value.clone());
resolved
.directives
.push((d.keyword.clone(), d.value.clone()));
} else if d.keyword.eq_ignore_ascii_case("remoteforward")
&& !resolved.remote_forwards.iter().any(|f| f == &d.value)
{
resolved.remote_forwards.push(d.value.clone());
resolved
.directives
.push((d.keyword.clone(), d.value.clone()));
} else if d.keyword.eq_ignore_ascii_case("dynamicforward")
&& !resolved.dynamic_forwards.iter().any(|f| f == &d.value)
{
resolved.dynamic_forwards.push(d.value.clone());
resolved
.directives
.push((d.keyword.clone(), d.value.clone()));
}
continue;
}
let key_lower = d.keyword.to_ascii_lowercase();
if !seen.insert(key_lower) {
continue;
}
if d.keyword.eq_ignore_ascii_case("hostname") {
resolved.host_name = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("user") {
resolved.user = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("port") {
resolved.port = d.value.parse::<u16>().ok();
} else if d.keyword.eq_ignore_ascii_case("proxyjump") {
resolved.proxy_jump = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("identityagent") {
resolved.identity_agent = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("forwardagent") {
resolved.forward_agent = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("addkeystoagent") {
resolved.add_keys_to_agent = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("usekeychain") {
resolved.use_keychain = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("controlmaster") {
resolved.control_master = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("controlpath") {
resolved.control_path = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("controlpersist") {
resolved.control_persist = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("userknownhostsfile") {
resolved.user_known_hosts_file = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("identitiesonly") {
let lv = d.value.to_ascii_lowercase();
if lv == "yes" {
resolved.identities_only = Some(true);
} else if lv == "no" {
resolved.identities_only = Some(false);
}
} else if d.keyword.eq_ignore_ascii_case("gssapiauthentication") {
resolved.gssapi_authentication = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("gssapidelegatecredentials") {
resolved.gssapi_delegate_credentials = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("gssapiserveridentity") {
resolved.gssapi_server_identity = Some(d.value.clone());
} else if d.keyword.eq_ignore_ascii_case("gssapiclientidentity") {
resolved.gssapi_client_identity = Some(d.value.clone());
}
resolved
.directives
.push((d.keyword.clone(), d.value.clone()));
}
}
}
struct TokenContext<'a> {
host: &'a str,
home_dir: &'a str,
local_hostname: &'a str,
remote_user: &'a str,
local_user: &'a str,
port: &'a str,
canonical_host: &'a str,
#[allow(
dead_code,
reason = "placeholder for `%i` token expansion, not yet wired"
)]
identity_file: Option<&'a str>,
local_host_key: &'a str,
jump_host: &'a str,
remote_host_key: &'a str,
}
#[expect(
clippy::too_many_lines,
reason = "serial field-by-field expansion over ResolvedHost"
)]
fn expand_resolved(resolved: &mut ResolvedHost, host: &str, _ssh_dir: &Path) {
let local_user = whoami();
let local_hostname = hostname();
let home_dir = dirs::home_dir()
.map(|p| p.display().to_string())
.unwrap_or_default();
let port_str = resolved
.port
.map_or_else(|| "22".to_owned(), |p| p.to_string());
let remote_user = resolved.user.as_deref().unwrap_or(&local_user).to_owned();
let ctx = TokenContext {
host,
home_dir: &home_dir,
local_hostname: &local_hostname,
remote_user: &remote_user,
local_user: &local_user,
port: &port_str,
canonical_host: host,
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
for id_file in &mut resolved.identity_files {
*id_file = expand_tilde_and_env(id_file);
*id_file = expand_tokens(id_file, &ctx);
*id_file = collapse_double_percent(id_file);
}
for cert_file in &mut resolved.certificate_files {
*cert_file = expand_tilde_and_env(cert_file);
*cert_file = expand_tokens(cert_file, &ctx);
*cert_file = collapse_double_percent(cert_file);
}
if let Some(ref mut hn) = resolved.host_name {
*hn = expand_tilde_and_env(hn);
*hn = expand_tokens(hn, &ctx);
*hn = collapse_double_percent(hn);
}
if let Some(ref mut pj) = resolved.proxy_jump {
*pj = expand_tokens(pj, &ctx);
*pj = collapse_double_percent(pj);
}
if let Some(ref mut ia) = resolved.identity_agent {
*ia = expand_tilde_and_env(ia);
*ia = expand_tokens(ia, &ctx);
*ia = collapse_double_percent(ia);
}
if let Some(ref mut cp) = resolved.control_path {
*cp = expand_tilde_and_env(cp);
*cp = expand_tokens(cp, &ctx);
*cp = collapse_double_percent(cp);
}
if let Some(ref mut fa) = resolved.forward_agent {
*fa = expand_tilde_and_env(fa);
*fa = expand_tokens(fa, &ctx);
*fa = collapse_double_percent(fa);
}
if let Some(ref mut ata) = resolved.add_keys_to_agent {
*ata = expand_tilde_and_env(ata);
*ata = expand_tokens(ata, &ctx);
*ata = collapse_double_percent(ata);
}
if let Some(ref mut uk) = resolved.use_keychain {
*uk = expand_tilde_and_env(uk);
*uk = expand_tokens(uk, &ctx);
*uk = collapse_double_percent(uk);
}
if let Some(ref mut cm) = resolved.control_master {
*cm = expand_tilde_and_env(cm);
*cm = expand_tokens(cm, &ctx);
*cm = collapse_double_percent(cm);
}
if let Some(ref mut cpers) = resolved.control_persist {
*cpers = expand_tilde_and_env(cpers);
*cpers = expand_tokens(cpers, &ctx);
*cpers = collapse_double_percent(cpers);
}
for lf in &mut resolved.local_forwards {
*lf = expand_tilde_and_env(lf);
*lf = expand_tokens(lf, &ctx);
*lf = collapse_double_percent(lf);
}
for rf in &mut resolved.remote_forwards {
*rf = expand_tilde_and_env(rf);
*rf = expand_tokens(rf, &ctx);
*rf = collapse_double_percent(rf);
}
for df in &mut resolved.dynamic_forwards {
*df = expand_tilde_and_env(df);
*df = expand_tokens(df, &ctx);
*df = collapse_double_percent(df);
}
for (key, value) in &mut resolved.directives {
let key_lower = key.to_lowercase();
if TOKEN_EXPANDABLE.contains(&key_lower.as_str())
|| key_lower == "identityfile"
|| key_lower == "hostname"
|| key_lower == "proxyjump"
{
let expanded = expand_tilde_and_env(value);
let expanded = expand_tokens(&expanded, &ctx);
*value = collapse_double_percent(&expanded);
}
}
}
fn is_canonicalize_enabled(resolved: &ResolvedHost) -> bool {
resolved
.directives
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("canonicalizehostname"))
.is_some_and(|(_, v)| {
let lv = v.to_lowercase();
lv == "yes" || lv == "always"
})
}
fn expand_tokens(s: &str, ctx: &TokenContext<'_>) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '%' {
match chars.peek().copied() {
Some('%') => {
result.push_str("%%");
chars.next();
}
Some('C') => {
chars.next();
let hash_input = format!("{}:{}:{}", ctx.host, ctx.port, ctx.local_user);
let hash = simple_hash(&hash_input);
result.push_str(&hash);
}
Some('d') => {
chars.next();
result.push_str(ctx.home_dir);
}
Some('H') => {
chars.next();
result.push_str(ctx.canonical_host);
}
Some('h' | 'n') => {
chars.next();
result.push_str(ctx.host);
}
Some('L') => {
chars.next();
let short = ctx
.local_hostname
.split('.')
.next()
.unwrap_or(ctx.local_hostname);
result.push_str(short);
}
Some('l') => {
chars.next();
result.push_str(ctx.local_hostname);
}
Some('p') => {
chars.next();
result.push_str(ctx.port);
}
Some('r' | 'T') => {
chars.next();
result.push_str(ctx.remote_user);
}
Some('i' | 'u') => {
chars.next();
result.push_str(ctx.local_user);
}
Some('k') => {
chars.next();
result.push_str(ctx.local_host_key);
}
Some('j') => {
chars.next();
result.push_str(ctx.jump_host);
}
Some('K') => {
chars.next();
result.push_str(ctx.remote_host_key);
}
Some('t') => {
chars.next();
result.push_str(ctx.port);
}
_ => {
result.push(ch);
}
}
} else {
result.push(ch);
}
}
result
}
fn simple_hash(s: &str) -> String {
let bytes = s.as_bytes();
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let prime: u64 = 0x0100_0000_01b3;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(prime);
}
format!("{hash:016x}")
}
fn collapse_double_percent(s: &str) -> String {
s.replace("%%", "%")
}
fn whoami() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_owned())
}
fn hostname() -> String {
std::env::var("HOSTNAME")
.unwrap_or_else(|_| gethostname::gethostname().to_string_lossy().into_owned())
}
fn host_matches(host: &str, patterns: &[impl AsRef<str>]) -> bool {
super::directives::host_matches_patterns(host, patterns)
}
fn match_criteria_host(
criteria: &str,
target_host: &str,
target_user: &str,
original_host: &str,
) -> bool {
let mut tokens = criteria.split_whitespace();
let mut has_host = false;
let mut host_matched = false;
let mut has_originalhost = false;
let mut originalhost_matched = false;
let mut has_user = false;
let mut user_matched = false;
let mut has_localuser = false;
let mut localuser_matched = false;
let local_user = whoami();
while let Some(keyword) = tokens.next() {
if keyword.eq_ignore_ascii_case("host") {
if let Some(patterns_str) = tokens.next() {
has_host = true;
let patterns: Vec<&str> = patterns_str.split(',').collect();
if host_matches(target_host, &patterns) {
host_matched = true;
}
}
} else if keyword.eq_ignore_ascii_case("originalhost") {
if let Some(patterns_str) = tokens.next() {
has_originalhost = true;
let patterns: Vec<&str> = patterns_str.split(',').collect();
if host_matches(original_host, &patterns) {
originalhost_matched = true;
}
}
} else if keyword.eq_ignore_ascii_case("user") {
if let Some(names_str) = tokens.next() {
has_user = true;
let names: Vec<&str> = names_str.split(',').collect();
if names.iter().any(|n| n.eq_ignore_ascii_case(target_user)) {
user_matched = true;
}
}
} else if keyword.eq_ignore_ascii_case("localuser") {
if let Some(names_str) = tokens.next() {
has_localuser = true;
let names: Vec<&str> = names_str.split(',').collect();
if names.iter().any(|n| n.eq_ignore_ascii_case(&local_user)) {
localuser_matched = true;
}
}
} else if keyword.eq_ignore_ascii_case("exec") {
break;
} else {
tokens.next();
}
}
let any_known = has_host || has_originalhost || has_user || has_localuser;
let all_matched = (!has_host || host_matched)
&& (!has_originalhost || originalhost_matched)
&& (!has_user || user_matched)
&& (!has_localuser || localuser_matched);
any_known && all_matched
}
fn contains_exec_criteria(criteria: &str) -> bool {
let mut tokens = criteria.split_whitespace();
while let Some(keyword) = tokens.next() {
if keyword.eq_ignore_ascii_case("exec") {
return true;
}
tokens.next();
}
false
}
#[cfg(test)]
#[path = "resolve.test.rs"]
mod tests;