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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

193
Cargo.lock generated Normal file
View File

@@ -0,0 +1,193 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "clap"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "lssh"
version = "0.1.0"
dependencies = [
"clap",
"glob",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]

8
Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[package]
name = "lssh"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.5.55", features = ["derive"] }
glob = "0.3.3"

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"));
}
}

223
src/words.rs Normal file
View File

@@ -0,0 +1,223 @@
use std::{collections::HashMap, env, path::PathBuf, str::Chars, sync::OnceLock};
static EXPANDER: OnceLock<Env> = OnceLock::new();
/// Missing `'\''`, `'"'` or `'}'` delimiter.
#[derive(Debug)]
pub struct MissingDelimiter;
/// Iterator over words in str.
pub struct Words<'a> {
chars: Chars<'a>,
}
pub enum Keyword {
Host,
Include,
}
/// Context that can expand words.
struct Env {
dir: PathBuf,
vars: HashMap<String, String>,
}
/// Expand env vars in the given word.
pub fn expand(word: &str) -> Result<String, MissingDelimiter> {
EXPANDER.get_or_init(|| Env::from_env()).expand(word)
}
impl Iterator for Words<'_> {
type Item = Result<String, MissingDelimiter>;
fn next(&mut self) -> Option<Self::Item> {
let ch = loop {
let ch = self.chars.next()?;
if !ch.is_whitespace() {
break ch;
}
};
let mut word = String::new();
match ch {
'\'' | '"' => {
let delim = ch;
loop {
match self.chars.next() {
Some(ch) if ch == delim => {
return Some(Ok(word));
}
Some(ch) => word.push(ch),
None => return Some(Err(MissingDelimiter)),
}
}
}
ch => {
word.push(ch);
loop {
match self.chars.next() {
Some(ch) if ch.is_whitespace() => return Some(Ok(word)),
Some(ch) => word.push(ch),
None => return Some(Ok(word)),
}
}
}
};
}
}
impl TryFrom<&str> for Keyword {
type Error = ();
fn try_from(value: &str) -> Result<Keyword, Self::Error> {
let keyword = match value.to_lowercase().as_str() {
"host" => Keyword::Host,
"include" => Keyword::Include,
_ => return Err(()),
};
Ok(keyword)
}
}
impl<'a> From<&'a str> for Words<'a> {
fn from(value: &'a str) -> Self {
Words {
chars: value.chars(),
}
}
}
impl Env {
fn from_env() -> Self {
let mut vars = HashMap::new();
for (var, val) in env::vars_os() {
let Some(var) = var.to_str() else {
continue;
};
let Some(val) = val.to_str() else {
continue;
};
vars.insert(var.to_string(), val.to_string());
}
Self {
dir: env::home_dir().unwrap_or_else(|| PathBuf::from("~")),
vars,
}
}
fn dir_str(&self) -> &str {
self.dir.to_str().unwrap_or("~")
}
fn expand(&self, word: &str) -> Result<String, MissingDelimiter> {
let mut chars = word.chars();
let mut expanded = String::new();
'outer: while let Some(ch) = chars.next() {
match ch {
'$' => {
let Some(ch) = chars.next() else {
expanded.push('$');
break;
};
if ch != '{' {
expanded.push('$');
expanded.push(ch);
break;
}
let mut var = String::new();
while let Some(ch) = chars.next() {
match ch {
'}' => match self.vars.get(&var) {
Some(val) if val == "~" => {
// ssh -vG indeed shows this to be true
expanded.push_str(self.dir_str());
continue 'outer;
}
Some(val) => {
expanded.push_str(val);
continue 'outer;
}
None => {
// TODO: I *think* the word must be ignored if the var is not
// set?
expanded.push('$');
expanded.push('{');
expanded.push_str(&var);
expanded.push('}');
continue 'outer;
}
},
ch => var.push(ch),
}
}
return Err(MissingDelimiter);
}
'~' => expanded.push_str(self.dir_str()),
ch => expanded.push(ch),
}
}
Ok(expanded)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn words() {
let mut words = Words::from("foo bar baz");
assert_eq!(words.next().unwrap().unwrap(), "foo");
assert_eq!(words.next().unwrap().unwrap(), "bar");
assert_eq!(words.next().unwrap().unwrap(), "baz");
assert!(words.next().is_none());
let mut words = Words::from("foo \"bar baz\"");
assert_eq!(words.next().unwrap().unwrap(), "foo");
assert_eq!(words.next().unwrap().unwrap(), "bar baz");
assert!(words.next().is_none());
let mut words = Words::from("foo 'bar baz'");
assert_eq!(words.next().unwrap().unwrap(), "foo");
assert_eq!(words.next().unwrap().unwrap(), "bar baz");
assert!(words.next().is_none());
let mut words = Words::from("foo 'bar baz\"");
assert_eq!(words.next().unwrap().unwrap(), "foo");
assert!(words.next().unwrap().is_err());
assert!(words.next().is_none());
}
#[test]
fn expand() {
let expander = Env {
dir: PathBuf::from("/home/jhill"),
vars: HashMap::from([
("FOO".to_string(), "foo".to_string()),
("BAR".to_string(), "bar".to_string()),
("BAZ".to_string(), "baz".to_string()),
("WEIRD".to_string(), "~".to_string()),
]),
};
assert_eq!(expander.expand("~").unwrap(), "/home/jhill");
assert_eq!(expander.expand("FOO ${BAR} ${BAZ}").unwrap(), "FOO bar baz");
assert_eq!(expander.expand("~/${FOO}").unwrap(), "/home/jhill/foo");
assert_eq!(
expander.expand("${WEIRD}/${FOO}").unwrap(),
"/home/jhill/foo"
);
// TODO: Is this emulating the ssh behaviour?
assert_eq!(expander.expand("foo ${QUX} baz").unwrap(), "foo ${QUX} baz");
assert!(expander.expand("foo ${BAR baz").is_err());
}
}