use anyhow::{bail, 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>) {
if let Ok(text) = fs::read_to_string(path) {
parse_lines(&text, true, out);
}
}
fn parse_lines(text: &str, follow_includes: bool, out: &mut Vec<Host>) {
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" => {
if follow_includes {
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()
&& 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<()> {
let dir = ssh_dir();
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
harden(&dir, 0o700);
let cfg = config_path();
append_host_in(&cfg, h)?;
harden(&cfg, 0o600);
Ok(())
}
pub fn update_host(original: &str, h: &NewHost) -> Result<()> {
let cfg = config_path();
update_host_in(&cfg, original, h)?;
harden(&cfg, 0o600);
Ok(())
}
pub fn delete_host(alias: &str) -> Result<()> {
delete_host_in(&config_path(), alias)
}
fn append_host_in(cfg: &Path, h: &NewHost) -> Result<()> {
if cfg.exists() {
backup(cfg)?;
}
let mut text = fs::read_to_string(cfg).unwrap_or_default();
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push('\n'); text.push_str(&render_block(h));
fs::write(cfg, text).with_context(|| format!("writing {}", cfg.display()))
}
fn update_host_in(cfg: &Path, original: &str, h: &NewHost) -> Result<()> {
let text = fs::read_to_string(cfg).with_context(|| format!("reading {}", cfg.display()))?;
let lines: Vec<&str> = text.lines().collect();
match sole_block_range(&lines, original) {
BlockFind::None => bail!("no host '{original}' in {}", cfg.display()),
BlockFind::Shared => bail!("'{original}' shares a Host block with other aliases - edit {} by hand", cfg.display()),
BlockFind::Range(s, e) => {
backup(cfg)?;
let mut out: Vec<String> = lines[..s].iter().map(|l| l.to_string()).collect();
out.extend(render_block_lines(h));
out.extend(lines[e..].iter().map(|l| l.to_string()));
write_lines(cfg, &out)
}
}
}
fn delete_host_in(cfg: &Path, alias: &str) -> Result<()> {
let text = fs::read_to_string(cfg).with_context(|| format!("reading {}", cfg.display()))?;
let lines: Vec<&str> = text.lines().collect();
match sole_block_range(&lines, alias) {
BlockFind::None => bail!("no host '{alias}' in {}", cfg.display()),
BlockFind::Shared => bail!("'{alias}' shares a Host block with other aliases - edit {} by hand", cfg.display()),
BlockFind::Range(mut s, e) => {
if s > 0 && lines[s - 1].trim().is_empty() {
s -= 1;
}
backup(cfg)?;
let out: Vec<String> = lines[..s].iter().chain(lines[e..].iter()).map(|l| l.to_string()).collect();
write_lines(cfg, &out)
}
}
}
fn write_lines(cfg: &Path, lines: &[String]) -> Result<()> {
let mut out = lines.join("\n");
if !out.is_empty() {
out.push('\n');
}
fs::write(cfg, out).with_context(|| format!("writing {}", cfg.display()))
}
fn backup(cfg: &Path) -> Result<()> {
if cfg.exists() {
let b = backup_path(cfg);
fs::copy(cfg, &b).with_context(|| format!("backing up to {}", b.display()))?;
}
Ok(())
}
fn render_block_lines(h: &NewHost) -> Vec<String> {
let mut v = vec![format!("Host {}", h.alias.trim())];
for (key, val) in [
("HostName", &h.hostname),
("User", &h.user),
("Port", &h.port),
("IdentityFile", &h.identity),
] {
let val = val.trim();
if !val.is_empty() {
v.push(format!(" {key} {val}"));
}
}
v
}
fn render_block(h: &NewHost) -> String {
let mut s = render_block_lines(h).join("\n");
s.push('\n');
s
}
enum BlockFind {
None,
Shared,
Range(usize, usize),
}
fn sole_block_range(lines: &[&str], alias: &str) -> BlockFind {
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with('#') {
i += 1;
continue;
}
let (key, value) = split_kv(line);
if key.eq_ignore_ascii_case("host") {
let aliases: Vec<&str> = value.split_whitespace().collect();
if aliases.contains(&alias) {
if aliases.len() != 1 {
return BlockFind::Shared;
}
let mut end = i + 1;
while end < lines.len() {
let l = lines[end].trim();
if !l.is_empty() && !l.starts_with('#') {
let (k, _) = split_kv(l);
if k.eq_ignore_ascii_case("host") || k.eq_ignore_ascii_case("match") {
break;
}
}
end += 1;
}
while end > i + 1 && lines[end - 1].trim().is_empty() {
end -= 1;
}
return BlockFind::Range(i, end);
}
}
i += 1;
}
BlockFind::None
}
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);
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_str(text: &str) -> Vec<Host> {
let mut v = Vec::new();
parse_lines(text, false, &mut v);
v
}
fn temp_cfg(body: &str) -> PathBuf {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("easyssh-test-{}-{stamp}.cfg", std::process::id()));
fs::write(&path, body).unwrap();
path
}
#[test]
fn parses_fields_and_multi_alias() {
let cfg = "\
Host raspi
HostName 192.168.0.5
User pi
Port 22
Host a b
HostName ex.com
";
let hosts = parse_str(cfg);
assert_eq!(hosts.len(), 3);
let raspi = hosts.iter().find(|h| h.alias == "raspi").unwrap();
assert_eq!(raspi.hostname.as_deref(), Some("192.168.0.5"));
assert_eq!(raspi.user.as_deref(), Some("pi"));
let a = hosts.iter().find(|h| h.alias == "a").unwrap();
let b = hosts.iter().find(|h| h.alias == "b").unwrap();
assert_eq!(a.hostname.as_deref(), Some("ex.com"));
assert_eq!(b.hostname.as_deref(), Some("ex.com"));
}
#[test]
fn equals_separator_and_comments() {
let hosts = parse_str("# a comment\nHost=x\n HostName=1.2.3.4\n");
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].alias, "x");
assert_eq!(hosts[0].hostname.as_deref(), Some("1.2.3.4"));
}
#[test]
fn patterns_flagged() {
assert!(is_pattern("*"));
assert!(is_pattern("web-?"));
assert!(!is_pattern("raspi"));
}
#[test]
fn append_then_parse_roundtrip() {
let cfg = temp_cfg("");
let nh = NewHost {
alias: "box".into(),
hostname: "10.0.0.1".into(),
user: "me".into(),
port: "22".into(),
identity: String::new(),
};
append_host_in(&cfg, &nh).unwrap();
let hosts = parse_str(&fs::read_to_string(&cfg).unwrap());
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].alias, "box");
assert_eq!(hosts[0].user.as_deref(), Some("me"));
fs::remove_file(&cfg).ok();
}
#[test]
fn delete_removes_only_target() {
let cfg = temp_cfg("Host a\n HostName 1\n\nHost b\n HostName 2\n");
delete_host_in(&cfg, "a").unwrap();
let hosts = parse_str(&fs::read_to_string(&cfg).unwrap());
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].alias, "b");
assert_eq!(hosts[0].hostname.as_deref(), Some("2"));
fs::remove_file(&cfg).ok();
}
#[test]
fn delete_refuses_shared_block() {
let cfg = temp_cfg("Host a b\n HostName 1\n");
assert!(delete_host_in(&cfg, "a").is_err());
fs::remove_file(&cfg).ok();
}
#[test]
fn update_replaces_block_leaving_others() {
let cfg = temp_cfg("Host a\n HostName old\n\nHost b\n HostName 2\n");
let nh = NewHost {
alias: "a".into(),
hostname: "new".into(),
user: String::new(),
port: String::new(),
identity: String::new(),
};
update_host_in(&cfg, "a", &nh).unwrap();
let hosts = parse_str(&fs::read_to_string(&cfg).unwrap());
assert_eq!(hosts.iter().find(|h| h.alias == "a").unwrap().hostname.as_deref(), Some("new"));
assert!(hosts.iter().any(|h| h.alias == "b"), "sibling block must survive");
fs::remove_file(&cfg).ok();
}
}