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 parsed = Vec::new();
parse_file(&config_path(), &mut parsed);
merge_hosts(parsed)
}
fn merge_hosts(parsed: Vec<Host>) -> Vec<Host> {
let mut merged: Vec<Host> = Vec::new();
for h in parsed {
if is_pattern(&h.alias) {
continue;
}
if let Some(e) = merged.iter_mut().find(|e| e.alias == h.alias) {
e.hostname = e.hostname.take().or(h.hostname);
e.user = e.user.take().or(h.user);
e.port = e.port.take().or(h.port);
e.identity = e.identity.take().or(h.identity);
} else {
merged.push(h);
}
}
merged.sort_by(|a, b| a.alias.cmp(&b.alias));
merged
}
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();
add_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 add_host_in(cfg: &Path, h: &NewHost) -> Result<()> {
if cfg.exists() {
backup(cfg)?;
}
let text = fs::read_to_string(cfg).unwrap_or_default();
let identity = h.identity.trim();
if !identity.is_empty() {
let lines: Vec<&str> = text.lines().collect();
if let Some(i) = unique_group_block_for_identity(&lines, identity) {
let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
out[i] = format!("{} {}", out[i].trim_end(), h.alias.trim());
let mut body = out.join("\n");
if !body.is_empty() {
body.push('\n');
}
let per_host = NewHost {
alias: h.alias.clone(),
hostname: h.hostname.clone(),
user: h.user.clone(),
port: h.port.clone(),
identity: String::new(),
};
body.push('\n');
body.push_str(&render_block(&per_host));
return fs::write(cfg, body).with_context(|| format!("writing {}", cfg.display()));
}
}
let mut out = text;
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push('\n'); out.push_str(&render_block(h));
fs::write(cfg, out).with_context(|| format!("writing {}", cfg.display()))
}
fn unique_group_block_for_identity(lines: &[&str], identity: &str) -> Option<usize> {
let mut candidates = Vec::new();
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") {
i += 1;
continue;
}
let host_idx = i;
let aliases: Vec<&str> = value.split_whitespace().collect();
let is_group = aliases.len() >= 2 && !aliases.iter().any(|a| is_pattern(a));
let mut matches = false;
let mut j = i + 1;
while j < lines.len() {
let l = lines[j].trim();
if !l.is_empty() && !l.starts_with('#') {
let (k, v) = split_kv(l);
if k.eq_ignore_ascii_case("host") || k.eq_ignore_ascii_case("match") {
break;
}
if k.eq_ignore_ascii_case("identityfile") && v.trim() == identity {
matches = true;
}
}
j += 1;
}
if is_group && matches {
candidates.push(host_idx);
}
i = j;
}
(candidates.len() == 1).then(|| candidates[0])
}
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 split_blocks_merge_into_one_host() {
let cfg = "\
Host alpha
HostName 10.0.0.1
User deploy
Host beta
HostName 10.0.0.2
User deploy
# shared fleet key
Host alpha beta
IdentityFile ~/.ssh/id_fleet
";
let hosts = merge_hosts(parse_str(cfg));
assert_eq!(hosts.len(), 2, "each alias appears once, not per block");
let alpha = hosts.iter().find(|h| h.alias == "alpha").unwrap();
assert_eq!(alpha.hostname.as_deref(), Some("10.0.0.1"));
assert_eq!(alpha.user.as_deref(), Some("deploy"));
assert_eq!(alpha.identity.as_deref(), Some("~/.ssh/id_fleet"));
}
#[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(),
};
add_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 add_with_key_joins_existing_shared_block() {
let cfg = temp_cfg("Host alpha beta\n IdentityFile ~/.ssh/id_fleet\n IdentitiesOnly yes\n");
let nh = NewHost {
alias: "gamma".into(),
hostname: "10.0.0.3".into(),
user: "deploy".into(),
port: String::new(),
identity: "~/.ssh/id_fleet".into(),
};
add_host_in(&cfg, &nh).unwrap();
let text = fs::read_to_string(&cfg).unwrap();
assert!(text.contains("Host alpha beta gamma"), "alias joined the group line");
assert_eq!(text.matches("IdentityFile ~/.ssh/id_fleet").count(), 1, "no duplicate key line");
let gamma = merge_hosts(parse_str(&text)).into_iter().find(|h| h.alias == "gamma").unwrap();
assert_eq!(gamma.identity.as_deref(), Some("~/.ssh/id_fleet"));
assert_eq!(gamma.hostname.as_deref(), Some("10.0.0.3"));
fs::remove_file(&cfg).ok();
}
#[test]
fn add_with_key_writes_own_block_when_no_group() {
let cfg = temp_cfg("Host solo\n IdentityFile ~/.ssh/id_fleet\n");
let nh = NewHost {
alias: "gamma".into(),
hostname: "10.0.0.3".into(),
user: String::new(),
port: String::new(),
identity: "~/.ssh/id_fleet".into(),
};
add_host_in(&cfg, &nh).unwrap();
let text = fs::read_to_string(&cfg).unwrap();
assert!(!text.contains("Host solo gamma"), "must not join a one-alias block");
assert_eq!(text.matches("IdentityFile ~/.ssh/id_fleet").count(), 2, "gamma got its own key line");
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();
}
}