Skip to main content

toride_ssh_config/
resolve.rs

1//! Full SSH config resolution.
2//!
3//! Handles Include chains, token/env expansion, first-match-wins
4//! (with `IdentityFile` accumulation), and `CanonicalizeHostname` double-parse.
5
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9use super::ast::{self, ConfigAst, ConfigNode};
10use toride_ssh_core::Result;
11
12/// Fully resolved parameters for a host.
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct ResolvedHost {
15    /// The alias used to look up the host.
16    pub alias: String,
17    /// The real hostname to connect to.
18    pub host_name: Option<String>,
19    /// The user for the SSH connection.
20    pub user: Option<String>,
21    /// The port number.
22    pub port: Option<u16>,
23    /// Identity files to try.
24    pub identity_files: Vec<String>,
25    /// Certificate files (accumulative across matching blocks).
26    pub certificate_files: Vec<String>,
27    /// `ProxyJump` hosts.
28    pub proxy_jump: Option<String>,
29    /// `IdentityAgent` socket path.
30    pub identity_agent: Option<String>,
31    /// `ForwardAgent` setting (yes/no).
32    pub forward_agent: Option<String>,
33    /// `AddKeysToAgent` setting (yes/confirm/ask/no/lifetime).
34    pub add_keys_to_agent: Option<String>,
35    /// `UseKeychain` setting (yes/no, macOS only).
36    pub use_keychain: Option<String>,
37    /// `ControlMaster` setting (yes/no/auto/ask/autoask).
38    pub control_master: Option<String>,
39    /// `ControlPath` socket path.
40    pub control_path: Option<String>,
41    /// `ControlPersist` duration.
42    pub control_persist: Option<String>,
43    /// `LocalForward` entries (accumulative).
44    pub local_forwards: Vec<String>,
45    /// `RemoteForward` entries (accumulative).
46    pub remote_forwards: Vec<String>,
47    /// `DynamicForward` entries (accumulative).
48    pub dynamic_forwards: Vec<String>,
49    /// All raw key-value directives from matching blocks.
50    pub directives: Vec<(String, String)>,
51    /// `UserKnownHostsFile` value, if set.
52    ///
53    /// When `None`, the default `~/.ssh/known_hosts` is used.
54    /// May contain SSH tokens (already expanded).
55    pub user_known_hosts_file: Option<String>,
56    /// `IdentitiesOnly` setting parsed as a boolean (`yes` / `no`).
57    ///
58    /// When `Some(true)`, only the identity files explicitly listed in the
59    /// config (and those on the command line) are offered during
60    /// authentication. When `Some(false)` or `None`, keys from the agent
61    /// and default key files are also tried.
62    pub identities_only: Option<bool>,
63    /// Whether the config was re-resolved after `CanonicalizeHostname` took
64    /// effect. When `true`, `%H` tokens expand to the canonical hostname
65    /// rather than the original alias.
66    pub canonicalized: bool,
67    /// Warnings for Match blocks containing `exec` criteria that were not
68    /// evaluated (toride does not execute arbitrary commands for security).
69    pub unevaluated_match_warnings: Vec<String>,
70    /// `GSSAPIAuthentication` setting (yes/no).
71    pub gssapi_authentication: Option<String>,
72    /// `GSSAPIDelegateCredentials` setting (yes/no).
73    pub gssapi_delegate_credentials: Option<String>,
74    /// `GSSAPIServerIdentity` value.
75    pub gssapi_server_identity: Option<String>,
76    /// `GSSAPIClientIdentity` value.
77    pub gssapi_client_identity: Option<String>,
78}
79
80/// Directives whose values may contain SSH tokens (`%h`, `%d`, etc.) or
81/// tilde/env expansion and should be expanded during resolution.
82const TOKEN_EXPANDABLE: &[&str] = &[
83    "certificatefile",
84    "controlmaster",
85    "controlpath",
86    "controlpersist",
87    "dynamicforward",
88    "forwardagent",
89    "identityagent",
90    "knownhostscommand",
91    "localforward",
92    "remoteforward",
93    "revokedhostkeys",
94    "usekeychain",
95    "userknownhostsfile",
96    "proxycommand",
97];
98
99/// Fully resolve config for a given host alias.
100///
101/// This performs:
102/// 1. Loading and parsing the main config file.
103/// 2. Inlining `Include` directives (with cycle detection).
104/// 3. Token and environment variable expansion.
105/// 4. First-match-wins resolution with `IdentityFile` accumulation.
106/// 5. If `CanonicalizeHostname` is enabled, a second resolution pass using
107///    the resolved `HostName` as the lookup key.
108///
109/// `user` is the remote username for `Match user` criteria.  When `None`,
110/// the local username is used (matching OpenSSH behaviour when no `-l`
111/// flag is given).
112///
113/// # Errors
114///
115/// Returns `Error::ConfigIncludeCycle` if an `Include` chain contains a
116/// cycle. Returns `Error::Io` if the config file cannot be read.
117pub async fn resolve(ssh_dir: &Path, host: &str, user: Option<&str>) -> Result<ResolvedHost> {
118    let config_path = ssh_dir.join("config");
119
120    // Load and flatten includes.
121    let mut visited = HashSet::new();
122    let flat_ast = load_and_flatten(&config_path, &mut visited).await?;
123
124    // First pass: resolve against the original alias.
125    let local_user = user.map_or_else(whoami, str::to_owned);
126    let mut resolved = resolve_pass(&flat_ast, host, host, &local_user);
127
128    // Token expansion on first-pass values.
129    expand_resolved(&mut resolved, host, ssh_dir);
130
131    // CanonicalizeHostname: if enabled, re-resolve using the resolved HostName.
132    if is_canonicalize_enabled(&resolved) {
133        let canonical_host = resolved.host_name.take().unwrap_or_else(|| host.to_owned());
134
135        let mut canon = resolve_pass(&flat_ast, &canonical_host, host, &local_user);
136
137        // Expand tokens in the canonicalized result.
138        expand_resolved(&mut canon, &canonical_host, ssh_dir);
139
140        host.clone_into(&mut canon.alias);
141        canon.canonicalized = true;
142        return Ok(canon);
143    }
144
145    Ok(resolved)
146}
147
148/// Perform a single resolution pass over the flattened AST.
149///
150/// `target_host` is the hostname used for pattern matching (the canonical
151/// name on the second pass, or the original alias on the first).
152/// `original_host` is always the alias the user typed — used for
153/// `Match originalhost` criteria.
154fn resolve_pass(
155    flat_ast: &ConfigAst,
156    target_host: &str,
157    original_host: &str,
158    local_user: &str,
159) -> ResolvedHost {
160    let mut resolved = ResolvedHost {
161        alias: target_host.to_owned(),
162        host_name: None,
163        user: None,
164        port: None,
165        identity_files: Vec::new(),
166        certificate_files: Vec::new(),
167        proxy_jump: None,
168        identity_agent: None,
169        forward_agent: None,
170        add_keys_to_agent: None,
171        use_keychain: None,
172        control_master: None,
173        control_path: None,
174        control_persist: None,
175        local_forwards: Vec::new(),
176        remote_forwards: Vec::new(),
177        dynamic_forwards: Vec::new(),
178        directives: Vec::new(),
179        user_known_hosts_file: None,
180        identities_only: None,
181        canonicalized: false,
182        unevaluated_match_warnings: Vec::new(),
183        gssapi_authentication: None,
184        gssapi_delegate_credentials: None,
185        gssapi_server_identity: None,
186        gssapi_client_identity: None,
187    };
188
189    let mut seen_keys = HashSet::new();
190
191    for node in &flat_ast.nodes {
192        match node {
193            ConfigNode::HostBlock(b) => {
194                if host_matches(target_host, &b.patterns) {
195                    resolve_block(&b.nodes, &mut resolved, &mut seen_keys);
196                }
197            }
198            ConfigNode::MatchBlock(b) => {
199                // Warn about `exec` criteria — we cannot evaluate them safely.
200                if contains_exec_criteria(&b.criteria) {
201                    let warning = format!(
202                        "Match block contains 'exec' criteria which are not evaluated: {}",
203                        b.criteria,
204                    );
205                    tracing::warn!("{}", &warning);
206                    resolved.unevaluated_match_warnings.push(warning);
207                }
208                if match_criteria_host(&b.criteria, target_host, local_user, original_host) {
209                    resolve_block(&b.nodes, &mut resolved, &mut seen_keys);
210                }
211            }
212            _ => {}
213        }
214    }
215
216    resolved
217}
218
219/// Load a config file and recursively inline all Include directives.
220fn load_and_flatten<'a>(
221    path: &'a Path,
222    visited: &'a mut HashSet<PathBuf>,
223) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ConfigAst>> + 'a>> {
224    Box::pin(async move {
225        // PERF: If canonicalize fails (e.g. permission denied, broken symlink),
226        // cycle detection may not catch symlink loops. This is acceptable because
227        // SSH config files are unlikely to use symlinks.
228        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_owned());
229
230        if visited.contains(&canonical) {
231            return Err(toride_ssh_core::Error::ConfigIncludeCycle(
232                canonical.display().to_string(),
233            ));
234        }
235        visited.insert(canonical);
236
237        let content = if path.exists() {
238            tokio::fs::read_to_string(path).await?
239        } else {
240            return Ok(ConfigAst { nodes: Vec::new() });
241        };
242
243        let mut flat = ast::parse(&content);
244
245        // Inline includes: single-pass replacement that avoids the position-
246        // shifting bug.  We walk the original nodes vec and, for each Include
247        // directive, expand its glob and splice in the recursively-loaded
248        // content.  Non-Include nodes are kept as-is.
249        let original_nodes = std::mem::take(&mut flat.nodes);
250        let mut new_nodes = Vec::with_capacity(original_nodes.len());
251
252        for node in original_nodes {
253            let pattern_value = match &node {
254                ConfigNode::Directive(d) if d.keyword.eq_ignore_ascii_case("include") => {
255                    Some(d.value.clone())
256                }
257                _ => None,
258            };
259
260            if let Some(include_pattern) = pattern_value {
261                let expanded = expand_tilde_and_env(&include_pattern);
262
263                // Glob the pattern.
264                let base_dir = if Path::new(&expanded).is_absolute() {
265                    PathBuf::new()
266                } else {
267                    path.parent().unwrap_or_else(|| Path::new(".")).to_owned()
268                };
269
270                let full_pattern = base_dir.join(&expanded);
271                let pattern_str = full_pattern.display().to_string();
272
273                let matched_files = glob_paths(&pattern_str);
274
275                for inc_path in matched_files {
276                    let included = load_and_flatten(&inc_path, visited).await?;
277                    new_nodes.extend(included.nodes);
278                }
279            } else {
280                new_nodes.push(node);
281            }
282        }
283
284        flat.nodes = new_nodes;
285
286        Ok(flat)
287    })
288}
289
290/// Expand tilde (`~`) and `${ENV}` patterns in an include path.
291fn expand_tilde_and_env(path: &str) -> String {
292    let mut result = path.to_owned();
293
294    // Expand `~` or `~/`.
295    if (result.starts_with("~/") || result == "~")
296        && let Some(home) = dirs::home_dir()
297    {
298        let home_str = home.display().to_string();
299        result = result.replacen('~', &home_str, 1);
300    }
301
302    // Expand `${ENV_VAR}` and `$ENV_VAR`.
303    result = expand_env_vars(&result);
304
305    result
306}
307
308/// Expand environment variables in `${VAR}` and `$VAR` formats.
309///
310/// Uses a single-pass builder to avoid repeated string reallocations.
311/// If a `${` is encountered without a matching `}`, the literal `${` is
312/// preserved in the output to avoid silently corrupting paths.
313fn expand_env_vars(s: &str) -> String {
314    let mut result = String::with_capacity(s.len());
315    let mut chars = s.char_indices().peekable();
316
317    while let Some((i, ch)) = chars.next() {
318        if ch == '$' {
319            if let Some((_, '{')) = chars.peek() {
320                // `${VAR}` form — look for closing `}`.
321                chars.next(); // consume '{'
322                let start = i + 2;
323                if let Some(end_offset) = s[start..].find('}') {
324                    let var_name = &s[start..start + end_offset];
325                    result.push_str(&std::env::var(var_name).unwrap_or_default());
326                    // Skip to after '}'
327                    for _ in 0..=end_offset {
328                        chars.next();
329                    }
330                    continue;
331                }
332                // No closing `}` — preserve the literal `${` to avoid corruption.
333                result.push(ch);
334                result.push('{');
335                continue;
336            }
337            // `$VAR` form (without braces) — read until non-alphanumeric/underscore.
338            let rest = &s[i + 1..];
339            let end = rest
340                .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
341                .unwrap_or(rest.len());
342            if end > 0 {
343                let var_name = &rest[..end];
344                result.push_str(&std::env::var(var_name).unwrap_or_default());
345                // Skip the variable name characters.
346                for _ in 0..end {
347                    chars.next();
348                }
349                continue;
350            }
351            // Bare `$` at end of string or before non-name char.
352            result.push(ch);
353        } else {
354            result.push(ch);
355        }
356    }
357
358    result
359}
360
361/// Expand glob patterns and return matching file paths.
362///
363/// Supports `*` and `?` single-directory wildcards as well as `**` for
364/// recursive directory matching:
365/// - `**/` matches zero or more directory levels
366/// - `dir/**/*.conf` matches all `.conf` files in `dir` and its subdirectories
367/// - `dir/**/` matches all directories under `dir` (recursively)
368fn glob_paths(pattern: &str) -> Vec<PathBuf> {
369    // Detect recursive glob (**).
370    if pattern.contains("**") {
371        return glob_paths_recursive(pattern);
372    }
373
374    // Original single-directory glob logic.
375    let mut paths = Vec::new();
376
377    if let Some(parent) = Path::new(pattern).parent() {
378        let file_name = Path::new(pattern)
379            .file_name()
380            .map(|f| f.to_string_lossy().into_owned())
381            .unwrap_or_default();
382
383        if let Ok(entries) = std::fs::read_dir(parent) {
384            for entry in entries.flatten() {
385                let name = entry.file_name();
386                let name_str = name.to_string_lossy();
387                if simple_glob_match(&name_str, &file_name) {
388                    paths.push(entry.path());
389                }
390            }
391        }
392    }
393
394    paths.sort();
395    paths
396}
397
398/// Recursive glob expansion for patterns containing `**`.
399///
400/// Splits the pattern at the first occurrence of `**/` to obtain a base
401/// directory (prefix) and a suffix pattern.  Walks all subdirectories under
402/// the prefix and applies the suffix pattern at every level using
403/// [`simple_glob_match`], matching zero or more intermediate directory
404/// levels.
405fn glob_paths_recursive(pattern: &str) -> Vec<PathBuf> {
406    let mut paths = Vec::new();
407
408    // Split on the first occurrence of "**/".
409    if let Some(delim) = pattern.find("**/") {
410        let prefix = &pattern[..delim];
411        // Skip past "**/" (3 characters).
412        let suffix = &pattern[delim + 3..];
413
414        let base = if prefix.is_empty() || prefix == "/" {
415            PathBuf::from(if prefix.is_empty() { "." } else { "/" })
416        } else {
417            PathBuf::from(prefix)
418        };
419
420        if base.is_dir() {
421            collect_recursive_glob(&base, suffix, &mut paths);
422        }
423    } else if let Some(prefix) = pattern.strip_suffix("**") {
424        // Trailing ** without trailing slash — treat as "match everything
425        // under the prefix directory".
426        let base = if prefix.is_empty() {
427            PathBuf::from(".")
428        } else {
429            PathBuf::from(prefix)
430        };
431
432        if base.is_dir() {
433            collect_recursive_glob(&base, "*", &mut paths);
434        }
435    }
436
437    paths.sort();
438    paths
439}
440
441/// Recursively walk `dir`, applying `suffix` at every directory level.
442///
443/// `**` matches zero or more directory levels.  This function visits every
444/// subdirectory and applies the suffix pattern at each level.  The suffix
445/// may itself contain additional path components separated by `/`; the first
446/// component is matched against directory entries and the remainder is
447/// walked normally (without `**` semantics).
448fn collect_recursive_glob(dir: &Path, suffix: &str, out: &mut Vec<PathBuf>) {
449    let Ok(entries) = std::fs::read_dir(dir) else {
450        return;
451    };
452
453    let collected: Vec<_> = entries.flatten().collect();
454
455    for entry in &collected {
456        let name = entry.file_name();
457        let name_str = name.to_string_lossy();
458        let path = entry.path();
459
460        // Apply the suffix at this level.
461        // Split suffix into the first path component and the rest.
462        if let Some(slash) = suffix.find('/') {
463            let first = &suffix[..slash];
464            let rest = &suffix[slash + 1..];
465
466            // First component must match and entry must be a directory for
467            // the rest of the pattern to apply.
468            if path.is_dir() && simple_glob_match(&name_str, first) {
469                walk_subpath(&path, rest, out);
470            }
471        } else if simple_glob_match(&name_str, suffix) {
472            out.push(path.clone());
473        }
474
475        // Recurse into every subdirectory (zero-or-more levels).
476        if path.is_dir() {
477            collect_recursive_glob(&path, suffix, out);
478        }
479    }
480}
481
482/// Walk a non-`**` path pattern starting from `dir`.
483///
484/// Each call consumes one path component from `pattern`, matching it against
485/// directory entries.  When the pattern is fully consumed the matching entry
486/// is added to `out`.
487fn walk_subpath(dir: &Path, pattern: &str, out: &mut Vec<PathBuf>) {
488    let (first, rest) = if let Some(slash) = pattern.find('/') {
489        (&pattern[..slash], Some(&pattern[slash + 1..]))
490    } else {
491        (pattern, None)
492    };
493
494    let Ok(entries) = std::fs::read_dir(dir) else {
495        return;
496    };
497
498    for entry in entries.flatten() {
499        let name = entry.file_name();
500        let name_str = name.to_string_lossy();
501
502        if !simple_glob_match(&name_str, first) {
503            continue;
504        }
505
506        if let Some(remaining) = rest {
507            if entry.path().is_dir() {
508                walk_subpath(&entry.path(), remaining, out);
509            }
510        } else {
511            out.push(entry.path());
512        }
513    }
514}
515
516/// Simple glob match for file names.
517///
518/// Performs case-sensitive matching, which is correct for Unix file paths.
519fn simple_glob_match(name: &str, pattern: &str) -> bool {
520    if pattern == "*" {
521        return true;
522    }
523    if !pattern.contains('*') && !pattern.contains('?') {
524        return name == pattern;
525    }
526    // Delegate to the directives glob matcher (case-sensitive for file paths).
527    super::directives::glob_matches(name, pattern)
528}
529
530/// Apply first-match-wins resolution from a block's nodes.
531///
532/// Accumulative directives (`IdentityFile`, `CertificateFile`, etc.) are
533/// collected across all matching blocks. All other directives use
534/// first-match-wins semantics.
535fn resolve_block(nodes: &[ConfigNode], resolved: &mut ResolvedHost, seen: &mut HashSet<String>) {
536    for node in nodes {
537        if let ConfigNode::Directive(d) = node {
538            // Accumulative directives — collect with dedup.
539            if super::directives::is_accumulative(&d.keyword) {
540                if d.keyword.eq_ignore_ascii_case("identityfile")
541                    && !resolved.identity_files.iter().any(|f| f == &d.value)
542                {
543                    resolved.identity_files.push(d.value.clone());
544                    resolved
545                        .directives
546                        .push((d.keyword.clone(), d.value.clone()));
547                } else if d.keyword.eq_ignore_ascii_case("certificatefile")
548                    && !resolved.certificate_files.iter().any(|f| f == &d.value)
549                {
550                    resolved.certificate_files.push(d.value.clone());
551                    resolved
552                        .directives
553                        .push((d.keyword.clone(), d.value.clone()));
554                } else if d.keyword.eq_ignore_ascii_case("localforward")
555                    && !resolved.local_forwards.iter().any(|f| f == &d.value)
556                {
557                    resolved.local_forwards.push(d.value.clone());
558                    resolved
559                        .directives
560                        .push((d.keyword.clone(), d.value.clone()));
561                } else if d.keyword.eq_ignore_ascii_case("remoteforward")
562                    && !resolved.remote_forwards.iter().any(|f| f == &d.value)
563                {
564                    resolved.remote_forwards.push(d.value.clone());
565                    resolved
566                        .directives
567                        .push((d.keyword.clone(), d.value.clone()));
568                } else if d.keyword.eq_ignore_ascii_case("dynamicforward")
569                    && !resolved.dynamic_forwards.iter().any(|f| f == &d.value)
570                {
571                    resolved.dynamic_forwards.push(d.value.clone());
572                    resolved
573                        .directives
574                        .push((d.keyword.clone(), d.value.clone()));
575                }
576                continue;
577            }
578
579            // Skip if we already have a value (first-match-wins).
580            // `insert` returns false if the key was already present.
581            let key_lower = d.keyword.to_ascii_lowercase();
582            if !seen.insert(key_lower) {
583                continue;
584            }
585
586            // Match first, then move key_lower into the set to avoid cloning.
587            if d.keyword.eq_ignore_ascii_case("hostname") {
588                resolved.host_name = Some(d.value.clone());
589            } else if d.keyword.eq_ignore_ascii_case("user") {
590                resolved.user = Some(d.value.clone());
591            } else if d.keyword.eq_ignore_ascii_case("port") {
592                resolved.port = d.value.parse::<u16>().ok();
593            } else if d.keyword.eq_ignore_ascii_case("proxyjump") {
594                resolved.proxy_jump = Some(d.value.clone());
595            } else if d.keyword.eq_ignore_ascii_case("identityagent") {
596                resolved.identity_agent = Some(d.value.clone());
597            } else if d.keyword.eq_ignore_ascii_case("forwardagent") {
598                resolved.forward_agent = Some(d.value.clone());
599            } else if d.keyword.eq_ignore_ascii_case("addkeystoagent") {
600                resolved.add_keys_to_agent = Some(d.value.clone());
601            } else if d.keyword.eq_ignore_ascii_case("usekeychain") {
602                resolved.use_keychain = Some(d.value.clone());
603            } else if d.keyword.eq_ignore_ascii_case("controlmaster") {
604                resolved.control_master = Some(d.value.clone());
605            } else if d.keyword.eq_ignore_ascii_case("controlpath") {
606                resolved.control_path = Some(d.value.clone());
607            } else if d.keyword.eq_ignore_ascii_case("controlpersist") {
608                resolved.control_persist = Some(d.value.clone());
609            } else if d.keyword.eq_ignore_ascii_case("userknownhostsfile") {
610                resolved.user_known_hosts_file = Some(d.value.clone());
611            } else if d.keyword.eq_ignore_ascii_case("identitiesonly") {
612                let lv = d.value.to_ascii_lowercase();
613                if lv == "yes" {
614                    resolved.identities_only = Some(true);
615                } else if lv == "no" {
616                    resolved.identities_only = Some(false);
617                }
618            } else if d.keyword.eq_ignore_ascii_case("gssapiauthentication") {
619                resolved.gssapi_authentication = Some(d.value.clone());
620            } else if d.keyword.eq_ignore_ascii_case("gssapidelegatecredentials") {
621                resolved.gssapi_delegate_credentials = Some(d.value.clone());
622            } else if d.keyword.eq_ignore_ascii_case("gssapiserveridentity") {
623                resolved.gssapi_server_identity = Some(d.value.clone());
624            } else if d.keyword.eq_ignore_ascii_case("gssapiclientidentity") {
625                resolved.gssapi_client_identity = Some(d.value.clone());
626            }
627
628            resolved
629                .directives
630                .push((d.keyword.clone(), d.value.clone()));
631        }
632    }
633}
634
635/// Context for SSH token expansion (`%h`, `%d`, `%l`, etc.).
636struct TokenContext<'a> {
637    host: &'a str,
638    home_dir: &'a str,
639    local_hostname: &'a str,
640    remote_user: &'a str,
641    local_user: &'a str,
642    port: &'a str,
643    /// Canonical hostname (same as host unless `CanonicalizeHostname` is enabled).
644    canonical_host: &'a str,
645    /// Identity file being expanded (`%i` → basename).  `None` when
646    /// expanding a non-IdentityFile directive.
647    #[allow(
648        dead_code,
649        reason = "placeholder for `%i` token expansion, not yet wired"
650    )]
651    identity_file: Option<&'a str>,
652    /// Local host key (`%k`).
653    local_host_key: &'a str,
654    /// Jump host (`%j`).
655    jump_host: &'a str,
656    /// Remote host key (`%K`).
657    remote_host_key: &'a str,
658}
659
660/// Expand tokens in resolved values.
661///
662/// Applies tilde, environment-variable, and SSH token expansion to all
663/// directive values that may contain them — including the dedicated
664/// fields (`identity_files`, `host_name`, `proxy_jump`) and every
665/// entry in the raw `directives` vec whose key is listed in
666/// [`TOKEN_EXPANDABLE`].
667#[expect(
668    clippy::too_many_lines,
669    reason = "serial field-by-field expansion over ResolvedHost"
670)]
671fn expand_resolved(resolved: &mut ResolvedHost, host: &str, _ssh_dir: &Path) {
672    let local_user = whoami();
673    let local_hostname = hostname();
674    let home_dir = dirs::home_dir()
675        .map(|p| p.display().to_string())
676        .unwrap_or_default();
677
678    let port_str = resolved
679        .port
680        .map_or_else(|| "22".to_owned(), |p| p.to_string());
681    let remote_user = resolved.user.as_deref().unwrap_or(&local_user).to_owned();
682
683    let ctx = TokenContext {
684        host,
685        home_dir: &home_dir,
686        local_hostname: &local_hostname,
687        remote_user: &remote_user,
688        local_user: &local_user,
689        port: &port_str,
690        // On first pass canonical_host == host; second pass uses the
691        // canonical name (already substituted as `host` by the caller).
692        canonical_host: host,
693        identity_file: None,
694        // Placeholders — not yet populated from live connection state.
695        local_host_key: "",
696        jump_host: "",
697        remote_host_key: "",
698    };
699
700    // Expand dedicated fields.
701    for id_file in &mut resolved.identity_files {
702        *id_file = expand_tilde_and_env(id_file);
703        *id_file = expand_tokens(id_file, &ctx);
704        *id_file = collapse_double_percent(id_file);
705    }
706
707    for cert_file in &mut resolved.certificate_files {
708        *cert_file = expand_tilde_and_env(cert_file);
709        *cert_file = expand_tokens(cert_file, &ctx);
710        *cert_file = collapse_double_percent(cert_file);
711    }
712
713    if let Some(ref mut hn) = resolved.host_name {
714        *hn = expand_tilde_and_env(hn);
715        *hn = expand_tokens(hn, &ctx);
716        *hn = collapse_double_percent(hn);
717    }
718
719    if let Some(ref mut pj) = resolved.proxy_jump {
720        *pj = expand_tokens(pj, &ctx);
721        *pj = collapse_double_percent(pj);
722    }
723
724    if let Some(ref mut ia) = resolved.identity_agent {
725        *ia = expand_tilde_and_env(ia);
726        *ia = expand_tokens(ia, &ctx);
727        *ia = collapse_double_percent(ia);
728    }
729
730    if let Some(ref mut cp) = resolved.control_path {
731        *cp = expand_tilde_and_env(cp);
732        *cp = expand_tokens(cp, &ctx);
733        *cp = collapse_double_percent(cp);
734    }
735
736    if let Some(ref mut fa) = resolved.forward_agent {
737        *fa = expand_tilde_and_env(fa);
738        *fa = expand_tokens(fa, &ctx);
739        *fa = collapse_double_percent(fa);
740    }
741
742    if let Some(ref mut ata) = resolved.add_keys_to_agent {
743        *ata = expand_tilde_and_env(ata);
744        *ata = expand_tokens(ata, &ctx);
745        *ata = collapse_double_percent(ata);
746    }
747
748    if let Some(ref mut uk) = resolved.use_keychain {
749        *uk = expand_tilde_and_env(uk);
750        *uk = expand_tokens(uk, &ctx);
751        *uk = collapse_double_percent(uk);
752    }
753
754    if let Some(ref mut cm) = resolved.control_master {
755        *cm = expand_tilde_and_env(cm);
756        *cm = expand_tokens(cm, &ctx);
757        *cm = collapse_double_percent(cm);
758    }
759
760    if let Some(ref mut cpers) = resolved.control_persist {
761        *cpers = expand_tilde_and_env(cpers);
762        *cpers = expand_tokens(cpers, &ctx);
763        *cpers = collapse_double_percent(cpers);
764    }
765
766    for lf in &mut resolved.local_forwards {
767        *lf = expand_tilde_and_env(lf);
768        *lf = expand_tokens(lf, &ctx);
769        *lf = collapse_double_percent(lf);
770    }
771
772    for rf in &mut resolved.remote_forwards {
773        *rf = expand_tilde_and_env(rf);
774        *rf = expand_tokens(rf, &ctx);
775        *rf = collapse_double_percent(rf);
776    }
777
778    for df in &mut resolved.dynamic_forwards {
779        *df = expand_tilde_and_env(df);
780        *df = expand_tokens(df, &ctx);
781        *df = collapse_double_percent(df);
782    }
783
784    // Expand all raw directive values that may contain tokens.
785    for (key, value) in &mut resolved.directives {
786        let key_lower = key.to_lowercase();
787        if TOKEN_EXPANDABLE.contains(&key_lower.as_str())
788            || key_lower == "identityfile"
789            || key_lower == "hostname"
790            || key_lower == "proxyjump"
791        {
792            let expanded = expand_tilde_and_env(value);
793            let expanded = expand_tokens(&expanded, &ctx);
794            *value = collapse_double_percent(&expanded);
795        }
796    }
797}
798
799/// Check whether `CanonicalizeHostname` is enabled in the resolved config.
800///
801/// OpenSSH recognises `yes`, `always`, and `no` (the default).  Any other
802/// value is treated as `no`.
803fn is_canonicalize_enabled(resolved: &ResolvedHost) -> bool {
804    resolved
805        .directives
806        .iter()
807        .find(|(k, _)| k.eq_ignore_ascii_case("canonicalizehostname"))
808        .is_some_and(|(_, v)| {
809            let lv = v.to_lowercase();
810            lv == "yes" || lv == "always"
811        })
812}
813
814/// Expand SSH tokens in a value string.
815///
816/// Supported tokens (matching OpenSSH `ssh_config(5)`):
817/// - `%%` → literal `%`
818/// - `%C` → hash of connection (host+port+user) — placeholder
819/// - `%d` → home directory
820/// - `%H` → canonical hostname
821/// - `%h` / `%n` → remote host (alias)
822/// - `%i` → local username (same as `%u`; see note in implementation)
823/// - `%j` → jump host (placeholder)
824/// - `%K` → remote host key (placeholder)
825/// - `%k` → local host key (placeholder)
826/// - `%L` → local hostname (short)
827/// - `%l` → local hostname (FQDN)
828/// - `%p` → remote port
829/// - `%r` → remote username
830/// - `%T` → remote username (same as %r)
831/// - `%t` → remote port (same as %p)
832/// - `%u` → local username
833///
834/// Unknown `%X` sequences and trailing `%` are preserved as-is.
835fn expand_tokens(s: &str, ctx: &TokenContext<'_>) -> String {
836    let mut result = String::with_capacity(s.len());
837    let mut chars = s.chars().peekable();
838
839    while let Some(ch) = chars.next() {
840        if ch == '%' {
841            match chars.peek().copied() {
842                Some('%') => {
843                    // Keep `%%` as-is; collapse_double_percent handles it later.
844                    result.push_str("%%");
845                    chars.next();
846                }
847                Some('C') => {
848                    // Connection hash — use a simple hash of host:port:user.
849                    chars.next();
850                    let hash_input = format!("{}:{}:{}", ctx.host, ctx.port, ctx.local_user);
851                    let hash = simple_hash(&hash_input);
852                    result.push_str(&hash);
853                }
854                Some('d') => {
855                    chars.next();
856                    result.push_str(ctx.home_dir);
857                }
858                Some('H') => {
859                    chars.next();
860                    result.push_str(ctx.canonical_host);
861                }
862                Some('h' | 'n') => {
863                    chars.next();
864                    result.push_str(ctx.host);
865                }
866                Some('L') => {
867                    // Short hostname (first component before '.').
868                    chars.next();
869                    let short = ctx
870                        .local_hostname
871                        .split('.')
872                        .next()
873                        .unwrap_or(ctx.local_hostname);
874                    result.push_str(short);
875                }
876                Some('l') => {
877                    chars.next();
878                    result.push_str(ctx.local_hostname);
879                }
880                Some('p') => {
881                    chars.next();
882                    result.push_str(ctx.port);
883                }
884                Some('r' | 'T') => {
885                    // %r and %T both expand to the remote username.
886                    chars.next();
887                    result.push_str(ctx.remote_user);
888                }
889                Some('i' | 'u') => {
890                    // %i and %u both expand to the local username.
891                    // Note: per OpenSSH, %i is the "identity file name" in some
892                    // contexts, but when used in IdentityFile paths it would create
893                    // a circular reference.  The local username fallback matches
894                    // OpenSSH behaviour for IdentityFile and most other directives.
895                    chars.next();
896                    result.push_str(ctx.local_user);
897                }
898                Some('k') => {
899                    // %k → local host key.
900                    chars.next();
901                    result.push_str(ctx.local_host_key);
902                }
903                Some('j') => {
904                    // %j → jump host.
905                    chars.next();
906                    result.push_str(ctx.jump_host);
907                }
908                Some('K') => {
909                    // %K → remote host key.
910                    chars.next();
911                    result.push_str(ctx.remote_host_key);
912                }
913                Some('t') => {
914                    // %t → remote port (same as %p).
915                    chars.next();
916                    result.push_str(ctx.port);
917                }
918                _ => {
919                    // Unknown token or '%' at end of string — keep as-is.
920                    result.push(ch);
921                }
922            }
923        } else {
924            result.push(ch);
925        }
926    }
927
928    result
929}
930
931/// Simple hash function for `%C` token (OpenSSH uses SHA-1 of host:port:user).
932///
933/// Uses a FNV-1a style hash for stability across Rust versions.  This is NOT
934/// cryptographically secure — it only needs to be deterministic and collision-
935/// resistant enough for socket naming.  OpenSSH uses SHA-1 here, but we avoid
936/// the dependency.
937fn simple_hash(s: &str) -> String {
938    let bytes = s.as_bytes();
939    // FNV-1a 64-bit parameters
940    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
941    let prime: u64 = 0x0100_0000_01b3;
942    for &b in bytes {
943        hash ^= u64::from(b);
944        hash = hash.wrapping_mul(prime);
945    }
946    format!("{hash:016x}")
947}
948
949/// Replace `%%` with a single `%` (OpenSSH escape convention).
950fn collapse_double_percent(s: &str) -> String {
951    s.replace("%%", "%")
952}
953
954/// Get the current username.
955fn whoami() -> String {
956    std::env::var("USER")
957        .or_else(|_| std::env::var("USERNAME"))
958        .unwrap_or_else(|_| "unknown".to_owned())
959}
960
961/// Get the local hostname.
962fn hostname() -> String {
963    std::env::var("HOSTNAME")
964        .unwrap_or_else(|_| gethostname::gethostname().to_string_lossy().into_owned())
965}
966
967/// Check if a hostname matches SSH config patterns (reuses directive logic).
968fn host_matches(host: &str, patterns: &[impl AsRef<str>]) -> bool {
969    super::directives::host_matches_patterns(host, patterns)
970}
971
972/// Check if `Match` criteria are satisfied for the given host context.
973///
974/// Supported criteria keywords (case-insensitive):
975/// - `host <patterns>` — matches against `target_host` (the hostname being
976///   resolved, which may be the canonical name on a second pass).
977/// - `originalhost <patterns>` — matches against `original_host` (the
978///   alias the user typed, before any canonicalization).
979/// - `user <names>` — matches against `target_user` (the remote username;
980///   comma-separated, case-insensitive comparison).
981/// - `localuser <names>` — matches against the local username;
982///   comma-separated, case-insensitive comparison.
983///
984/// Multiple occurrences of the **same** keyword are OR'd (e.g.
985/// `host web host db` matches either "web" or "db").  Different keywords
986/// are AND'd (e.g. `user alice host web` requires both to match).
987///
988/// Returns `true` only when every recognized criterion type matches and at
989/// least one recognized criterion is present.  Unrecognized keywords
990/// (e.g. `exec`, `address`) are silently skipped.
991fn match_criteria_host(
992    criteria: &str,
993    target_host: &str,
994    target_user: &str,
995    original_host: &str,
996) -> bool {
997    let mut tokens = criteria.split_whitespace();
998
999    // Track per-keyword-type state: whether the type appeared and whether
1000    // at least one occurrence matched (OR within a type).
1001    let mut has_host = false;
1002    let mut host_matched = false;
1003    let mut has_originalhost = false;
1004    let mut originalhost_matched = false;
1005    let mut has_user = false;
1006    let mut user_matched = false;
1007    let mut has_localuser = false;
1008    let mut localuser_matched = false;
1009
1010    // Determine the local username for localuser matching.
1011    let local_user = whoami();
1012
1013    while let Some(keyword) = tokens.next() {
1014        if keyword.eq_ignore_ascii_case("host") {
1015            if let Some(patterns_str) = tokens.next() {
1016                has_host = true;
1017                let patterns: Vec<&str> = patterns_str.split(',').collect();
1018                if host_matches(target_host, &patterns) {
1019                    host_matched = true;
1020                }
1021            }
1022        } else if keyword.eq_ignore_ascii_case("originalhost") {
1023            if let Some(patterns_str) = tokens.next() {
1024                has_originalhost = true;
1025                let patterns: Vec<&str> = patterns_str.split(',').collect();
1026                if host_matches(original_host, &patterns) {
1027                    originalhost_matched = true;
1028                }
1029            }
1030        } else if keyword.eq_ignore_ascii_case("user") {
1031            if let Some(names_str) = tokens.next() {
1032                has_user = true;
1033                // OpenSSH matches user names case-insensitively.
1034                let names: Vec<&str> = names_str.split(',').collect();
1035                if names.iter().any(|n| n.eq_ignore_ascii_case(target_user)) {
1036                    user_matched = true;
1037                }
1038            }
1039        } else if keyword.eq_ignore_ascii_case("localuser") {
1040            if let Some(names_str) = tokens.next() {
1041                has_localuser = true;
1042                let names: Vec<&str> = names_str.split(',').collect();
1043                if names.iter().any(|n| n.eq_ignore_ascii_case(&local_user)) {
1044                    localuser_matched = true;
1045                }
1046            }
1047        } else if keyword.eq_ignore_ascii_case("exec") {
1048            // `exec` consumes the rest of the criteria string as its command
1049            // (it is always the last criterion on a Match line per OpenSSH).
1050            // We intentionally do not evaluate exec — just skip the remainder.
1051            break;
1052        } else {
1053            // Unknown criterion keyword — consume its value token and skip.
1054            // Criteria like `address` fall here.
1055            tokens.next();
1056        }
1057    }
1058
1059    // At least one criterion type must be present.
1060    let any_known = has_host || has_originalhost || has_user || has_localuser;
1061    // All present types must have matched (AND across types).
1062    let all_matched = (!has_host || host_matched)
1063        && (!has_originalhost || originalhost_matched)
1064        && (!has_user || user_matched)
1065        && (!has_localuser || localuser_matched);
1066
1067    any_known && all_matched
1068}
1069
1070/// Check whether a Match criteria string contains an `exec` keyword.
1071///
1072/// The `exec` criterion runs an arbitrary command to determine whether
1073/// a Match block applies.  This is a security-sensitive operation that
1074/// toride intentionally does not support.
1075fn contains_exec_criteria(criteria: &str) -> bool {
1076    let mut tokens = criteria.split_whitespace();
1077    while let Some(keyword) = tokens.next() {
1078        if keyword.eq_ignore_ascii_case("exec") {
1079            return true;
1080        }
1081        // Each criterion keyword is followed by a value token.
1082        // If the keyword is unknown but not exec, just skip one value token.
1083        tokens.next();
1084    }
1085    false
1086}
1087
1088#[cfg(test)]
1089#[path = "resolve.test.rs"]
1090mod tests;