From 6108b3f481d52edbd2db41d41d6f7faee2a6c3a2 Mon Sep 17 00:00:00 2001 From: Jonas Kattendick Date: Sun, 1 Feb 2026 15:36:09 +0100 Subject: [PATCH] fix: handle = syntax and some tokens --- Cargo.toml | 2 + src/main.rs | 44 +++--- src/ssh.rs | 399 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/words.rs | 223 ---------------------------- 4 files changed, 422 insertions(+), 246 deletions(-) create mode 100644 src/ssh.rs delete mode 100644 src/words.rs diff --git a/Cargo.toml b/Cargo.toml index 2fdbdef..c700200 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,7 @@ [package] name = "lssh" +description = "List host names in ssh_config(5) files" +license = "MIT" version = "0.1.0" edition = "2024" diff --git a/src/main.rs b/src/main.rs index e046b6f..abffde1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ //! [ssh(1)]: //! [ssh_config(5)]: -mod words; +mod ssh; use std::{ collections::HashSet, @@ -25,9 +25,7 @@ use std::{ use clap::Parser; use glob::glob; -use crate::words::{Keyword, Words, expand}; - -const SYSTEM_CONFIG: &str = "/etc/ssh/ssh_config"; +use crate::ssh::{SSH_SYSTEM_CONFIG, SSH_USER_CONFIG, Words, expand_inlude_args, is_host_pattern}; /// List host names in ssh_config(5) files #[derive(Debug, Parser)] @@ -44,8 +42,8 @@ fn main() { 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()], + Some(home) => vec![SSH_SYSTEM_CONFIG.into(), home.join(SSH_USER_CONFIG)], + None => vec![SSH_SYSTEM_CONFIG.into()], }, }; @@ -61,7 +59,7 @@ fn main() { }; } - let mut hosts = Vec::from_iter(hosts.into_iter()); + let mut hosts = Vec::from_iter(hosts); hosts.sort(); hosts }; @@ -94,30 +92,31 @@ fn find_hosts(filename: &Path) -> io::Result> { 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 { + let Some(Ok(keyword)) = words.next() else { continue; }; match keyword { - Keyword::Include => find_hosts_in_include_directive(&mut hosts, words), - Keyword::Host => find_hosts_in_host_directive(&mut hosts, words), + k if k.eq_ignore_ascii_case("include") => { + find_hosts_in_include_directive(&mut hosts, words); + } + k if k.eq_ignore_ascii_case("host") => { + find_hosts_in_host_directive(&mut hosts, words); + } + _ => continue, } } } fn find_hosts_in_include_directive(hosts: &mut HashSet, words: Words<'_>) { - for pattern in words { - let Ok(pattern) = pattern else { + for arg in words { + let Ok(arg) = arg else { // TODO: print warning continue; }; // Expanding env vars before globbing is what ssh does as well. - let Ok(pattern) = expand(&pattern) else { + let Ok(pattern) = expand_inlude_args(arg) else { // TODO: print warning continue; }; @@ -145,18 +144,17 @@ fn find_hosts_in_include_directive(hosts: &mut HashSet, words: Words<'_> } fn find_hosts_in_host_directive(hosts: &mut HashSet, words: Words<'_>) { - for host in words { - let Ok(host) = host else { + for word in words { + let Ok(word) = word else { // TODO: print warning continue; }; - if host.contains(['*', '?', '!', ',']) { - // Ignore patterns (see ssh_config(5) "PATTERNS"). + if is_host_pattern(word) { continue; } - hosts.insert(host.into()); + hosts.insert(word.to_string()); } } @@ -172,7 +170,7 @@ mod tests { } #[test] - fn test() { + fn find_hosts() { let mut hosts = HashSet::new(); find_hosts_in_host_directive(&mut hosts, "foo.test".into()); assert!(hosts.contains("foo.test")); diff --git a/src/ssh.rs b/src/ssh.rs new file mode 100644 index 0000000..6767fe8 --- /dev/null +++ b/src/ssh.rs @@ -0,0 +1,399 @@ +use std::{collections::HashMap, env, path::PathBuf, str::CharIndices, sync::OnceLock}; + +pub const SSH_SYSTEM_CONFIG: &str = "/etc/ssh/ssh_config"; +pub const SSH_USER_CONFIG: &str = ".ssh/config"; + +static ENV: OnceLock = OnceLock::new(); + +#[derive(Debug, PartialEq)] +pub struct InvalidQuotesError; + +#[derive(Debug, PartialEq)] +pub enum ExpandError { + /// Missing closing delimiter `}`. + EnvMissingClosing { var: String }, + /// The environment variable does not exist. + EnvNoValue { var: String }, + /// The environment variable has not name (`${}`). + EnvZeroLen, + /// The token was invalid for the keyword. + TokenUnkownKey { token: char }, + /// A token was expected, but was missing. + TokenInvalidFormat, + /// The token was valid, but is not implemented. + TokenNotImplemented { token: char }, +} + +/// Context that can expand words. +struct Env { + home_dir: PathBuf, + vars: HashMap, +} + +/// Iterator over words in a ssh_config(5) keyword-argument pair. +pub struct Words<'a> { + chars: CharIndices<'a>, + s: &'a str, + state: WordsExpect, +} + +enum WordsExpect { + Keyword, + EqualsOrArgument, + Arguments, +} + +pub fn expand_inlude_args(s: &str) -> Result { + ENV.get_or_init(Env::from_env).expand_include_arg(s) +} + +pub fn is_host_pattern(s: &str) -> bool { + s.contains(['*', '?', '!', ',']) +} + +impl<'a> From<&'a str> for Words<'a> { + fn from(s: &'a str) -> Self { + Self { + s, + chars: s.char_indices(), + state: WordsExpect::Keyword, + } + } +} + +impl<'a> Iterator for Words<'a> { + type Item = Result<&'a str, InvalidQuotesError>; + + fn next(&mut self) -> Option { + use WordsExpect as Expect; + + macro_rules! word { + ($range:expr) => { + match self.state { + Expect::Keyword => { + self.state = Expect::EqualsOrArgument; + Some(Ok(&self.s[$range])) + } + Expect::EqualsOrArgument | Expect::Arguments => { + self.state = Expect::Arguments; + Some(Ok(&self.s[$range])) + } + } + }; + + ($range:expr; $state:ident) => { + match self.state { + Expect::Keyword => { + self.state = Expect::$state; + Some(Ok(&self.s[$range])) + } + Expect::EqualsOrArgument | Expect::Arguments => { + self.state = Expect::$state; + Some(Ok(&self.s[$range])) + } + } + }; + } + + let (idx, ch) = loop { + let (idx, ch) = self.chars.next()?; + match (ch, &self.state) { + ('=', Expect::EqualsOrArgument) => { + self.state = Expect::Arguments; + } + (ch, _) if ch.is_whitespace() => {} + _ => break (idx, ch), + } + }; + + match ch { + '\'' | '"' => { + let delim = ch; + let start_idx = idx + 1; + loop { + match self.chars.next() { + Some((idx, ch)) if ch == delim => { + return word!(start_idx..idx); + } + Some(_) => {} + // End of words, but no closing delimiter for current word. + None => return Some(Err(InvalidQuotesError)), + } + } + } + _ => { + let start_idx = idx; + loop { + match (&self.state, self.chars.next()) { + (Expect::Keyword, Some((idx, '='))) => { + return word!(start_idx..idx; Arguments); + } + (_, Some((idx, ch))) if ch.is_whitespace() => { + return word!(start_idx..idx); + } + (_, Some(_)) => {} + (_, None) => return word!(start_idx..), + } + } + } + }; + } +} + +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 { + home_dir: env::home_dir().unwrap_or_else(|| PathBuf::from("~")), + vars, + } + } + + fn home_dir_str(&self) -> &str { + self.home_dir.to_str().unwrap_or("~") + } + + fn expand_include_arg(&self, arg: &str) -> Result { + enum S { + Literal, + EnvVar, + MaybeEnvVar, + ExpectToken, + } + + let mut expanded = String::new(); + let mut state = S::Literal; + let mut var_start: Option = None; + + for (idx, ch) in arg.char_indices() { + match (&state, ch) { + (S::Literal | S::MaybeEnvVar, '~') => { + expanded.push_str(self.home_dir_str()); + state = S::Literal; + } + (S::Literal | S::MaybeEnvVar, '%') => state = S::ExpectToken, + (S::Literal, '$') => state = S::MaybeEnvVar, + (S::Literal, ch) => expanded.push(ch), + (S::MaybeEnvVar, '$') => expanded.push('$'), + (S::MaybeEnvVar, '{') => { + state = S::EnvVar; + var_start = None; + } + (S::MaybeEnvVar, ch) => { + state = S::Literal; + expanded.push('$'); + expanded.push(ch); + } + (S::EnvVar, '}') => { + let Some(start) = var_start else { + return Err(ExpandError::EnvZeroLen); + }; + let var = &arg[start..idx]; + let Some(val) = self.vars.get(var) else { + return Err(ExpandError::EnvNoValue { + var: var.to_string(), + }); + }; + + if val == "~" { + expanded.push_str(self.home_dir_str()); + } else { + expanded.push_str(val); + } + + state = S::Literal; + } + (S::EnvVar, _) => { + if var_start.is_none() { + var_start = Some(idx) + } + } + (S::ExpectToken, ch) => { + state = S::Literal; + + macro_rules! unimplemented_token { + ($t:expr) => {{ + return Err(ExpandError::TokenNotImplemented { token: $t }); + }}; + } + + match ch { + // A literal ‘%’. + '%' => expanded.push('%'), + // Hash of %l%h%p%r%j. + 'C' => unimplemented_token!(ch), + // Local user's home directory. + 'd' => expanded.push_str(self.home_dir_str()), + // The fingerprint of the server's host key. + // ('f', _) => todo!(), + // The known_hosts hostname or address that is being searched for. + // ('H', _) => todo!(), + // The remote hostname. + 'h' => unimplemented_token!(ch), + // A string describing the reason for a KnownHostsCommand execution: either + // ADDRESS when looking up a host by address (only when CheckHostIP is + // enabled), HOSTNAME when searching by hostname, or ORDER when preparing + // the host key algorithm preference list to use for the destination host. + // ('I', _) => todo!(), + // The local user ID. + 'i' => unimplemented_token!(ch), + // The contents of the ProxyJump option, or the empty string if this option + // is unset. + 'j' => unimplemented_token!(ch), + // The base64 encoded host key. + // ('K', _) => todo!(), + // The host key alias if specified, otherwise the original remote hostname + // given on the command line. + 'k' => unimplemented_token!(ch), + // The local hostname. + 'L' => unimplemented_token!(ch), + // The local hostname, including the domain name. + 'l' => unimplemented_token!(ch), + // The original remote hostname, as given on the command line. + 'n' => unimplemented_token!(ch), + // The remote port. + 'p' => unimplemented_token!(ch), + // The remote username. + 'r' => unimplemented_token!(ch), + // The local tun(4) or tap(4) network interface assigned if tunnel + // forwarding was requested, or "NONE" otherwise. + // ('T', _) => todo!(), + // The type of the server host key, e.g. ssh-ed25519. + // ('t', _) => todo!(), + // The local username. + 'u' => unimplemented_token!(ch), + // Unknown key %*. + _ => return Err(ExpandError::TokenUnkownKey { token: ch }), + } + } + } + } + + match state { + S::Literal => {} + S::MaybeEnvVar => expanded.push('$'), + S::EnvVar => { + let start = var_start.expect("var_start must be set"); + return Err(ExpandError::EnvMissingClosing { + var: arg[start..].to_string(), + }); + } + S::ExpectToken => { + return Err(ExpandError::TokenInvalidFormat); + } + } + + Ok(expanded) + } +} + +impl std::error::Error for InvalidQuotesError {} + +impl std::fmt::Display for InvalidQuotesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "invalid quotes") + } +} + +impl std::error::Error for ExpandError {} + +impl std::fmt::Display for ExpandError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use ExpandError::*; + + match self { + EnvMissingClosing { var } => { + write!(f, "environment variable '{var}' missing closing '}}'") + } + EnvNoValue { var } => write!(f, "env var ${{{var}}} has no value"), + EnvZeroLen => write!(f, "zero-length environment variable"), + TokenUnkownKey { token } => write!(f, "unknown key %{token}"), + TokenInvalidFormat => write!(f, "invalid format"), + TokenNotImplemented { token } => { + write!(f, "valid key %{token} is not implemented") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_words() { + macro_rules! assert_words { + ($words:expr, $parsed:expr) => { + assert_eq!( + Words::from($words).collect::>>()[..], + $parsed, + ) + }; + } + + assert_words!("foo bar baz", [Ok("foo"), Ok("bar"), Ok("baz")]); + assert_words!("foo \"bar \" ' baz'", [Ok("foo"), Ok("bar "), Ok(" baz")]); + assert_words!("foo 'bar\"", [Ok("foo"), Err(InvalidQuotesError)]); + assert_words!("foo==bar", [Ok("foo"), Ok("=bar")]); + assert_words!("foo '==bar'", [Ok("foo"), Ok("==bar")]); + assert_words!("foo='=bar'", [Ok("foo"), Ok("=bar")]); + assert_words!("'foo''==bar'", [Ok("foo"), Ok("==bar")]); + } + + #[test] + fn expand_include_args() { + let env = Env { + home_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()), + ("QUX".to_string(), "~".to_string()), + ]), + }; + + macro_rules! assert_expand { + ($e:expr, $expanded:expr) => { + assert_eq!(env.expand_include_arg($e).as_deref(), $expanded) + }; + } + + assert_expand!("~", Ok("/home/jhill")); + assert_expand!("FOO ${BAR} ${BAZ}", Ok("FOO bar baz")); + assert_expand!("~/${FOO}", Ok("/home/jhill/foo")); + assert_expand!("${QUX}/${FOO}", Ok("/home/jhill/foo")); + + // Environment expansion errors. + // TODO: Is this emulating the ssh behaviour? + assert_expand!( + "foo ${QUUX} baz", + Err(&ExpandError::EnvNoValue { + var: "QUUX".to_string() + }) + ); + assert_expand!( + "foo ${BAR baz", + Err(&ExpandError::EnvMissingClosing { + var: "BAR baz".to_string() + }) + ); + assert_expand!("foo ${} baz", Err(&ExpandError::EnvZeroLen)); + + // Token expansion errors. + assert_expand!("%", Err(&ExpandError::TokenInvalidFormat)); + assert_expand!("%Z", Err(&ExpandError::TokenUnkownKey { token: 'Z' })); + assert_expand!("%C", Err(&ExpandError::TokenNotImplemented { token: 'C' })); + } +} diff --git a/src/words.rs b/src/words.rs deleted file mode 100644 index 884602a..0000000 --- a/src/words.rs +++ /dev/null @@ -1,223 +0,0 @@ -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()); - } -}