intial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
193
Cargo.lock
generated
Normal file
193
Cargo.lock
generated
Normal 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
8
Cargo.toml
Normal 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
187
src/main.rs
Normal 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
223
src/words.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user