Skip to main content

par_term_ssh/
known_hosts.rs

1//! Parser for ~/.ssh/known_hosts files.
2//!
3//! Extracts hostnames from known_hosts entries. Handles both plain and
4//! hashed hostname formats, as well as bracketed [host]:port entries.
5
6use super::types::{SshHost, SshHostSource};
7use std::collections::HashSet;
8use std::path::Path;
9
10/// Parse a known_hosts file and return discovered hosts.
11pub fn parse_known_hosts(path: &Path) -> Vec<SshHost> {
12    let content = match std::fs::read_to_string(path) {
13        Ok(c) => c,
14        Err(_) => return Vec::new(),
15    };
16    parse_known_hosts_str(&content)
17}
18
19/// Parse known_hosts from a string (for testing).
20pub fn parse_known_hosts_str(content: &str) -> Vec<SshHost> {
21    let mut seen = HashSet::new();
22    let mut hosts = Vec::new();
23
24    for line in content.lines() {
25        let line = line.trim();
26
27        if line.is_empty() || line.starts_with('#') || line.starts_with('@') {
28            continue;
29        }
30
31        let host_field = match line.split_whitespace().next() {
32            Some(f) => f,
33            None => continue,
34        };
35
36        if host_field.starts_with("|1|") {
37            continue;
38        }
39
40        for entry in host_field.split(',') {
41            let (hostname, port) = parse_host_entry(entry);
42
43            if hostname.is_empty() {
44                continue;
45            }
46
47            let key = format!("{}:{}", hostname, port.unwrap_or(22));
48            if seen.contains(&key) {
49                continue;
50            }
51            seen.insert(key);
52
53            hosts.push(SshHost {
54                alias: hostname.clone(),
55                hostname: Some(hostname),
56                user: None,
57                port,
58                identity_file: None,
59                proxy_jump: None,
60                source: SshHostSource::KnownHosts,
61            });
62        }
63    }
64
65    hosts
66}
67
68fn parse_host_entry(entry: &str) -> (String, Option<u16>) {
69    if entry.starts_with('[') {
70        if let Some(bracket_end) = entry.find(']') {
71            let hostname = entry[1..bracket_end].to_string();
72            let port = entry[bracket_end + 1..]
73                .strip_prefix(':')
74                .and_then(|p| p.parse().ok());
75            (hostname, port)
76        } else {
77            (entry.to_string(), None)
78        }
79    } else {
80        (entry.to_string(), None)
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn test_parse_basic_entries() {
90        let content = "github.com ssh-ed25519 AAAAC3...\nbitbucket.org ssh-rsa AAAAB3...\n";
91        let hosts = parse_known_hosts_str(content);
92        assert_eq!(hosts.len(), 2);
93        assert_eq!(hosts[0].alias, "github.com");
94        assert_eq!(hosts[1].alias, "bitbucket.org");
95    }
96
97    #[test]
98    fn test_skip_hashed_entries() {
99        let content = "|1|abc123|def456 ssh-rsa AAAAB3...\ngithub.com ssh-ed25519 AAAA...\n";
100        let hosts = parse_known_hosts_str(content);
101        assert_eq!(hosts.len(), 1);
102        assert_eq!(hosts[0].alias, "github.com");
103    }
104
105    #[test]
106    fn test_bracketed_port() {
107        let content = "[myhost.example.com]:2222 ssh-rsa AAAAB3...\n";
108        let hosts = parse_known_hosts_str(content);
109        assert_eq!(hosts.len(), 1);
110        assert_eq!(hosts[0].alias, "myhost.example.com");
111        assert_eq!(hosts[0].port, Some(2222));
112    }
113
114    #[test]
115    fn test_comma_separated_hostnames() {
116        let content = "host1.example.com,192.168.1.1 ssh-rsa AAAAB3...\n";
117        let hosts = parse_known_hosts_str(content);
118        assert_eq!(hosts.len(), 2);
119        assert_eq!(hosts[0].alias, "host1.example.com");
120        assert_eq!(hosts[1].alias, "192.168.1.1");
121    }
122
123    #[test]
124    fn test_dedup() {
125        let content = "host.com ssh-rsa AAAA...\nhost.com ssh-ed25519 AAAA...\n";
126        let hosts = parse_known_hosts_str(content);
127        assert_eq!(hosts.len(), 1);
128    }
129
130    #[test]
131    fn test_skip_comments_and_markers() {
132        let content =
133            "# comment\n@cert-authority *.example.com ssh-rsa AAAA...\nreal.host ssh-rsa AAAA...\n";
134        let hosts = parse_known_hosts_str(content);
135        assert_eq!(hosts.len(), 1);
136        assert_eq!(hosts[0].alias, "real.host");
137    }
138
139    #[test]
140    fn test_empty() {
141        let hosts = parse_known_hosts_str("");
142        assert!(hosts.is_empty());
143    }
144}