use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Host {
pub name: String,
pub aliases: Vec<String>,
}
impl Host {
pub fn matches(&self, needle: &str) -> bool {
std::iter::once(&self.name)
.chain(&self.aliases)
.any(|n| n.to_lowercase().contains(needle))
}
}
const MAX_DEPTH: usize = 16;
pub fn hosts() -> Vec<Host> {
let Some(ssh_dir) = dirs::home_dir().map(|h| h.join(".ssh")) else {
return Vec::new();
};
let mut out = Vec::new();
read_into(&ssh_dir.join("config"), &ssh_dir, 0, &mut out);
out
}
fn read_into(path: &Path, ssh_dir: &Path, depth: usize, out: &mut Vec<Host>) {
if depth > MAX_DEPTH {
return;
}
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
for line in parse(&text) {
match line {
Line::Host(host) => {
if !out.iter().any(|h| h.name == host.name) {
out.push(host);
}
}
Line::Include(patterns) => {
for pattern in patterns {
for file in expand_include(&pattern, ssh_dir) {
read_into(&file, ssh_dir, depth + 1, out);
}
}
}
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum Line {
Host(Host),
Include(Vec<String>),
}
fn parse(text: &str) -> Vec<Line> {
let mut out = Vec::new();
for raw in text.lines() {
let line = strip_comment(raw).trim();
if line.is_empty() {
continue;
}
let (keyword, rest) = split_keyword(line);
let words = words(rest);
if keyword.eq_ignore_ascii_case("host") {
let mut names = words.into_iter().filter(|w| is_concrete(w));
if let Some(name) = names.next() {
out.push(Line::Host(Host {
name,
aliases: names.collect(),
}));
}
} else if keyword.eq_ignore_ascii_case("include") {
out.push(Line::Include(words));
}
}
out
}
fn strip_comment(line: &str) -> &str {
let mut prev_blank = true;
for (at, c) in line.char_indices() {
if c == '#' && prev_blank {
return &line[..at];
}
prev_blank = c.is_whitespace();
}
line
}
fn split_keyword(line: &str) -> (&str, &str) {
let end = line
.find(|c: char| c.is_whitespace() || c == '=')
.unwrap_or(line.len());
let rest = line[end..].trim_start();
let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
(&line[..end], rest)
}
fn words(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut word = String::new();
let mut quoted = false;
let mut any = false;
for c in text.chars() {
match c {
'"' => {
quoted = !quoted;
any = true;
}
c if c.is_whitespace() && !quoted => {
if any {
out.push(std::mem::take(&mut word));
any = false;
}
}
c => {
word.push(c);
any = true;
}
}
}
if any {
out.push(word);
}
out.retain(|w| !w.is_empty());
out
}
fn is_concrete(word: &str) -> bool {
!word.contains(['*', '?']) && !word.starts_with('!')
}
fn expand_include(pattern: &str, ssh_dir: &Path) -> Vec<PathBuf> {
let path = match pattern.strip_prefix("~/") {
Some(rest) => match dirs::home_dir() {
Some(home) => home.join(rest),
None => return Vec::new(),
},
None => ssh_dir.join(pattern),
};
let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else {
return Vec::new();
};
if !name.contains(['*', '?']) {
return vec![path];
}
let Some(dir) = path.parent() else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.filter(|e| wildcard(&name, &e.file_name().to_string_lossy()))
.map(|e| e.path())
.filter(|p| p.is_file())
.collect();
files.sort();
files
}
fn wildcard(pattern: &str, name: &str) -> bool {
fn go(p: &[char], n: &[char]) -> bool {
match (p.first(), n.first()) {
(None, None) => true,
(Some('*'), _) => go(&p[1..], n) || (!n.is_empty() && go(p, &n[1..])),
(Some('?'), Some(_)) => go(&p[1..], &n[1..]),
(Some(a), Some(b)) if a == b => go(&p[1..], &n[1..]),
_ => false,
}
}
if name.starts_with('.') && !pattern.starts_with('.') {
return false;
}
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
go(&p, &n)
}
#[cfg(test)]
mod tests {
use super::*;
fn hosts_of(text: &str) -> Vec<Host> {
parse(text)
.into_iter()
.filter_map(|line| match line {
Line::Host(h) => Some(h),
Line::Include(_) => None,
})
.collect()
}
fn host(name: &str, aliases: &[&str]) -> Host {
Host {
name: name.into(),
aliases: aliases.iter().map(|a| a.to_string()).collect(),
}
}
#[test]
fn wildcards_and_negations_name_no_host() {
let text = "\
Host *
ServerAliveInterval 30
Host *.internal !bastion
User me
Host devbox
HostName 10.0.0.5
Host web-? db
";
assert_eq!(hosts_of(text), vec![host("devbox", &[]), host("db", &[])]);
}
#[test]
fn several_names_on_one_line_are_one_host_with_aliases() {
let text = "Host nz-b-procurementdb1 procdb\n User flo\n";
assert_eq!(
hosts_of(text),
vec![host("nz-b-procurementdb1", &["procdb"])]
);
let h = &hosts_of(text)[0];
assert!(h.matches("procdb"));
assert!(h.matches("procurement"));
assert!(!h.matches("web"));
}
#[test]
fn comments_case_and_equals_are_read_as_ssh_reads_them() {
let text = "\
# Host commented-out
host lower # a trailing comment
HOST=upper
Host indented\tother
Hostname not-a-host-line
Host \"quoted name\" with#hash
";
assert_eq!(
hosts_of(text),
vec![
host("lower", &[]),
host("upper", &[]),
host("indented", &["other"]),
host("quoted name", &["with#hash"]),
]
);
}
#[test]
fn includes_are_followed_relative_to_dot_ssh_in_sorted_order() {
let dir = tempfile::tempdir().expect("tempdir");
let ssh = dir.path();
std::fs::create_dir(ssh.join("config.d")).expect("mkdir");
std::fs::write(
ssh.join("config"),
"Include config.d/*\nHost main\nInclude config\n",
)
.expect("write");
std::fs::write(ssh.join("config.d/20-b"), "Host second main\n").expect("write");
std::fs::write(ssh.join("config.d/10-a"), "Host first\n").expect("write");
std::fs::write(ssh.join("config.d/.hidden"), "Host hidden\n").expect("write");
let mut out = Vec::new();
read_into(&ssh.join("config"), ssh, 0, &mut out);
let names: Vec<&str> = out.iter().map(|h| h.name.as_str()).collect();
assert_eq!(names, ["first", "second", "main"]);
}
#[test]
fn a_wildcard_matches_one_file_name() {
assert!(wildcard("*", "work"));
assert!(wildcard("*.conf", "a.conf"));
assert!(!wildcard("*.conf", "a.conf.bak"));
assert!(wildcard("h?st", "host"));
assert!(!wildcard("*", ".hidden"));
}
}