use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::origin::guard;
const MAX_INCLUDE_DEPTH: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Host {
pub host: String,
pub alias: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Unusable {
pub host: String,
pub why: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Found {
pub hosts: Vec<Host>,
pub unusable: Vec<Unusable>,
}
pub fn default_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.filter(|h| !h.is_empty())?;
Some(PathBuf::from(home).join(".ssh").join("config"))
}
pub fn read() -> Result<Found> {
let Some(path) = default_path() else {
return Ok(Found::default());
};
read_from(&path)
}
pub fn read_from(path: &Path) -> Result<Found> {
if !path.exists() {
return Ok(Found::default());
}
let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
let mut text = String::new();
gather(path, &root, 0, &mut text)?;
Ok(parse(&text))
}
fn gather(path: &Path, root: &Path, depth: usize, out: &mut String) -> Result<()> {
if depth > MAX_INCLUDE_DEPTH {
anyhow::bail!(
"ssh_config includes nest more than {MAX_INCLUDE_DEPTH} deep at {}",
path.display()
);
}
let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
for line in text.lines() {
match include_target(line) {
Some(pattern) => {
for file in expand(pattern, root) {
if file.is_file() {
gather(&file, root, depth + 1, out)?;
}
}
}
None => {
out.push_str(line);
out.push('\n');
}
}
}
Ok(())
}
fn include_target(line: &str) -> Option<&str> {
let (keyword, rest) = keyword_and_rest(line)?;
keyword.eq_ignore_ascii_case("include").then_some(rest)
}
fn keyword_and_rest(line: &str) -> Option<(&str, &str)> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let end = line
.find(|c: char| c.is_ascii_whitespace() || c == '=')
.unwrap_or(line.len());
let (keyword, rest) = line.split_at(end);
Some((keyword, rest.trim_start_matches(['=', ' ', '\t']).trim()))
}
fn expand(pattern: &str, root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for word in pattern.split_ascii_whitespace() {
let word = word.trim_matches('"');
let resolved = if let Some(rest) = word.strip_prefix("~/") {
match std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
Some(home) => PathBuf::from(home).join(rest),
None => continue,
}
} else if Path::new(word).is_absolute() {
PathBuf::from(word)
} else {
root.join(word)
};
let Some(last) = resolved.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !last.contains(['*', '?']) {
out.push(resolved);
continue;
}
let Some(dir) = resolved.parent() else {
continue;
};
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
let mut matched: Vec<PathBuf> = entries
.flatten()
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|name| glob_matches(last, name))
})
.map(|e| e.path())
.collect();
matched.sort();
out.extend(matched);
}
out
}
fn glob_matches(pattern: &str, name: &str) -> bool {
let (p, n): (Vec<char>, Vec<char>) = (pattern.chars().collect(), name.chars().collect());
let (mut pi, mut ni) = (0, 0);
let (mut star, mut resume) = (None, 0);
while ni < n.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
pi += 1;
ni += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
resume = ni;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
resume += 1;
ni = resume;
} else {
return false;
}
}
p[pi..].iter().all(|&c| c == '*')
}
pub fn parse(text: &str) -> Found {
let mut found = Found::default();
let mut seen: Vec<String> = Vec::new();
for line in text.lines() {
let Some((keyword, rest)) = keyword_and_rest(line) else {
continue;
};
if !keyword.eq_ignore_ascii_case("host") {
continue;
}
for name in rest.split_ascii_whitespace() {
let name = name.trim_matches('"');
if name.is_empty() || name.contains(['*', '?']) || name.starts_with('!') {
continue;
}
let alias = name.to_ascii_lowercase();
if seen.iter().any(|s| s == &alias) {
continue;
}
seen.push(alias.clone());
if guard::is_label(&alias) {
found.hosts.push(Host {
host: name.to_string(),
alias,
});
} else {
found.unusable.push(Unusable {
host: name.to_string(),
why: "not usable as a hostname label: give it an alias in the config file"
.to_string(),
});
}
}
}
found
}
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Settings {
pub user: Option<String>,
pub hostname: Option<String>,
pub port: Option<u16>,
#[serde(rename = "proxyJump")]
pub proxy_jump: Option<String>,
}
pub fn parse_settings(text: &str) -> Settings {
let mut s = Settings::default();
for line in text.lines() {
let Some((key, value)) = line.trim().split_once(' ') else {
continue;
};
let value = value.trim();
match key.to_ascii_lowercase().as_str() {
"user" => s.user = Some(value.to_string()),
"hostname" => s.hostname = Some(value.to_string()),
"port" => s.port = value.parse().ok(),
"proxyjump" if !value.eq_ignore_ascii_case("none") => {
s.proxy_jump = Some(value.to_string());
}
_ => {}
}
}
s
}
pub async fn describe(host: &str) -> Result<Settings> {
let out = tokio::process::Command::new("ssh")
.arg("-G")
.arg(host)
.output()
.await
.with_context(|| format!("run ssh -G {host}"))?;
Ok(parse_settings(&String::from_utf8_lossy(&out.stdout)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_hosts_of_a_real_looking_config_are_found_in_order() {
let found = parse(
"Host Panza\n HostName panza.example\n\nHost yukawa-front\n ProxyJump yukawa-mercury\n",
);
assert_eq!(
found.hosts,
vec![
Host {
host: "Panza".to_string(),
alias: "panza".to_string()
},
Host {
host: "yukawa-front".to_string(),
alias: "yukawa-front".to_string()
},
]
);
assert!(found.unusable.is_empty());
}
#[test]
fn patterns_are_not_hosts() {
let found = parse("Host *\n ForwardAgent yes\nHost *.example.com\nHost !bad ok\n");
assert_eq!(
found.hosts,
vec![Host {
host: "ok".to_string(),
alias: "ok".to_string()
}]
);
}
#[test]
fn one_line_can_name_several_hosts() {
let found = parse("Host alpha beta gamma\n");
let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
assert_eq!(aliases, ["alpha", "beta", "gamma"]);
}
#[test]
fn the_odd_spellings_ssh_config_allows_are_understood() {
let found = parse("# a comment\n\n host=Odd\n\tHOST Other\n");
let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
assert_eq!(aliases, ["odd", "other"]);
}
#[test]
fn a_host_named_twice_in_different_cases_is_one_alias() {
let found = parse("Host Panza\nHost panza\n");
assert_eq!(found.hosts.len(), 1);
assert_eq!(found.hosts[0].host, "Panza");
}
#[test]
fn a_host_that_cannot_be_a_label_is_reported_rather_than_dropped() {
let found = parse("Host build.example.com\nHost fine\n");
let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
assert_eq!(aliases, ["fine"]);
assert_eq!(found.unusable.len(), 1);
assert_eq!(found.unusable[0].host, "build.example.com");
}
#[test]
fn ssh_dash_g_output_is_read_for_the_fields_worth_showing() {
let s = parse_settings(
"user souta\nhostname 10.0.0.2\nport 2222\nproxyjump bastion\nforwardagent yes\n",
);
assert_eq!(s.user.as_deref(), Some("souta"));
assert_eq!(s.hostname.as_deref(), Some("10.0.0.2"));
assert_eq!(s.port, Some(2222));
assert_eq!(s.proxy_jump.as_deref(), Some("bastion"));
}
#[test]
fn proxyjump_none_is_no_proxy_jump() {
assert_eq!(parse_settings("proxyjump none\n").proxy_jump, None);
}
#[test]
fn globs_match_the_way_include_needs() {
assert!(glob_matches("*", "anything"));
assert!(glob_matches("*.conf", "work.conf"));
assert!(glob_matches("a?c", "abc"));
assert!(!glob_matches("a?c", "ac"));
assert!(!glob_matches("*.conf", "conf.bak"));
assert!(glob_matches("*a*b*", "xxayybzz"));
}
#[test]
fn include_pulls_in_another_file() {
let dir = std::env::temp_dir().join(format!("ssh-browser-inc-{}", std::process::id()));
let sub = dir.join("config.d");
std::fs::create_dir_all(&sub).expect("temp dirs");
std::fs::write(sub.join("10-work.conf"), "Host from-include\n").expect("write include");
std::fs::write(dir.join("config"), "Host direct\nInclude config.d/*\n").expect("write");
let found = read_from(&dir.join("config")).expect("reads");
let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
assert_eq!(aliases, ["direct", "from-include"]);
std::fs::remove_dir_all(&dir).ok();
}
}