use std::path::Path;
use std::process::Command;
use crate::ssh_config::model::HostEntry;
#[derive(Debug, Clone)]
pub struct SshKeyInfo {
pub name: String,
pub display_path: String,
pub key_type: String,
pub bits: String,
pub fingerprint: String,
pub comment: String,
pub linked_hosts: Vec<String>,
}
impl SshKeyInfo {
pub fn type_display(&self) -> String {
if self.bits.is_empty() {
self.key_type.clone()
} else {
format!("{} {}", self.key_type, self.bits)
}
}
}
pub fn discover_keys(ssh_dir: &Path, hosts: &[HostEntry]) -> Vec<SshKeyInfo> {
let entries = match std::fs::read_dir(ssh_dir) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
let home = dirs::home_dir();
let mut keys: Vec<SshKeyInfo> = entries
.filter_map(|e| e.ok())
.filter(is_public_key_file)
.filter_map(|e| read_key_info(ssh_dir, &e.path(), home.as_deref(), hosts))
.collect();
keys.sort_by(|a, b| a.name.cmp(&b.name));
keys
}
fn is_public_key_file(entry: &std::fs::DirEntry) -> bool {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.ends_with(".pub") {
return false;
}
let skip = ["authorized_keys.pub", "known_hosts.pub"];
if skip.contains(&name.as_ref()) {
return false;
}
entry.file_type().map(|t| t.is_file()).unwrap_or(false)
}
fn read_key_info(
ssh_dir: &Path,
pub_path: &Path,
home: Option<&Path>,
hosts: &[HostEntry],
) -> Option<SshKeyInfo> {
let output = Command::new("ssh-keygen")
.args(["-lf", &pub_path.to_string_lossy(), "-E", "sha256"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let line = String::from_utf8_lossy(&output.stdout);
let line = line.trim();
let (bits, fingerprint, comment, key_type) = parse_keygen_output(line)?;
let pub_name = pub_path.file_name()?.to_string_lossy();
let name = pub_name.strip_suffix(".pub").unwrap_or(&pub_name).to_string();
let private_path = ssh_dir.join(&name);
let display_path = match home {
Some(home) if ssh_dir.starts_with(home) => {
let relative = ssh_dir.strip_prefix(home).unwrap();
format!("~/{}/{}", relative.display(), name)
}
_ => private_path.display().to_string(),
};
let linked_hosts = find_linked_hosts(&private_path, &display_path, hosts);
Some(SshKeyInfo {
name,
display_path,
key_type,
bits,
fingerprint,
comment,
linked_hosts,
})
}
fn parse_keygen_output(line: &str) -> Option<(String, String, String, String)> {
let parts: Vec<&str> = line.splitn(3, ' ').collect();
if parts.len() < 3 {
return None;
}
let bits = parts[0].to_string();
let fingerprint = parts[1].to_string();
let rest = parts[2];
let (comment, key_type) = if let Some(paren_start) = rest.rfind('(') {
let comment = rest[..paren_start].trim().to_string();
let key_type = rest[paren_start + 1..]
.trim_end_matches(')')
.to_string();
(comment, key_type)
} else {
(rest.to_string(), String::new())
};
Some((bits, fingerprint, comment, key_type))
}
fn find_linked_hosts(full_path: &Path, display_path: &str, hosts: &[HostEntry]) -> Vec<String> {
hosts
.iter()
.filter(|h| {
if h.identity_file.is_empty() {
return true;
}
h.identity_file == display_path
|| Path::new(&h.identity_file) == full_path
})
.map(|h| h.alias.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_keygen_output_ed25519() {
let line = "256 SHA256:abcdef1234567890 user@host (ED25519)";
let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
assert_eq!(bits, "256");
assert_eq!(fp, "SHA256:abcdef1234567890");
assert_eq!(comment, "user@host");
assert_eq!(key_type, "ED25519");
}
#[test]
fn test_parse_keygen_output_rsa() {
let line = "4096 SHA256:xyz9876543210 deploy@prod.example.com (RSA)";
let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
assert_eq!(bits, "4096");
assert_eq!(fp, "SHA256:xyz9876543210");
assert_eq!(comment, "deploy@prod.example.com");
assert_eq!(key_type, "RSA");
}
#[test]
fn test_parse_keygen_output_no_comment() {
let line = "256 SHA256:fingerprint (ED25519)";
let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
assert_eq!(bits, "256");
assert_eq!(fp, "SHA256:fingerprint");
assert_eq!(comment, "");
assert_eq!(key_type, "ED25519");
}
#[test]
fn test_parse_keygen_output_comment_with_spaces() {
let line = "256 SHA256:fingerprint eko@MacBook Pro (ED25519)";
let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
assert_eq!(bits, "256");
assert_eq!(fp, "SHA256:fingerprint");
assert_eq!(comment, "eko@MacBook Pro");
assert_eq!(key_type, "ED25519");
}
#[test]
fn test_parse_keygen_output_no_type_parens() {
let line = "256 SHA256:fingerprint user@host";
let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
assert_eq!(bits, "256");
assert_eq!(fp, "SHA256:fingerprint");
assert_eq!(comment, "user@host");
assert_eq!(key_type, "");
}
#[test]
fn test_parse_keygen_output_too_short() {
assert!(parse_keygen_output("256 SHA256:fp").is_none());
assert!(parse_keygen_output("").is_none());
}
#[test]
fn test_find_linked_hosts_display_path() {
let hosts = vec![
HostEntry {
alias: "prod".to_string(),
identity_file: "~/.ssh/id_ed25519".to_string(),
..Default::default()
},
HostEntry {
alias: "staging".to_string(),
identity_file: "~/.ssh/other_key".to_string(),
..Default::default()
},
];
let linked = find_linked_hosts(
Path::new("/home/user/.ssh/id_ed25519"),
"~/.ssh/id_ed25519",
&hosts,
);
assert_eq!(linked, vec!["prod"]);
}
#[test]
fn test_find_linked_hosts_full_path() {
let hosts = vec![HostEntry {
alias: "server".to_string(),
identity_file: "/home/user/.ssh/deploy_key".to_string(),
..Default::default()
}];
let linked = find_linked_hosts(
Path::new("/home/user/.ssh/deploy_key"),
"~/.ssh/deploy_key",
&hosts,
);
assert_eq!(linked, vec!["server"]);
}
#[test]
fn test_find_linked_hosts_no_identity_file_links_to_all() {
let hosts = vec![HostEntry {
alias: "server".to_string(),
identity_file: String::new(),
..Default::default()
}];
let linked = find_linked_hosts(
Path::new("/home/user/.ssh/id_rsa"),
"~/.ssh/id_rsa",
&hosts,
);
assert_eq!(linked, vec!["server"]);
}
#[test]
fn test_find_linked_hosts_wrong_identity_file() {
let hosts = vec![HostEntry {
alias: "server".to_string(),
identity_file: "~/.ssh/other_key".to_string(),
..Default::default()
}];
let linked = find_linked_hosts(
Path::new("/home/user/.ssh/id_rsa"),
"~/.ssh/id_rsa",
&hosts,
);
assert!(linked.is_empty());
}
#[test]
fn test_type_display() {
let key = SshKeyInfo {
name: "id_ed25519".to_string(),
display_path: "~/.ssh/id_ed25519".to_string(),
key_type: "ED25519".to_string(),
bits: "256".to_string(),
fingerprint: String::new(),
comment: String::new(),
linked_hosts: Vec::new(),
};
assert_eq!(key.type_display(), "ED25519 256");
let key2 = SshKeyInfo {
bits: String::new(),
..key
};
assert_eq!(key2.type_display(), "ED25519");
}
}