Skip to main content

omnyssh_core/config/
ssh_config.rs

1//! Parser for `~/.ssh/config`.
2//!
3//! Supported directives: `Host`, `HostName`, `User`, `Port`,
4//! `IdentityFile`, `ProxyJump`, `Include`.
5//!
6//! The original file is **never modified**.
7
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10
11use crate::ssh::client::{Host, HostSource};
12
13/// Parses the text of an SSH config file and returns all non-wildcard hosts.
14///
15/// `Host *` entries are silently skipped.
16/// `Include` recursion is limited to 3 levels to prevent cycles.
17pub 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
22/// Loads and parses an SSH config file from disk.
23///
24/// # Errors
25/// Returns an error if the file cannot be read.
26pub 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
32// ---------------------------------------------------------------------------
33// Internal helpers
34// ---------------------------------------------------------------------------
35
36fn 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    // True when we are inside a wildcard `Host *` block (skip directives).
44    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                // Flush previous host before starting a new block.
59                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                // Flush the current host before processing Include.
103                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                    // Canonicalise to catch cycles (symlinks, etc.).
110                    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; // already visited — break cycle
120                    }
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            _ => {} // Unknown directive — silently ignore.
130        }
131    }
132
133    // Flush the last pending host.
134    if let Some(h) = current.take() {
135        hosts.push(h);
136    }
137
138    // Fallback: if HostName was never set, use the alias as the address.
139    for h in &mut hosts {
140        if h.hostname.is_empty() {
141            h.hostname = h.name.clone();
142        }
143    }
144
145    hosts
146}
147
148/// Removes everything from the first `#` onwards (inline comments).
149fn strip_comment(line: &str) -> &str {
150    match line.find('#') {
151        Some(pos) => &line[..pos],
152        None => line,
153    }
154}
155
156/// Splits `"Keyword Value"` or `"Keyword=Value"` into `("Keyword", "Value")`.
157fn split_kv(line: &str) -> Option<(&str, &str)> {
158    // Split on the first whitespace or '='.
159    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
169/// Expands a leading `~/` to the user's home directory.
170fn 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
184/// Resolves an Include pattern to a list of file paths.
185///
186/// Supports a single `*` wildcard in the file-name component only.
187/// The parent directory must exist; `*` in path segments other than the
188/// last one is not supported (matches OpenSSH behaviour).
189fn 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    // Simple single-`*` glob: match prefix and suffix.
209    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(); // deterministic order
223            paths
224        }
225        Err(_) => Vec::new(),
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Tests
231// ---------------------------------------------------------------------------
232
233#[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        // default_user() falls back to $USER / $LOGNAME / "root"
260        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); // default
265    }
266
267    #[test]
268    fn test_hostname_fallback_to_name() {
269        // If HostName is omitted, hostname should equal the alias.
270        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        // Only the non-wildcard host should be imported.
335        assert_eq!(hosts.len(), 1);
336        assert_eq!(hosts[0].name, "realhost");
337        // The wildcard User should NOT leak into realhost.
338        // (We don't apply wildcard defaults — per plan, just skip them.)
339    }
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        // OpenSSH config is case-insensitive for keywords.
369        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        // "srv # this is a comment" — name should be trimmed
387        // Note: our strip_comment removes '#' and after, so name = "srv"
388        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        // Some configs use '=' instead of space.
402        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}