Skip to main content

par_term_ssh/
config_parser.rs

1//! Parser for ~/.ssh/config files.
2//!
3//! Reads SSH config and extracts host entries with their connection parameters.
4
5use super::types::{SshHost, SshHostSource};
6use std::path::Path;
7
8/// Parse an SSH config file and return discovered hosts.
9///
10/// Skips wildcard-only hosts (e.g., `Host *`) since they're defaults, not connectable targets.
11/// Handles multi-host lines like `Host foo bar` by creating separate entries.
12pub fn parse_ssh_config(path: &Path) -> Vec<SshHost> {
13    let content = match std::fs::read_to_string(path) {
14        Ok(c) => c,
15        Err(_) => return Vec::new(),
16    };
17    parse_ssh_config_str(&content)
18}
19
20/// Parse SSH config from a string (for testing).
21pub fn parse_ssh_config_str(content: &str) -> Vec<SshHost> {
22    let mut hosts = Vec::new();
23    let mut current_aliases: Vec<String> = Vec::new();
24    let mut hostname: Option<String> = None;
25    let mut user: Option<String> = None;
26    let mut port: Option<u16> = None;
27    let mut identity_file: Option<String> = None;
28    let mut proxy_jump: Option<String> = None;
29
30    for line in content.lines() {
31        let line = line.trim();
32
33        if line.is_empty() || line.starts_with('#') {
34            continue;
35        }
36
37        let (key, value) = if let Some(eq_pos) = line.find('=') {
38            let (k, v) = line.split_at(eq_pos);
39            (k.trim(), v[1..].trim())
40        } else if let Some(space_pos) = line.find(char::is_whitespace) {
41            let (k, v) = line.split_at(space_pos);
42            (k.trim(), v.trim())
43        } else {
44            continue;
45        };
46
47        match key.to_lowercase().as_str() {
48            "host" => {
49                flush_host_block(
50                    &current_aliases,
51                    &hostname,
52                    &user,
53                    &port,
54                    &identity_file,
55                    &proxy_jump,
56                    &mut hosts,
57                );
58
59                current_aliases = value
60                    .split_whitespace()
61                    .filter(|a| !a.contains('*') && !a.contains('?'))
62                    .map(String::from)
63                    .collect();
64                hostname = None;
65                user = None;
66                port = None;
67                identity_file = None;
68                proxy_jump = None;
69            }
70            "hostname" => hostname = Some(value.to_string()),
71            "user" => user = Some(value.to_string()),
72            "port" => port = value.parse().ok(),
73            "identityfile" => {
74                let expanded = if let Some(rest) = value.strip_prefix("~/") {
75                    if let Some(home) = dirs::home_dir() {
76                        format!("{}/{}", home.display(), rest)
77                    } else {
78                        value.to_string()
79                    }
80                } else {
81                    value.to_string()
82                };
83                identity_file = Some(expanded);
84            }
85            "proxyjump" => proxy_jump = Some(value.to_string()),
86            _ => {}
87        }
88    }
89
90    flush_host_block(
91        &current_aliases,
92        &hostname,
93        &user,
94        &port,
95        &identity_file,
96        &proxy_jump,
97        &mut hosts,
98    );
99
100    hosts
101}
102
103fn flush_host_block(
104    aliases: &[String],
105    hostname: &Option<String>,
106    user: &Option<String>,
107    port: &Option<u16>,
108    identity_file: &Option<String>,
109    proxy_jump: &Option<String>,
110    hosts: &mut Vec<SshHost>,
111) {
112    for alias in aliases {
113        hosts.push(SshHost {
114            alias: alias.clone(),
115            hostname: hostname.clone(),
116            user: user.clone(),
117            port: *port,
118            identity_file: identity_file.clone(),
119            proxy_jump: proxy_jump.clone(),
120            source: SshHostSource::Config,
121        });
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_parse_basic_host() {
131        let config = r#"
132Host myserver
133    HostName 192.168.1.100
134    User deploy
135    Port 2222
136"#;
137        let hosts = parse_ssh_config_str(config);
138        assert_eq!(hosts.len(), 1);
139        assert_eq!(hosts[0].alias, "myserver");
140        assert_eq!(hosts[0].hostname.as_deref(), Some("192.168.1.100"));
141        assert_eq!(hosts[0].user.as_deref(), Some("deploy"));
142        assert_eq!(hosts[0].port, Some(2222));
143    }
144
145    #[test]
146    fn test_parse_multiple_hosts() {
147        let config = r#"
148Host web
149    HostName web.example.com
150    User www
151
152Host db
153    HostName db.example.com
154    User postgres
155    Port 5432
156"#;
157        let hosts = parse_ssh_config_str(config);
158        assert_eq!(hosts.len(), 2);
159        assert_eq!(hosts[0].alias, "web");
160        assert_eq!(hosts[1].alias, "db");
161    }
162
163    #[test]
164    fn test_skip_wildcard_hosts() {
165        let config = r#"
166Host *
167    ServerAliveInterval 60
168
169Host *.example.com
170    User admin
171
172Host myserver
173    HostName 10.0.0.1
174"#;
175        let hosts = parse_ssh_config_str(config);
176        assert_eq!(hosts.len(), 1);
177        assert_eq!(hosts[0].alias, "myserver");
178    }
179
180    #[test]
181    fn test_multi_alias_host_line() {
182        let config = r#"
183Host foo bar
184    HostName shared.example.com
185    User shared
186"#;
187        let hosts = parse_ssh_config_str(config);
188        assert_eq!(hosts.len(), 2);
189        assert_eq!(hosts[0].alias, "foo");
190        assert_eq!(hosts[1].alias, "bar");
191        assert_eq!(hosts[0].hostname, hosts[1].hostname);
192    }
193
194    #[test]
195    fn test_proxy_jump() {
196        let config = r#"
197Host internal
198    HostName 10.0.0.5
199    ProxyJump bastion
200"#;
201        let hosts = parse_ssh_config_str(config);
202        assert_eq!(hosts.len(), 1);
203        assert_eq!(hosts[0].proxy_jump.as_deref(), Some("bastion"));
204    }
205
206    #[test]
207    fn test_identity_file_tilde_expansion() {
208        let config = r#"
209Host myhost
210    IdentityFile ~/.ssh/id_work
211"#;
212        let hosts = parse_ssh_config_str(config);
213        assert_eq!(hosts.len(), 1);
214        assert!(hosts[0].identity_file.is_some());
215        assert!(!hosts[0].identity_file.as_ref().unwrap().starts_with("~"));
216    }
217
218    #[test]
219    fn test_equals_syntax() {
220        let config = r#"
221Host eqhost
222    HostName=eq.example.com
223    User=equser
224    Port=3022
225"#;
226        let hosts = parse_ssh_config_str(config);
227        assert_eq!(hosts.len(), 1);
228        assert_eq!(hosts[0].hostname.as_deref(), Some("eq.example.com"));
229        assert_eq!(hosts[0].user.as_deref(), Some("equser"));
230        assert_eq!(hosts[0].port, Some(3022));
231    }
232
233    #[test]
234    fn test_comments_and_empty_lines() {
235        let config = r#"
236# This is a comment
237Host server1
238    # HostName commented.out
239    HostName real.example.com
240
241    User admin
242"#;
243        let hosts = parse_ssh_config_str(config);
244        assert_eq!(hosts.len(), 1);
245        assert_eq!(hosts[0].hostname.as_deref(), Some("real.example.com"));
246    }
247
248    #[test]
249    fn test_empty_config() {
250        let hosts = parse_ssh_config_str("");
251        assert!(hosts.is_empty());
252    }
253
254    #[test]
255    fn test_ssh_args_basic() {
256        let host = SshHost {
257            alias: "myhost".to_string(),
258            hostname: Some("10.0.0.1".to_string()),
259            user: Some("deploy".to_string()),
260            port: Some(2222),
261            identity_file: None,
262            proxy_jump: None,
263            source: SshHostSource::Config,
264        };
265        let args = host.ssh_args();
266        assert_eq!(args, vec!["-p", "2222", "deploy@10.0.0.1"]);
267    }
268
269    #[test]
270    fn test_ssh_args_default_port() {
271        let host = SshHost {
272            alias: "myhost".to_string(),
273            hostname: Some("10.0.0.1".to_string()),
274            user: None,
275            port: Some(22),
276            identity_file: None,
277            proxy_jump: None,
278            source: SshHostSource::Config,
279        };
280        let args = host.ssh_args();
281        assert_eq!(args, vec!["10.0.0.1"]);
282    }
283
284    #[test]
285    fn test_connection_string() {
286        let host = SshHost {
287            alias: "myhost".to_string(),
288            hostname: Some("10.0.0.1".to_string()),
289            user: Some("deploy".to_string()),
290            port: Some(2222),
291            identity_file: None,
292            proxy_jump: None,
293            source: SshHostSource::Config,
294        };
295        assert_eq!(host.connection_string(), "deploy@10.0.0.1:2222");
296    }
297}