omnyssh_core/config/
ssh_config.rs1use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10
11use crate::ssh::client::{Host, HostSource};
12
13pub fn parse_ssh_config(content: &str) -> Vec<Host> {
18 let mut visited: HashSet<PathBuf> = HashSet::new();
19 parse_content(content, 0, &mut visited)
20}
21
22pub fn load_from_file(path: &Path) -> anyhow::Result<Vec<Host>> {
27 let content = std::fs::read_to_string(path)
28 .map_err(|e| anyhow::anyhow!("Failed to read {}: {}", path.display(), e))?;
29 Ok(parse_ssh_config(&content))
30}
31
32fn parse_content(content: &str, depth: usize, visited: &mut HashSet<PathBuf>) -> Vec<Host> {
37 if depth > 3 {
38 return Vec::new();
39 }
40
41 let mut hosts: Vec<Host> = Vec::new();
42 let mut current: Option<Host> = None;
43 let mut in_wildcard = false;
45
46 for raw_line in content.lines() {
47 let line = strip_comment(raw_line).trim().to_string();
48 if line.is_empty() {
49 continue;
50 }
51
52 let Some((keyword, value)) = split_kv(&line) else {
53 continue;
54 };
55
56 match keyword.to_lowercase().as_str() {
57 "host" => {
58 if let Some(h) = current.take() {
60 hosts.push(h);
61 }
62 in_wildcard = value.contains('*') || value.contains('?');
63 if !in_wildcard {
64 let h = Host {
65 name: value.to_string(),
66 source: HostSource::SshConfig,
67 ..Host::default()
68 };
69 current = Some(h);
70 } else {
71 current = None;
72 }
73 }
74 "hostname" if !in_wildcard => {
75 if let Some(ref mut h) = current {
76 h.hostname = value.to_string();
77 }
78 }
79 "user" if !in_wildcard => {
80 if let Some(ref mut h) = current {
81 h.user = value.to_string();
82 }
83 }
84 "port" if !in_wildcard => {
85 if let Some(ref mut h) = current {
86 if let Ok(p) = value.parse::<u16>() {
87 h.port = p;
88 }
89 }
90 }
91 "identityfile" if !in_wildcard => {
92 if let Some(ref mut h) = current {
93 h.identity_file = Some(expand_tilde(value));
94 }
95 }
96 "proxyjump" if !in_wildcard => {
97 if let Some(ref mut h) = current {
98 h.proxy_jump = Some(value.to_string());
99 }
100 }
101 "include" => {
102 if let Some(h) = current.take() {
104 hosts.push(h);
105 }
106 in_wildcard = false;
107 let expanded = expand_tilde(value);
108 for path in expand_include_glob(&expanded) {
109 let canonical = path.canonicalize().unwrap_or_else(|e| {
111 tracing::warn!(
112 path = %path.display(),
113 error = %e,
114 "canonicalize failed; symlink-cycle detection disabled for this path"
115 );
116 path.clone()
117 });
118 if !visited.insert(canonical) {
119 continue; }
121 match std::fs::read_to_string(&path) {
122 Ok(sub) => hosts.extend(parse_content(&sub, depth + 1, visited)),
123 Err(e) => {
124 tracing::warn!(path = %path.display(), error = %e, "Include file unreadable")
125 }
126 }
127 }
128 }
129 _ => {} }
131 }
132
133 if let Some(h) = current.take() {
135 hosts.push(h);
136 }
137
138 for h in &mut hosts {
140 if h.hostname.is_empty() {
141 h.hostname = h.name.clone();
142 }
143 }
144
145 hosts
146}
147
148fn strip_comment(line: &str) -> &str {
150 match line.find('#') {
151 Some(pos) => &line[..pos],
152 None => line,
153 }
154}
155
156fn split_kv(line: &str) -> Option<(&str, &str)> {
158 let idx = line.find(|c: char| c.is_whitespace() || c == '=')?;
160 let keyword = line[..idx].trim();
161 let value = line[idx + 1..].trim_start_matches('=').trim();
162 if keyword.is_empty() || value.is_empty() {
163 None
164 } else {
165 Some((keyword, value))
166 }
167}
168
169fn expand_tilde(s: &str) -> String {
171 if let Some(rest) = s.strip_prefix("~/") {
172 if let Some(home) = dirs::home_dir() {
173 return home.join(rest).to_string_lossy().into_owned();
174 }
175 }
176 if s == "~" {
177 if let Some(home) = dirs::home_dir() {
178 return home.to_string_lossy().into_owned();
179 }
180 }
181 s.to_string()
182}
183
184fn expand_include_glob(pattern: &str) -> Vec<PathBuf> {
190 let path = PathBuf::from(pattern);
191 let parent = match path.parent() {
192 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
193 _ => PathBuf::from("."),
194 };
195 let file_name = match path.file_name().and_then(|f| f.to_str()) {
196 Some(n) => n.to_string(),
197 None => return Vec::new(),
198 };
199
200 if !file_name.contains('*') {
201 return if path.is_file() {
202 vec![path]
203 } else {
204 Vec::new()
205 };
206 }
207
208 let (prefix, suffix) = file_name.split_once('*').unwrap_or((&file_name, ""));
210 match std::fs::read_dir(&parent) {
211 Ok(entries) => {
212 let mut paths: Vec<PathBuf> = entries
213 .flatten()
214 .filter(|e| {
215 let name = e.file_name();
216 let name = name.to_string_lossy();
217 name.starts_with(prefix) && name.ends_with(suffix)
218 })
219 .map(|e| e.path())
220 .filter(|p| p.is_file())
221 .collect();
222 paths.sort(); paths
224 }
225 Err(_) => Vec::new(),
226 }
227}
228
229#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn test_empty_input() {
239 assert!(parse_ssh_config("").is_empty());
240 assert!(parse_ssh_config(" \n \n").is_empty());
241 }
242
243 #[test]
244 fn test_comments_only() {
245 let cfg = "# This is a comment\n# Another comment\n";
246 assert!(parse_ssh_config(cfg).is_empty());
247 }
248
249 #[test]
250 fn test_minimal_config() {
251 let cfg = "\
252Host myserver
253 HostName 192.168.1.100
254";
255 let hosts = parse_ssh_config(cfg);
256 assert_eq!(hosts.len(), 1);
257 assert_eq!(hosts[0].name, "myserver");
258 assert_eq!(hosts[0].hostname, "192.168.1.100");
259 let expected_user = std::env::var("USER")
261 .or_else(|_| std::env::var("LOGNAME"))
262 .unwrap_or_else(|_| String::from("root"));
263 assert_eq!(hosts[0].user, expected_user);
264 assert_eq!(hosts[0].port, 22); }
266
267 #[test]
268 fn test_hostname_fallback_to_name() {
269 let cfg = "\
271Host myalias
272 User admin
273";
274 let hosts = parse_ssh_config(cfg);
275 assert_eq!(hosts.len(), 1);
276 assert_eq!(hosts[0].hostname, "myalias");
277 }
278
279 #[test]
280 fn test_full_config_all_fields() {
281 let cfg = "\
282Host web-prod-1
283 HostName 192.168.1.10
284 User deploy
285 Port 2222
286 IdentityFile ~/.ssh/id_ed25519
287 ProxyJump bastion
288";
289 let hosts = parse_ssh_config(cfg);
290 assert_eq!(hosts.len(), 1);
291 let h = &hosts[0];
292 assert_eq!(h.name, "web-prod-1");
293 assert_eq!(h.hostname, "192.168.1.10");
294 assert_eq!(h.user, "deploy");
295 assert_eq!(h.port, 2222);
296 assert!(h
297 .identity_file
298 .as_deref()
299 .unwrap_or("")
300 .contains("id_ed25519"));
301 assert_eq!(h.proxy_jump.as_deref(), Some("bastion"));
302 }
303
304 #[test]
305 fn test_multiple_hosts() {
306 let cfg = "\
307Host web
308 HostName 10.0.0.1
309 User ubuntu
310
311Host db
312 HostName 10.0.0.2
313 User postgres
314 Port 5432
315";
316 let hosts = parse_ssh_config(cfg);
317 assert_eq!(hosts.len(), 2);
318 assert_eq!(hosts[0].name, "web");
319 assert_eq!(hosts[1].name, "db");
320 assert_eq!(hosts[1].port, 5432);
321 }
322
323 #[test]
324 fn test_wildcard_host_ignored() {
325 let cfg = "\
326Host *
327 User ubuntu
328 ServerAliveInterval 60
329
330Host realhost
331 HostName 10.0.0.1
332";
333 let hosts = parse_ssh_config(cfg);
334 assert_eq!(hosts.len(), 1);
336 assert_eq!(hosts[0].name, "realhost");
337 }
340
341 #[test]
342 fn test_proxy_jump() {
343 let cfg = "\
344Host bastion
345 HostName jump.example.com
346 User ops
347
348Host internal
349 HostName 192.168.100.50
350 User admin
351 ProxyJump bastion
352";
353 let hosts = parse_ssh_config(cfg);
354 assert_eq!(hosts.len(), 2);
355 let internal = hosts.iter().find(|h| h.name == "internal").unwrap();
356 assert_eq!(internal.proxy_jump.as_deref(), Some("bastion"));
357 }
358
359 #[test]
360 fn test_nonstandard_port() {
361 let cfg = "Host custom\n HostName 1.2.3.4\n Port 22022\n";
362 let hosts = parse_ssh_config(cfg);
363 assert_eq!(hosts[0].port, 22022);
364 }
365
366 #[test]
367 fn test_case_insensitive_keywords() {
368 let cfg = "\
370host server1
371 hostname 10.0.0.1
372 user admin
373 port 2222
374";
375 let hosts = parse_ssh_config(cfg);
376 assert_eq!(hosts.len(), 1);
377 assert_eq!(hosts[0].hostname, "10.0.0.1");
378 assert_eq!(hosts[0].user, "admin");
379 assert_eq!(hosts[0].port, 2222);
380 }
381
382 #[test]
383 fn test_inline_comment() {
384 let cfg = "Host srv # this is a comment\n HostName 1.2.3.4\n";
385 let hosts = parse_ssh_config(cfg);
386 assert_eq!(hosts.len(), 1);
389 assert_eq!(hosts[0].name, "srv");
390 }
391
392 #[test]
393 fn test_source_is_ssh_config() {
394 let cfg = "Host test\n HostName 1.2.3.4\n";
395 let hosts = parse_ssh_config(cfg);
396 assert_eq!(hosts[0].source, crate::ssh::client::HostSource::SshConfig);
397 }
398
399 #[test]
400 fn test_equals_separator() {
401 let cfg = "Host=myhost\n HostName=10.0.0.1\n User=admin\n";
403 let hosts = parse_ssh_config(cfg);
404 assert_eq!(hosts.len(), 1);
405 assert_eq!(hosts[0].hostname, "10.0.0.1");
406 assert_eq!(hosts[0].user, "admin");
407 }
408}