Skip to content

Commit

Permalink
feat: support multiple config paths and add /etc/ssh/ssh_config as …
Browse files Browse the repository at this point in the history
…a default one

Signed-off-by: Nathanael DEMACON <quantumsheep@users.noreply.github.com>
  • Loading branch information
quantumsheep committed Mar 9, 2024
1 parent b067719 commit 9eb5291
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 30 deletions.
20 changes: 12 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@ use ui::{App, AppConfig};
#[command(version, about, long_about = None)]
struct Args {
/// Path to the SSH configuration file
#[arg(short, long, default_value = "~/.ssh/config")]
config: String,
#[arg(
short,
long,
num_args = 1..,
default_values_t = [
"/etc/ssh/ssh_config".to_string(),
"~/.ssh/config".to_string(),
],
)]
config: Vec<String>,

/// Shows ProxyCommand
#[arg(long, default_value_t = false)]
Expand All @@ -27,11 +35,7 @@ struct Args {
sort: bool,

/// Handlebars template of the command to execute
#[arg(
short,
long,
default_value = "ssh \"{{{name}}}\""
)]
#[arg(short, long, default_value = "ssh \"{{{name}}}\"")]
template: String,

/// Exit after ending the SSH session
Expand All @@ -43,7 +47,7 @@ fn main() -> Result<()> {
let args = Args::parse();

let mut app = App::new(&AppConfig {
config_path: args.config,
config_paths: args.config,
search_filter: args.search,
sort_by_name: args.sort,
show_proxy_command: args.show_proxy_command,
Expand Down
33 changes: 24 additions & 9 deletions src/ssh.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use anyhow::{anyhow, Result};
use anyhow::anyhow;
use handlebars::Handlebars;
use itertools::Itertools;
use serde::Serialize;
use std::collections::VecDeque;
use std::process::Command;

use crate::ssh_config::{self, HostVecExt};
use crate::ssh_config::{self, parser_error::ParseError, HostVecExt};

#[derive(Debug, Serialize, Clone)]
pub struct Host {
Expand All @@ -27,7 +27,7 @@ impl Host {
/// # Panics
///
/// Will panic if the regex cannot be compiled.
pub fn run_command_template(&self, pattern: &str) -> Result<()> {
pub fn run_command_template(&self, pattern: &str) -> anyhow::Result<()> {
let handlebars = Handlebars::new();
let rendered_command = handlebars.render_template(pattern, &self)?;

Expand All @@ -48,15 +48,30 @@ impl Host {
}
}

#[derive(Debug)]
pub enum ParseConfigError {
Io(std::io::Error),
SshConfig(ParseError),
}

impl From<std::io::Error> for ParseConfigError {
fn from(e: std::io::Error) -> Self {
ParseConfigError::Io(e)
}
}

impl From<ParseError> for ParseConfigError {
fn from(e: ParseError) -> Self {
ParseConfigError::SshConfig(e)
}
}

/// # Errors
///
/// Will return `Err` if the SSH configuration file cannot be parsed.
pub fn parse_config(raw_path: &String) -> Result<Vec<Host>> {
let mut path = shellexpand::tilde(&raw_path).to_string();
path = std::fs::canonicalize(path)?
.to_str()
.ok_or(anyhow!("Failed to convert path to string"))?
.to_string();
pub fn parse_config(raw_path: &String) -> Result<Vec<Host>, ParseConfigError> {
let normalized_path = shellexpand::tilde(&raw_path).to_string();
let path = std::fs::canonicalize(normalized_path)?;

let hosts = ssh_config::Parser::new()
.parse_file(path)?
Expand Down
1 change: 1 addition & 0 deletions src/ssh_config/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod host;
mod host_entry;
pub mod parser;
pub mod parser_error;

pub use host::Host;
pub use host::HostVecExt;
Expand Down
49 changes: 38 additions & 11 deletions src/ssh_config/parser.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
use anyhow::anyhow;
use anyhow::Result;
use glob::glob;
use std::fs::File;
use std::io::BufRead;
Expand All @@ -8,6 +6,10 @@ use std::path::Path;
use std::str::FromStr;

use super::host::Entry;
use super::parser_error::InvalidIncludeError;
use super::parser_error::InvalidIncludeErrorDetails;
use super::parser_error::ParseError;
use super::parser_error::UnknownEntryError;
use super::{EntryType, Host};

#[derive(Debug)]
Expand All @@ -32,7 +34,7 @@ impl Parser {
/// # Errors
///
/// Will return `Err` if the SSH configuration cannot be parsed.
pub fn parse_file<P>(&self, path: P) -> Result<Vec<Host>>
pub fn parse_file<P>(&self, path: P) -> Result<Vec<Host>, ParseError>
where
P: AsRef<Path>,
{
Expand All @@ -43,7 +45,7 @@ impl Parser {
/// # Errors
///
/// Will return `Err` if the SSH configuration cannot be parsed.
pub fn parse(&self, reader: &mut impl BufRead) -> Result<Vec<Host>> {
pub fn parse(&self, reader: &mut impl BufRead) -> Result<Vec<Host>, ParseError> {
let (global_host, mut hosts) = self.parse_raw(reader)?;

if !global_host.is_empty() {
Expand All @@ -55,7 +57,7 @@ impl Parser {
Ok(hosts)
}

fn parse_raw(&self, reader: &mut impl BufRead) -> Result<(Host, Vec<Host>)> {
fn parse_raw(&self, reader: &mut impl BufRead) -> Result<(Host, Vec<Host>), ParseError> {
let mut global_host = Host::new(Vec::new());
let mut is_in_host_block = false;
let mut hosts = Vec::new();
Expand All @@ -74,7 +76,11 @@ impl Parser {
match entry.0 {
EntryType::Unknown(_) => {
if !self.ignore_unknown_entries {
return Err(anyhow!("Unknown entry: {line}"));
return Err(UnknownEntryError {
line,
entry: entry.0.to_string(),
}
.into());
}
}
EntryType::Host => {
Expand All @@ -92,10 +98,27 @@ impl Parser {
include_path = format!("{ssh_config_directory}/{include_path}");
}

for path in glob(&include_path)? {
let paths = match glob(&include_path) {
Ok(paths) => paths,
Err(e) => {
return Err(InvalidIncludeError {
line,
details: InvalidIncludeErrorDetails::Pattern(e),
}
.into())
}
};

for path in paths {
let path = match path {
Ok(path) => path,
Err(e) => return Err(anyhow!("Failed to glob path: {e}")),
Err(e) => {
return Err(InvalidIncludeError {
line,
details: InvalidIncludeErrorDetails::Glob(e),
}
.into())
}
};

let mut file = BufReader::new(File::open(path)?);
Expand All @@ -104,7 +127,11 @@ impl Parser {
if is_in_host_block {
// Can't include hosts inside a host block
if !included_hosts.is_empty() {
return Err(anyhow!("Cannot include hosts inside a host block"));
return Err(InvalidIncludeError {
line,
details: InvalidIncludeErrorDetails::HostsInsideHostBlock,
}
.into());
}

hosts
Expand Down Expand Up @@ -136,12 +163,12 @@ impl Parser {
}
}

fn parse_line(line: &str) -> Result<Entry> {
fn parse_line(line: &str) -> Result<Entry, ParseError> {
let (mut key, mut value) = line
.trim()
.split_once([' ', '\t', '='])
.map(|(k, v)| (k.trim_end(), v.trim_start()))
.ok_or(anyhow!("Invalid line: {line}"))?;
.ok_or(ParseError::UnparseableLine(line.to_string()))?;

// Format can be key=value with whitespaces around the equal sign, strip the equal sign and whitespaces
if key.ends_with('=') {
Expand Down
45 changes: 45 additions & 0 deletions src/ssh_config/parser_error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#[derive(Debug)]
pub struct UnknownEntryError {
pub line: String,
pub entry: String,
}

#[derive(Debug)]
pub enum InvalidIncludeErrorDetails {
Pattern(glob::PatternError),
Glob(glob::GlobError),
Io(std::io::Error),
HostsInsideHostBlock,
}

#[derive(Debug)]
pub struct InvalidIncludeError {
pub line: String,
pub details: InvalidIncludeErrorDetails,
}

#[derive(Debug)]
pub enum ParseError {
Io(std::io::Error),
UnparseableLine(String),
UnknownEntry(UnknownEntryError),
InvalidInclude(InvalidIncludeError),
}

impl From<std::io::Error> for ParseError {
fn from(e: std::io::Error) -> Self {
ParseError::Io(e)
}
}

impl From<UnknownEntryError> for ParseError {
fn from(e: UnknownEntryError) -> Self {
ParseError::UnknownEntry(e)
}
}

impl From<InvalidIncludeError> for ParseError {
fn from(e: InvalidIncludeError) -> Self {
ParseError::InvalidInclude(e)
}
}
25 changes: 23 additions & 2 deletions src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const INFO_TEXT: &str = "(Esc) quit | (↑) move up | (↓) move down | (enter)

#[derive(Clone)]
pub struct AppConfig {
pub config_path: String,
pub config_paths: Vec<String>,

pub search_filter: Option<String>,
pub sort_by_name: bool,
Expand All @@ -54,7 +54,28 @@ impl App {
///
/// Will return `Err` if the SSH configuration file cannot be parsed.
pub fn new(config: &AppConfig) -> Result<App> {
let mut hosts = ssh::parse_config(&config.config_path)?;
let mut hosts = Vec::new();

for path in &config.config_paths {
let parsed_hosts = match ssh::parse_config(path) {
Ok(hosts) => hosts,
Err(err) => {
if path == "/etc/ssh/ssh_config" {
if let ssh::ParseConfigError::Io(io_err) = &err {
// Ignore missing system-wide SSH configuration file
if io_err.kind() == std::io::ErrorKind::NotFound {
continue;
}
}
}

anyhow::bail!("Failed to parse SSH configuration file: {err:?}");
}
};

hosts.extend(parsed_hosts);
}

if config.sort_by_name {
hosts.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
Expand Down

0 comments on commit 9eb5291

Please sign in to comment.