1use super::types::{SshHost, SshHostSource};
6use std::path::Path;
7
8pub 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
20pub 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 ¤t_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 ¤t_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}