use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SshAlias { pub name: String, pub source: PathBuf, } pub fn discover_ssh_aliases() -> Vec { let Some(home) = std::env::var_os("HOME") else { return Vec::new(); }; let config_path = PathBuf::from(home).join(".ssh/config"); discover_from_file(&config_path) } pub fn discover_from_file(path: &Path) -> Vec { let mut visited = HashSet::new(); let mut aliases = Vec::new(); discover_inner(path, &mut visited, &mut aliases); dedupe_aliases(aliases) } fn discover_inner(path: &Path, visited: &mut HashSet, aliases: &mut Vec) { let Ok(canonical) = path.canonicalize() else { return; }; if !visited.insert(canonical.clone()) { return; } let Ok(content) = fs::read_to_string(&canonical) else { return; }; for line in content.lines() { let trimmed = trim_comment(line); let mut parts = trimmed.split_whitespace(); let Some(keyword) = parts.next() else { continue; }; if keyword.eq_ignore_ascii_case("host") { aliases.extend(parts.filter_map(|name| { is_concrete_alias(name).then(|| SshAlias { name: name.to_owned(), source: canonical.clone(), }) })); } else if keyword.eq_ignore_ascii_case("include") { for include in parts { for include_path in expand_include_path(include, canonical.parent()) { discover_inner(&include_path, visited, aliases); } } } } } fn dedupe_aliases(aliases: Vec) -> Vec { let mut seen = HashSet::new(); let mut deduped = Vec::new(); for alias in aliases { if seen.insert(alias.name.clone()) { deduped.push(alias); } } deduped } fn trim_comment(line: &str) -> &str { line.split('#').next().unwrap_or("").trim() } fn is_concrete_alias(value: &str) -> bool { !value.is_empty() && !value.starts_with('-') && !value.starts_with('!') && !value.contains('*') && !value.contains('?') && !value.contains('%') && !value.contains('/') } fn expand_include_path(raw: &str, parent: Option<&Path>) -> Vec { if raw.contains('*') || raw.contains('?') { // 中文注释:SSH Include 支持复杂 glob;面板只解析普通文件,避免误扫过大目录。 return Vec::new(); } let expanded = if let Some(rest) = raw.strip_prefix("~/") { std::env::var_os("HOME") .map(PathBuf::from) .map(|home| home.join(rest)) } else { let path = PathBuf::from(raw); if path.is_absolute() { Some(path) } else { parent.map(|base| base.join(path)) } }; expanded.into_iter().collect() } #[cfg(test)] mod tests { use super::*; #[test] fn host_parser_ignores_wildcards_and_negations() { let mut aliases = Vec::new(); let source = PathBuf::from("/tmp/config"); for line in [ "Host dev release *.internal !blocked", "Host github.com", "Host ?pattern", "Host -bad", ] { let trimmed = trim_comment(line); let mut parts = trimmed.split_whitespace(); let keyword = parts.next().unwrap(); if keyword.eq_ignore_ascii_case("host") { aliases.extend(parts.filter_map(|name| { is_concrete_alias(name).then(|| SshAlias { name: name.to_owned(), source: source.clone(), }) })); } } let names: Vec<_> = dedupe_aliases(aliases) .into_iter() .map(|alias| alias.name) .collect(); assert_eq!(names, ["dev", "release", "github.com"]); } #[test] fn comment_trimming_keeps_plain_aliases() { assert_eq!(trim_comment(" Host dev # release host "), "Host dev"); } }