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