From ba8537063f8c6f6856436fdd4bc802905a331b2e Mon Sep 17 00:00:00 2001 From: Jonas Kattendick Date: Wed, 28 Jan 2026 21:17:28 +0100 Subject: [PATCH] intial commit --- .gitignore | 1 + Cargo.lock | 193 ++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 8 ++ src/main.rs | 187 ++++++++++++++++++++++++++++++++++++++++++ src/words.rs | 223 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 612 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/main.rs create mode 100644 src/words.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ee6db6a --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2fdbdef --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e046b6f --- /dev/null +++ b/src/main.rs @@ -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)]: +//! [ssh(1)]: +//! [ssh_config(5)]: + +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, +} + +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> { + 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, 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, 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")); + } +} diff --git a/src/words.rs b/src/words.rs new file mode 100644 index 0000000..884602a --- /dev/null +++ b/src/words.rs @@ -0,0 +1,223 @@ +use std::{collections::HashMap, env, path::PathBuf, str::Chars, sync::OnceLock}; + +static EXPANDER: OnceLock = 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, +} + +/// Expand env vars in the given word. +pub fn expand(word: &str) -> Result { + EXPANDER.get_or_init(|| Env::from_env()).expand(word) +} + +impl Iterator for Words<'_> { + type Item = Result; + + fn next(&mut self) -> Option { + 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 { + 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 { + 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()); + } +}