use crate::utils::{
ignore::pattern_matches,
paths::{expand_tilde, home_dir},
};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SshHost {
pub alias: String,
pub hostname: String,
pub user: Option<String>,
pub port: Option<u16>,
pub identity_file: Option<String>,
pub proxy_jump: Option<String>,
}
pub fn ssh_dir() -> Option<PathBuf> {
let home = home_dir();
(!home.as_os_str().is_empty()).then(|| home.join(".ssh"))
}
pub fn parse_default() -> Vec<SshHost> {
let Some(dir) = ssh_dir() else {
return Vec::new();
};
parse_file(&dir.join("config"), &dir)
}
pub fn lookup(alias: &str) -> Option<SshHost> {
parse_default().into_iter().find(|h| {
h.alias == alias
|| h.hostname == alias
|| (h.alias.contains(['*', '?']) && pattern_matches(&h.alias, alias))
})
}
pub fn parse_file(path: &Path, ssh_dir: &Path) -> Vec<SshHost> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
parse_str(&text, ssh_dir)
}
pub fn parse_str(text: &str, ssh_dir: &Path) -> Vec<SshHost> {
let mut hosts: Vec<SshHost> = Vec::new();
let mut current: Option<usize> = None;
let mut seen: Vec<u8> = Vec::new();
let mut include_targets: Vec<PathBuf> = Vec::new();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = match line.split_once('=') {
Some((k, v)) => (k.trim(), v.trim()),
None => match line.split_once(char::is_whitespace) {
Some((k, v)) => (k.trim(), v.trim()),
None => (line, ""),
},
};
let key_lower = key.to_ascii_lowercase();
if key_lower == "host" {
let pattern = value.split_whitespace().next().unwrap_or("");
hosts.push(SshHost {
alias: pattern.to_string(),
hostname: pattern.to_string(),
..Default::default()
});
current = Some(hosts.len() - 1);
seen.push(0);
continue;
}
let Some(idx) = current else {
if key_lower == "include" {
include_targets.push(expand_include(value, ssh_dir));
}
continue;
};
let Some(host) = hosts.get_mut(idx) else {
continue;
};
let flags = &mut seen[idx];
match key_lower.as_str() {
"hostname" => {
if *flags & 1 == 0 {
host.hostname = value.to_string();
*flags |= 1;
}
}
"user" => {
if *flags & 2 == 0 {
host.user = Some(value.to_string());
*flags |= 2;
}
}
"port" => {
if *flags & 4 == 0
&& let Ok(p) = value.parse::<u16>()
&& p > 0
{
host.port = Some(p);
*flags |= 4;
}
}
"identityfile" => {
if *flags & 8 == 0 {
host.identity_file = Some(
expand_tilde(PathBuf::from(value))
.to_string_lossy()
.to_string(),
);
*flags |= 8;
}
}
"proxyjump" => {
if *flags & 16 == 0 && !value.eq_ignore_ascii_case("none") {
host.proxy_jump = Some(value.to_string());
*flags |= 16;
}
}
"include" => include_targets.push(expand_include(value, ssh_dir)),
_ => {}
}
}
for target in include_targets {
hosts.extend(parse_file(&target, ssh_dir));
}
hosts
}
fn expand_include(value: &str, ssh_dir: &Path) -> PathBuf {
let expanded = expand_tilde(PathBuf::from(value.trim()));
if expanded.is_absolute() {
expanded
} else {
ssh_dir.join(expanded)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
Host gpu-box
HostName gpu.example.com
User root
Port 2202
IdentityFile ~/.ssh/id_ed25519
Host *.internal
User deploy
Host *
User fallback
"#;
#[test]
fn parses_host_blocks() {
let hosts = parse_str(SAMPLE, Path::new("/home/u/.ssh"));
assert_eq!(hosts.len(), 3);
assert_eq!(hosts[0].hostname, "gpu.example.com");
assert_eq!(hosts[0].user.as_deref(), Some("root"));
assert_eq!(hosts[0].port, Some(2202));
assert!(
hosts[0]
.identity_file
.as_deref()
.unwrap()
.ends_with(".ssh/id_ed25519")
);
assert_eq!(hosts[1].hostname, "*.internal");
}
#[test]
fn glob_patterns() {
assert!(pattern_matches("gpu-*", "gpu-1"));
assert!(pattern_matches("gpu-?", "gpu-1"));
assert!(!pattern_matches("gpu-?", "gpu-11"));
assert!(pattern_matches("!gpu-1", "gpu-2"));
assert!(!pattern_matches("!gpu-1", "gpu-1"));
}
}