use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Clone)]
pub struct Host {
pub alias: String,
pub hostname: Option<String>,
pub user: Option<String>,
pub port: Option<String>,
pub identity: Option<String>,
}
impl Host {
pub fn target(&self) -> String {
let host = self.hostname.clone().unwrap_or_else(|| self.alias.clone());
let mut s = match &self.user {
Some(u) => format!("{u}@{host}"),
None => host,
};
if let Some(p) = &self.port {
s.push_str(&format!(":{p}"));
}
s
}
}
pub struct NewHost {
pub alias: String,
pub hostname: String,
pub user: String,
pub port: String,
pub identity: String,
}
pub fn ssh_dir() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".ssh")
}
pub fn config_path() -> PathBuf {
ssh_dir().join("config")
}
pub fn list_hosts() -> Vec<Host> {
let mut hosts = Vec::new();
parse_file(&config_path(), &mut hosts);
hosts.retain(|h| !is_pattern(&h.alias));
hosts.sort_by(|a, b| a.alias.cmp(&b.alias));
hosts
}
fn is_pattern(alias: &str) -> bool {
alias.contains('*') || alias.contains('?') || alias.contains('!')
}
fn parse_file(path: &Path, out: &mut Vec<Host>) {
let Ok(text) = fs::read_to_string(path) else {
return; };
let mut aliases: Vec<String> = Vec::new();
let mut hostname = None;
let mut user = None;
let mut port = None;
let mut identity = None;
macro_rules! flush {
() => {
for a in aliases.drain(..) {
out.push(Host {
alias: a,
hostname: hostname.clone(),
user: user.clone(),
port: port.clone(),
identity: identity.clone(),
});
}
};
}
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = split_kv(line);
let key_lc = key.to_ascii_lowercase();
match key_lc.as_str() {
"host" => {
flush!();
hostname = None;
user = None;
port = None;
identity = None;
aliases = value.split_whitespace().map(str::to_string).collect();
}
"include" => {
for inc in expand_include(value) {
parse_file(&inc, out);
}
}
_ if aliases.is_empty() => {} "hostname" => hostname = Some(value.to_string()),
"user" => user = Some(value.to_string()),
"port" => port = Some(value.to_string()),
"identityfile" => identity = Some(value.to_string()),
_ => {}
}
}
flush!();
}
fn split_kv(line: &str) -> (&str, &str) {
let sep = line.find(|c: char| c.is_whitespace() || c == '=');
match sep {
Some(i) => (line[..i].trim_end_matches('='), line[i + 1..].trim_start_matches(['=', ' ', '\t'])),
None => (line, ""),
}
}
fn expand_include(value: &str) -> Vec<PathBuf> {
let mut out = Vec::new();
for token in value.split_whitespace() {
let expanded = if let Some(rest) = token.strip_prefix("~/") {
dirs::home_dir().unwrap_or_default().join(rest)
} else {
let p = Path::new(token);
if p.is_absolute() { p.to_path_buf() } else { ssh_dir().join(p) }
};
match expanded.file_name().and_then(|n| n.to_str()) {
Some(name) if name.contains('*') => {
if let Some(parent) = expanded.parent() {
if let Ok(entries) = fs::read_dir(parent) {
for e in entries.flatten() {
let fname = e.file_name();
if glob_match(name, &fname.to_string_lossy()) {
out.push(e.path());
}
}
}
}
}
_ => out.push(expanded),
}
}
out
}
fn glob_match(pattern: &str, name: &str) -> bool {
match pattern.split_once('*') {
None => pattern == name,
Some((pre, suf)) => name.starts_with(pre) && name.ends_with(suf) && name.len() >= pre.len() + suf.len(),
}
}
pub fn add_host(h: &NewHost) -> Result<PathBuf> {
let dir = ssh_dir();
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
harden(&dir, 0o700);
let cfg = config_path();
let mut backup = None;
if cfg.exists() {
let b = backup_path(&cfg);
fs::copy(&cfg, &b).with_context(|| format!("backing up config to {}", b.display()))?;
backup = Some(b);
}
let mut block = String::new();
block.push_str(&format!("\nHost {}\n", h.alias.trim()));
push_field(&mut block, "HostName", &h.hostname);
push_field(&mut block, "User", &h.user);
push_field(&mut block, "Port", &h.port);
push_field(&mut block, "IdentityFile", &h.identity);
let mut existing = fs::read_to_string(&cfg).unwrap_or_default();
existing.push_str(&block);
fs::write(&cfg, existing).with_context(|| format!("writing {}", cfg.display()))?;
harden(&cfg, 0o600);
Ok(backup.unwrap_or(cfg))
}
fn push_field(block: &mut String, key: &str, value: &str) {
let v = value.trim();
if !v.is_empty() {
block.push_str(&format!(" {key} {v}\n"));
}
}
fn backup_path(cfg: &Path) -> PathBuf {
let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
cfg.with_extension(format!("bak.{secs}"))
}
fn harden(path: &Path, mode: u32) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
}
#[cfg(not(unix))]
let _ = (path, mode);
}