Skip to main content

purple_ssh/ssh_config/
parser.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5use super::model::{
6    ConfigElement, Directive, HostBlock, IncludeDirective, IncludedFile, SshConfigFile,
7};
8
9const MAX_INCLUDE_DEPTH: usize = 5;
10
11impl SshConfigFile {
12    /// Parse an SSH config file from the given path.
13    /// Preserves all formatting, comments, and unknown directives for round-trip fidelity.
14    pub fn parse(path: &Path) -> Result<Self> {
15        Self::parse_with_depth(path, 0)
16    }
17
18    fn parse_with_depth(path: &Path, depth: usize) -> Result<Self> {
19        let content = if path.exists() {
20            std::fs::read_to_string(path)
21                .with_context(|| format!("Failed to read SSH config at {}", path.display()))?
22        } else {
23            String::new()
24        };
25
26        let crlf = content.contains("\r\n");
27        let config_dir = path.parent().map(|p| p.to_path_buf());
28        let elements = Self::parse_content_with_includes(&content, config_dir.as_deref(), depth);
29
30        Ok(SshConfigFile {
31            elements,
32            path: path.to_path_buf(),
33            crlf,
34        })
35    }
36
37    /// Parse SSH config content from a string (without Include resolution).
38    /// Used by tests to create SshConfigFile from inline strings.
39    #[allow(dead_code)]
40    pub fn parse_content(content: &str) -> Vec<ConfigElement> {
41        Self::parse_content_with_includes(content, None, MAX_INCLUDE_DEPTH)
42    }
43
44    /// Parse SSH config content, optionally resolving Include directives.
45    fn parse_content_with_includes(
46        content: &str,
47        config_dir: Option<&Path>,
48        depth: usize,
49    ) -> Vec<ConfigElement> {
50        let mut elements = Vec::new();
51        let mut current_block: Option<HostBlock> = None;
52
53        for line in content.lines() {
54            let trimmed = line.trim();
55
56            // Check for Include directive.
57            // An indented Include inside a Host block is preserved as a directive
58            // (not a top-level Include). A non-indented Include flushes the block.
59            let is_indented = line.starts_with(' ') || line.starts_with('\t');
60            if !(current_block.is_some() && is_indented) {
61                if let Some(pattern) = Self::parse_include_line(trimmed) {
62                    if let Some(block) = current_block.take() {
63                        elements.push(ConfigElement::HostBlock(block));
64                    }
65                    let resolved = if depth < MAX_INCLUDE_DEPTH {
66                        Self::resolve_include(pattern, config_dir, depth)
67                    } else {
68                        Vec::new()
69                    };
70                    elements.push(ConfigElement::Include(IncludeDirective {
71                        raw_line: line.to_string(),
72                        pattern: pattern.to_string(),
73                        resolved_files: resolved,
74                    }));
75                    continue;
76                }
77            }
78
79            // Check if this line starts a new Host block
80            if let Some(pattern) = Self::parse_host_line(trimmed) {
81                // Flush the previous block if any
82                if let Some(block) = current_block.take() {
83                    elements.push(ConfigElement::HostBlock(block));
84                }
85                current_block = Some(HostBlock {
86                    host_pattern: pattern,
87                    raw_host_line: line.to_string(),
88                    directives: Vec::new(),
89                });
90                continue;
91            }
92
93            // If we're inside a Host block, add this line as a directive
94            if let Some(ref mut block) = current_block {
95                if trimmed.is_empty() || trimmed.starts_with('#') {
96                    // Comment or blank line inside a host block
97                    block.directives.push(Directive {
98                        key: String::new(),
99                        value: String::new(),
100                        raw_line: line.to_string(),
101                        is_non_directive: true,
102                    });
103                } else if let Some((key, value)) = Self::parse_directive(trimmed) {
104                    block.directives.push(Directive {
105                        key,
106                        value,
107                        raw_line: line.to_string(),
108                        is_non_directive: false,
109                    });
110                } else {
111                    // Unrecognized line format — preserve verbatim
112                    block.directives.push(Directive {
113                        key: String::new(),
114                        value: String::new(),
115                        raw_line: line.to_string(),
116                        is_non_directive: true,
117                    });
118                }
119            } else {
120                // Global line (before any Host block)
121                elements.push(ConfigElement::GlobalLine(line.to_string()));
122            }
123        }
124
125        // Flush the last block
126        if let Some(block) = current_block {
127            elements.push(ConfigElement::HostBlock(block));
128        }
129
130        elements
131    }
132
133    /// Parse an Include directive line. Returns the pattern if it matches.
134    /// Handles both space and tab between keyword and value (SSH allows either).
135    fn parse_include_line(trimmed: &str) -> Option<&str> {
136        let bytes = trimmed.as_bytes();
137        // "include" is 7 ASCII bytes; byte 7 must be ASCII whitespace (space or tab)
138        if bytes.len() > 8
139            && bytes[..7].eq_ignore_ascii_case(b"include")
140            && bytes[7].is_ascii_whitespace()
141        {
142            // byte 8 is safe to slice at: bytes 0-7 are ASCII, so byte 8 is a char boundary
143            let pattern = trimmed[8..].trim();
144            if !pattern.is_empty() {
145                return Some(pattern);
146            }
147        }
148        None
149    }
150
151    /// Resolve an Include pattern to a list of included files.
152    fn resolve_include(
153        pattern: &str,
154        config_dir: Option<&Path>,
155        depth: usize,
156    ) -> Vec<IncludedFile> {
157        let expanded = Self::expand_tilde(pattern);
158
159        // If relative path, resolve against config dir
160        let glob_pattern = if expanded.starts_with('/') {
161            expanded
162        } else if let Some(dir) = config_dir {
163            dir.join(&expanded).to_string_lossy().to_string()
164        } else {
165            return Vec::new();
166        };
167
168        let mut files = Vec::new();
169        if let Ok(paths) = glob::glob(&glob_pattern) {
170            let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
171            matched.sort();
172            for path in matched {
173                if path.is_file() {
174                    if let Ok(content) = std::fs::read_to_string(&path) {
175                        let elements = Self::parse_content_with_includes(
176                            &content,
177                            path.parent(),
178                            depth + 1,
179                        );
180                        files.push(IncludedFile {
181                            path: path.clone(),
182                            elements,
183                        });
184                    }
185                }
186            }
187        }
188        files
189    }
190
191    /// Expand ~ to the home directory.
192    pub(crate) fn expand_tilde(pattern: &str) -> String {
193        if let Some(rest) = pattern.strip_prefix("~/") {
194            if let Some(home) = dirs::home_dir() {
195                return format!("{}/{}", home.display(), rest);
196            }
197        }
198        pattern.to_string()
199    }
200
201    /// Check if a line is a "Host <pattern>" line.
202    /// Returns the pattern if it is.
203    /// Handles both space and tab between keyword and value (SSH allows either).
204    fn parse_host_line(trimmed: &str) -> Option<String> {
205        // Split on first space or tab to isolate the keyword
206        let mut parts = trimmed.splitn(2, [' ', '\t']);
207        let keyword = parts.next()?;
208        if !keyword.eq_ignore_ascii_case("host") {
209            return None;
210        }
211        // "hostname" splits as keyword="hostname" which fails the check above
212        let pattern = parts.next()?.trim().to_string();
213        if !pattern.is_empty() {
214            return Some(pattern);
215        }
216        None
217    }
218
219    /// Parse a "Key Value" directive line.
220    /// Matches OpenSSH behavior: keyword ends at first whitespace or `=`.
221    /// An `=` in the value portion (e.g. `IdentityFile ~/.ssh/id=prod`) is
222    /// NOT treated as a separator.
223    fn parse_directive(trimmed: &str) -> Option<(String, String)> {
224        // Find end of keyword: first whitespace or '='
225        let key_end = trimmed.find(|c: char| c.is_whitespace() || c == '=')?;
226        let key = &trimmed[..key_end];
227        if key.is_empty() {
228            return None;
229        }
230
231        // Skip whitespace, optional '=', and more whitespace after the keyword
232        let rest = trimmed[key_end..].trim_start();
233        let rest = rest.strip_prefix('=').unwrap_or(rest);
234        let value = rest.trim_start();
235
236        // Strip inline comments (# preceded by whitespace) from parsed value,
237        // but only outside quoted strings. Raw_line is untouched for round-trip fidelity.
238        let value = strip_inline_comment(value);
239
240        Some((key.to_string(), value.to_string()))
241    }
242}
243
244/// Strip an inline comment (`# ...` preceded by whitespace) from a parsed value,
245/// respecting double-quoted strings.
246fn strip_inline_comment(value: &str) -> &str {
247    let bytes = value.as_bytes();
248    let mut in_quote = false;
249    for i in 0..bytes.len() {
250        if bytes[i] == b'"' {
251            in_quote = !in_quote;
252        } else if !in_quote
253            && bytes[i] == b'#'
254            && i > 0
255            && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
256        {
257            return value[..i].trim_end();
258        }
259    }
260    value
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use std::path::PathBuf;
267
268    fn parse_str(content: &str) -> SshConfigFile {
269        SshConfigFile {
270            elements: SshConfigFile::parse_content(content),
271            path: PathBuf::from("/tmp/test_config"),
272            crlf: content.contains("\r\n"),
273        }
274    }
275
276    #[test]
277    fn test_empty_config() {
278        let config = parse_str("");
279        assert!(config.host_entries().is_empty());
280    }
281
282    #[test]
283    fn test_basic_host() {
284        let config = parse_str(
285            "Host myserver\n  HostName 192.168.1.10\n  User admin\n  Port 2222\n",
286        );
287        let entries = config.host_entries();
288        assert_eq!(entries.len(), 1);
289        assert_eq!(entries[0].alias, "myserver");
290        assert_eq!(entries[0].hostname, "192.168.1.10");
291        assert_eq!(entries[0].user, "admin");
292        assert_eq!(entries[0].port, 2222);
293    }
294
295    #[test]
296    fn test_multiple_hosts() {
297        let content = "\
298Host alpha
299  HostName alpha.example.com
300  User deploy
301
302Host beta
303  HostName beta.example.com
304  User root
305  Port 22022
306";
307        let config = parse_str(content);
308        let entries = config.host_entries();
309        assert_eq!(entries.len(), 2);
310        assert_eq!(entries[0].alias, "alpha");
311        assert_eq!(entries[1].alias, "beta");
312        assert_eq!(entries[1].port, 22022);
313    }
314
315    #[test]
316    fn test_wildcard_host_filtered() {
317        let content = "\
318Host *
319  ServerAliveInterval 60
320
321Host myserver
322  HostName 10.0.0.1
323";
324        let config = parse_str(content);
325        let entries = config.host_entries();
326        assert_eq!(entries.len(), 1);
327        assert_eq!(entries[0].alias, "myserver");
328    }
329
330    #[test]
331    fn test_comments_preserved() {
332        let content = "\
333# Global comment
334Host myserver
335  # This is a comment
336  HostName 10.0.0.1
337  User admin
338";
339        let config = parse_str(content);
340        // Check that the global comment is preserved
341        assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment"));
342        // Check that the host block has the comment directive
343        if let ConfigElement::HostBlock(block) = &config.elements[1] {
344            assert!(block.directives[0].is_non_directive);
345            assert_eq!(block.directives[0].raw_line, "  # This is a comment");
346        } else {
347            panic!("Expected HostBlock");
348        }
349    }
350
351    #[test]
352    fn test_identity_file_and_proxy_jump() {
353        let content = "\
354Host bastion
355  HostName bastion.example.com
356  User admin
357  IdentityFile ~/.ssh/id_ed25519
358  ProxyJump gateway
359";
360        let config = parse_str(content);
361        let entries = config.host_entries();
362        assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
363        assert_eq!(entries[0].proxy_jump, "gateway");
364    }
365
366    #[test]
367    fn test_unknown_directives_preserved() {
368        let content = "\
369Host myserver
370  HostName 10.0.0.1
371  ForwardAgent yes
372  LocalForward 8080 localhost:80
373";
374        let config = parse_str(content);
375        if let ConfigElement::HostBlock(block) = &config.elements[0] {
376            assert_eq!(block.directives.len(), 3);
377            assert_eq!(block.directives[1].key, "ForwardAgent");
378            assert_eq!(block.directives[1].value, "yes");
379            assert_eq!(block.directives[2].key, "LocalForward");
380        } else {
381            panic!("Expected HostBlock");
382        }
383    }
384
385    #[test]
386    fn test_include_directive_parsed() {
387        let content = "\
388Include config.d/*
389
390Host myserver
391  HostName 10.0.0.1
392";
393        let config = parse_str(content);
394        // parse_content uses no config_dir, so Include resolves to no files
395        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*"));
396        // Blank line becomes a GlobalLine between Include and HostBlock
397        assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
398        assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
399    }
400
401    #[test]
402    fn test_include_round_trip() {
403        let content = "\
404Include ~/.ssh/config.d/*
405
406Host myserver
407  HostName 10.0.0.1
408";
409        let config = parse_str(content);
410        assert_eq!(config.serialize(), content);
411    }
412
413    #[test]
414    fn test_ssh_command() {
415        use crate::ssh_config::model::HostEntry;
416        let entry = HostEntry {
417            alias: "myserver".to_string(),
418            hostname: "10.0.0.1".to_string(),
419            ..Default::default()
420        };
421        assert_eq!(entry.ssh_command(), "ssh -- 'myserver'");
422    }
423
424    #[test]
425    fn test_unicode_comment_no_panic() {
426        // "# abcdeé" has byte 8 mid-character (é starts at byte 7, is 2 bytes)
427        // This must not panic in parse_include_line
428        let content = "# abcde\u{00e9} test\n\nHost myserver\n  HostName 10.0.0.1\n";
429        let config = parse_str(content);
430        let entries = config.host_entries();
431        assert_eq!(entries.len(), 1);
432        assert_eq!(entries[0].alias, "myserver");
433    }
434
435    #[test]
436    fn test_unicode_multibyte_line_no_panic() {
437        // Three 3-byte CJK characters: byte 8 falls mid-character
438        let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n  HostName 10.0.0.1\n";
439        let config = parse_str(content);
440        let entries = config.host_entries();
441        assert_eq!(entries.len(), 1);
442    }
443
444    #[test]
445    fn test_host_with_tab_separator() {
446        let content = "Host\tmyserver\n  HostName 10.0.0.1\n";
447        let config = parse_str(content);
448        let entries = config.host_entries();
449        assert_eq!(entries.len(), 1);
450        assert_eq!(entries[0].alias, "myserver");
451    }
452
453    #[test]
454    fn test_include_with_tab_separator() {
455        let content = "Include\tconfig.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
456        let config = parse_str(content);
457        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
458    }
459
460    #[test]
461    fn test_hostname_not_confused_with_host() {
462        // "HostName" should not be parsed as a Host line
463        let content = "Host myserver\n  HostName example.com\n";
464        let config = parse_str(content);
465        let entries = config.host_entries();
466        assert_eq!(entries.len(), 1);
467        assert_eq!(entries[0].hostname, "example.com");
468    }
469
470    #[test]
471    fn test_equals_in_value_not_treated_as_separator() {
472        let content = "Host myserver\n  IdentityFile ~/.ssh/id=prod\n";
473        let config = parse_str(content);
474        let entries = config.host_entries();
475        assert_eq!(entries.len(), 1);
476        assert_eq!(entries[0].identity_file, "~/.ssh/id=prod");
477    }
478
479    #[test]
480    fn test_equals_syntax_key_value() {
481        let content = "Host myserver\n  HostName=10.0.0.1\n  User = admin\n";
482        let config = parse_str(content);
483        let entries = config.host_entries();
484        assert_eq!(entries.len(), 1);
485        assert_eq!(entries[0].hostname, "10.0.0.1");
486        assert_eq!(entries[0].user, "admin");
487    }
488
489    #[test]
490    fn test_inline_comment_inside_quotes_preserved() {
491        let content = "Host myserver\n  ProxyCommand ssh -W \"%h #test\" gateway\n";
492        let config = parse_str(content);
493        let entries = config.host_entries();
494        assert_eq!(entries.len(), 1);
495        // The value should preserve the # inside quotes
496        if let ConfigElement::HostBlock(block) = &config.elements[0] {
497            let proxy_cmd = block.directives.iter().find(|d| d.key == "ProxyCommand").unwrap();
498            assert_eq!(proxy_cmd.value, "ssh -W \"%h #test\" gateway");
499        } else {
500            panic!("Expected HostBlock");
501        }
502    }
503
504    #[test]
505    fn test_inline_comment_outside_quotes_stripped() {
506        let content = "Host myserver\n  HostName 10.0.0.1 # production\n";
507        let config = parse_str(content);
508        let entries = config.host_entries();
509        assert_eq!(entries[0].hostname, "10.0.0.1");
510    }
511}