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(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
23pub 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 ¤t_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 ¤t_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}