intial commit

This commit is contained in:
2026-01-28 21:17:28 +01:00
commit ba8537063f
5 changed files with 612 additions and 0 deletions

187
src/main.rs Normal file
View File

@@ -0,0 +1,187 @@
//! Evaluates [ssh_config(5)] files to find all **Host** declarations.
//!
//! **Include** directives will be resolved and evaluated. Pathnames will be expanded in the same
//! ways as in [ssh(1)], with the notable exception of runtime tokens (see "TOKENS" section in
//! [ssh_config(5)].
//!
//! Patterns given to any **Host** directive will be ignored (see "PATTERNS" section in
//! [ssh_config(5)]).
//!
//! [glob(7)]: <https://manpages.debian.org/buster/manpages/glob.7.en.html>
//! [ssh(1)]: <https://manpages.debian.org/testing/manpages-de/ssh.1.de.html>
//! [ssh_config(5)]: <https://manpages.debian.org/bullseye/openssh-client/ssh_config.5.en.html>
mod words;
use std::{
collections::HashSet,
env::{self},
fs::File,
io::{self, BufRead, BufReader, Read},
path::{Path, PathBuf},
process,
};
use clap::Parser;
use glob::glob;
use crate::words::{Keyword, Words, expand};
const SYSTEM_CONFIG: &str = "/etc/ssh/ssh_config";
/// List host names in ssh_config(5) files
#[derive(Debug, Parser)]
struct Cli {
/// This emulates the behaviour of the ssh(1) -F option
#[arg(short = 'F')]
file: Option<PathBuf>,
}
fn main() {
let cli = Cli::parse();
let filenames = match &cli.file {
Some(path) if path == "none" => vec![],
Some(path) => vec![path.clone()],
None => match env::home_dir() {
Some(home) => vec![SYSTEM_CONFIG.into(), home.join(".ssh/config")],
None => vec![SYSTEM_CONFIG.into()],
},
};
let hosts = {
let mut hosts = HashSet::new();
for filename in filenames {
match find_hosts(&filename) {
Ok(h) => hosts.extend(h),
Err(err) => {
eprintln!("{err}");
process::exit(1);
}
};
}
let mut hosts = Vec::from_iter(hosts.into_iter());
hosts.sort();
hosts
};
for host in hosts {
println!("{host}");
}
}
fn find_hosts(filename: &Path) -> io::Result<HashSet<String>> {
if !filename.is_file() {
return Err(io::Error::other("no such file"));
}
let mut reader = {
let file = File::open(filename)?;
BufReader::new(file)
};
let mut hosts = HashSet::new();
let mut buf = String::new();
loop {
buf.clear();
let n = (&mut reader).take(1024 * 16).read_line(&mut buf)?;
if n == 0 {
return Ok(hosts);
}
let line = buf[..n].trim();
let mut words: Words<'_> = line.into();
let Some(Ok(directive)) = words.next() else {
continue;
};
let Ok(keyword) = Keyword::try_from(directive.as_str()) else {
continue;
};
match keyword {
Keyword::Include => find_hosts_in_include_directive(&mut hosts, words),
Keyword::Host => find_hosts_in_host_directive(&mut hosts, words),
}
}
}
fn find_hosts_in_include_directive(hosts: &mut HashSet<String>, words: Words<'_>) {
for pattern in words {
let Ok(pattern) = pattern else {
// TODO: print warning
continue;
};
// Expanding env vars before globbing is what ssh does as well.
let Ok(pattern) = expand(&pattern) else {
// TODO: print warning
continue;
};
let Ok(paths) = glob(&pattern) else {
// TODO: print warning
continue;
};
for path in paths {
// TODO: print warning
let Ok(path) = path else {
// TODO: print warning
continue;
};
let Ok(ihosts) = find_hosts(&path) else {
// TODO: print warning
continue;
};
hosts.extend(ihosts);
}
}
}
fn find_hosts_in_host_directive(hosts: &mut HashSet<String>, words: Words<'_>) {
for host in words {
let Ok(host) = host else {
// TODO: print warning
continue;
};
if host.contains(['*', '?', '!', ',']) {
// Ignore patterns (see ssh_config(5) "PATTERNS").
continue;
}
hosts.insert(host.into());
}
}
#[cfg(test)]
mod tests {
use clap::CommandFactory;
use super::*;
#[test]
fn cli() {
Cli::command().debug_assert();
}
#[test]
fn test() {
let mut hosts = HashSet::new();
find_hosts_in_host_directive(&mut hosts, "foo.test".into());
assert!(hosts.contains("foo.test"));
find_hosts_in_host_directive(&mut hosts, "*".into());
assert!(!hosts.contains("*"));
find_hosts_in_host_directive(&mut hosts, "bar.test *.test".into());
assert!(hosts.contains("bar.test"));
assert!(!hosts.contains("*.test"));
}
}