use std::{ffi::OsStr, path::PathBuf};
use crate::config::ssh::{Parser, Setting};
use anyhow::Context;
use tracing::{debug, warn};
use crate::os::{AbstractPlatform as _, Platform};
#[derive(Debug)]
struct SshConfigFile {
path: PathBuf,
user: bool,
warn_on_error: bool,
}
impl SshConfigFile {
fn new<S: AsRef<OsStr> + ?Sized>(s: &S, user: bool, warn_on_error: bool) -> Self {
Self {
path: s.into(),
user,
warn_on_error,
}
}
fn get(&self, host: &str, key: &str) -> Option<Setting> {
let path = &self.path;
if !std::fs::exists(path).is_ok_and(|b| b) {
if self.warn_on_error {
warn!("ssh-config file {path:?} not found");
}
return None;
}
let parser = match Parser::for_path(path, self.user) {
Ok(p) => p,
Err(e) => {
warn!("failed to open {path:?}: {e:?}");
return None;
}
};
let data = match parser
.parse_file_for(Some(host))
.with_context(|| format!("error reading configuration file {}", path.display()))
{
Ok(data) => data,
Err(e) => {
warn!("{e:?}");
return None;
}
};
data.get(key).map(std::borrow::ToOwned::to_owned)
}
}
#[derive(Debug)]
pub(crate) struct SshConfigFiles {
files: Vec<SshConfigFile>,
}
impl SshConfigFiles {
pub(crate) fn new<S>(config_files: &[S]) -> Self
where
S: AsRef<OsStr>,
{
let files = if config_files.is_empty() {
let mut v = Vec::new();
if let Some(f) = Platform::user_ssh_config() {
v.push(SshConfigFile::new(&f, true, false));
}
if let Some(p) = Platform::system_ssh_config() {
let f = SshConfigFile::new(&p, false, false);
v.push(f);
}
v
} else {
config_files
.iter()
.map(|s| SshConfigFile::new(s, true, true))
.collect()
};
Self { files }
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
fn get_files(&self) -> &Vec<SshConfigFile> {
&self.files
}
#[must_use]
pub(crate) fn resolve_host_alias(&self, host: &str) -> Option<String> {
self.get(host, "hostname")
.inspect(|s| {
debug!(
"Using hostname '{}' for '{host}' (from {})",
s.first_arg(),
s.source
);
})
.map(|s| s.first_arg())
}
#[must_use]
pub(crate) fn get(&self, host: &str, key: &str) -> Option<Setting> {
self.files.iter().find_map(|c| c.get(host, key))
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod test {
use std::{ffi::OsStr, path::PathBuf};
use super::SshConfigFiles;
use crate::client::ssh::SshConfigFile;
use littertray::LitterTray;
use pretty_assertions::assert_eq;
fn resolve_one<P: AsRef<OsStr>>(path: P, host: &str) -> Option<String> {
let files = SshConfigFiles::new(&[path.as_ref()]);
files.resolve_host_alias(host)
}
#[test]
fn hosts_resolve() {
LitterTray::try_with(|tray| {
let path = "test_ssh_config";
let _ = tray.create_text(
path,
r"
Host aaa
HostName zzz
Host bbb ccc.ddd
HostName yyy
",
)?;
assert!(resolve_one(path, "nope").is_none());
assert_eq!(resolve_one(path, "aaa").unwrap(), "zzz");
assert_eq!(resolve_one(path, "bbb").unwrap(), "yyy");
assert_eq!(resolve_one(path, "ccc.ddd").unwrap(), "yyy");
Ok(())
})
.unwrap();
}
#[test]
fn wildcards_match() {
LitterTray::try_with(|tray| {
let path = "test_ssh_config";
let _ = tray.create_text(
path,
r"
Host *.bar
HostName baz
Host 10.11.*.13
# this is a silly example but it shows that wildcards match by IP
HostName wibble
Host fr?d
hostname barney
",
)?;
assert_eq!(resolve_one(path, "foo.bar").unwrap(), "baz");
assert_eq!(resolve_one(path, "qux.qix.bar").unwrap(), "baz");
assert!(resolve_one(path, "qux.qix").is_none());
assert_eq!(resolve_one(path, "10.11.12.13").unwrap(), "wibble");
assert_eq!(resolve_one(path, "10.11.0.13").unwrap(), "wibble");
assert_eq!(resolve_one(path, "10.11.256.13").unwrap(), "wibble"); assert!(resolve_one(path, "10.11.0.130").is_none());
assert_eq!(resolve_one(path, "fred").unwrap(), "barney");
assert_eq!(resolve_one(path, "frid").unwrap(), "barney");
assert!(resolve_one(path, "freed").is_none());
assert!(resolve_one(path, "fredd").is_none());
Ok(())
})
.unwrap();
}
#[test]
fn no_such_file() {
let path = PathBuf::from("/no-such-file--------------");
let s = SshConfigFile::new(&path, false, true);
assert!(s.get("", "hostname").is_none());
}
#[cfg(linux)] #[test]
fn file_permissions() {
let path = PathBuf::from("/dev/console");
let s = SshConfigFile::new(&path, false, false);
assert!(s.get("", "hostname").is_none());
}
#[test]
fn reading_failed() {
let path = "myfile";
let contents = format!("include {path:?}");
LitterTray::try_with(|tray| {
let _f = tray.create_text(path, &contents)?;
let s = SshConfigFile::new(&path, false, false);
assert!(s.get("", "hostname").is_none());
Ok(())
})
.unwrap();
}
#[test]
fn empty_fileset() {
let f = SshConfigFiles::new::<&str>(&[]);
let files = f.get_files();
assert!(files.len() == 2);
}
}