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            // Non-indented Match line = block boundary (flush current Host block).
80            // Match blocks are stored as GlobalLines (inert, never edited/deleted).
81            if !is_indented && Self::is_match_line(trimmed) {
82                if let Some(block) = current_block.take() {
83                    elements.push(ConfigElement::HostBlock(block));
84                }
85                elements.push(ConfigElement::GlobalLine(line.to_string()));
86                continue;
87            }
88
89            // Check if this line starts a new Host block
90            if let Some(pattern) = Self::parse_host_line(trimmed) {
91                // Flush the previous block if any
92                if let Some(block) = current_block.take() {
93                    elements.push(ConfigElement::HostBlock(block));
94                }
95                current_block = Some(HostBlock {
96                    host_pattern: pattern,
97                    raw_host_line: line.to_string(),
98                    directives: Vec::new(),
99                });
100                continue;
101            }
102
103            // If we're inside a Host block, add this line as a directive
104            if let Some(ref mut block) = current_block {
105                if trimmed.is_empty() || trimmed.starts_with('#') {
106                    // Comment or blank line inside a host block
107                    block.directives.push(Directive {
108                        key: String::new(),
109                        value: String::new(),
110                        raw_line: line.to_string(),
111                        is_non_directive: true,
112                    });
113                } else if let Some((key, value)) = Self::parse_directive(trimmed) {
114                    block.directives.push(Directive {
115                        key,
116                        value,
117                        raw_line: line.to_string(),
118                        is_non_directive: false,
119                    });
120                } else {
121                    // Unrecognized line format — preserve verbatim
122                    block.directives.push(Directive {
123                        key: String::new(),
124                        value: String::new(),
125                        raw_line: line.to_string(),
126                        is_non_directive: true,
127                    });
128                }
129            } else {
130                // Global line (before any Host block)
131                elements.push(ConfigElement::GlobalLine(line.to_string()));
132            }
133        }
134
135        // Flush the last block
136        if let Some(block) = current_block {
137            elements.push(ConfigElement::HostBlock(block));
138        }
139
140        elements
141    }
142
143    /// Parse an Include directive line. Returns the pattern if it matches.
144    /// Handles space, tab and `=` between keyword and value (SSH allows all three).
145    /// Matches OpenSSH behavior: skip whitespace, optional `=`, more whitespace.
146    fn parse_include_line(trimmed: &str) -> Option<&str> {
147        let bytes = trimmed.as_bytes();
148        // "include" is 7 ASCII bytes; byte 7 must be whitespace or '='
149        if bytes.len() > 7
150            && bytes[..7].eq_ignore_ascii_case(b"include")
151        {
152            let sep = bytes[7];
153            if sep.is_ascii_whitespace() || sep == b'=' {
154                // Skip whitespace, optional '=', and more whitespace after keyword.
155                // All bytes 0..7 are ASCII so byte 7 onward is a valid slice point.
156                let rest = trimmed[7..].trim_start();
157                let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
158                if !rest.is_empty() {
159                    return Some(rest);
160                }
161            }
162        }
163        None
164    }
165
166    /// Resolve an Include pattern to a list of included files.
167    /// Supports multiple space-separated patterns on one line (SSH spec).
168    fn resolve_include(
169        pattern: &str,
170        config_dir: Option<&Path>,
171        depth: usize,
172    ) -> Vec<IncludedFile> {
173        let mut files = Vec::new();
174        let mut seen = std::collections::HashSet::new();
175
176        for single in pattern.split_whitespace() {
177            let expanded = Self::expand_tilde(single);
178
179            // If relative path, resolve against config dir
180            let glob_pattern = if expanded.starts_with('/') {
181                expanded
182            } else if let Some(dir) = config_dir {
183                dir.join(&expanded).to_string_lossy().to_string()
184            } else {
185                continue;
186            };
187
188            if let Ok(paths) = glob::glob(&glob_pattern) {
189                let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
190                matched.sort();
191                for path in matched {
192                    if path.is_file() && seen.insert(path.clone()) {
193                        match std::fs::read_to_string(&path) {
194                            Ok(content) => {
195                                let elements = Self::parse_content_with_includes(
196                                    &content,
197                                    path.parent(),
198                                    depth + 1,
199                                );
200                                files.push(IncludedFile {
201                                    path: path.clone(),
202                                    elements,
203                                });
204                            }
205                            Err(e) => {
206                                eprintln!(
207                                    "! Could not read Include file {}: {}",
208                                    path.display(),
209                                    e
210                                );
211                            }
212                        }
213                    }
214                }
215            }
216        }
217        files
218    }
219
220    /// Expand ~ to the home directory.
221    pub(crate) fn expand_tilde(pattern: &str) -> String {
222        if let Some(rest) = pattern.strip_prefix("~/") {
223            if let Some(home) = dirs::home_dir() {
224                return format!("{}/{}", home.display(), rest);
225            }
226        }
227        pattern.to_string()
228    }
229
230    /// Check if a line is a "Host <pattern>" line.
231    /// Returns the pattern if it is.
232    /// Handles both space and tab between keyword and value (SSH allows either).
233    /// Strips inline comments (`# ...` preceded by whitespace) from the pattern.
234    fn parse_host_line(trimmed: &str) -> Option<String> {
235        // Split on first space or tab to isolate the keyword
236        let mut parts = trimmed.splitn(2, [' ', '\t']);
237        let keyword = parts.next()?;
238        if !keyword.eq_ignore_ascii_case("host") {
239            return None;
240        }
241        // "hostname" splits as keyword="hostname" which fails the check above
242        let raw_pattern = parts.next()?.trim();
243        let pattern = strip_inline_comment(raw_pattern).to_string();
244        if !pattern.is_empty() {
245            return Some(pattern);
246        }
247        None
248    }
249
250    /// Check if a line is a "Match ..." line (block boundary).
251    fn is_match_line(trimmed: &str) -> bool {
252        let mut parts = trimmed.splitn(2, [' ', '\t']);
253        let keyword = parts.next().unwrap_or("");
254        keyword.eq_ignore_ascii_case("match")
255    }
256
257    /// Parse a "Key Value" directive line.
258    /// Matches OpenSSH behavior: keyword ends at first whitespace or `=`.
259    /// An `=` in the value portion (e.g. `IdentityFile ~/.ssh/id=prod`) is
260    /// NOT treated as a separator.
261    fn parse_directive(trimmed: &str) -> Option<(String, String)> {
262        // Find end of keyword: first whitespace or '='
263        let key_end = trimmed.find(|c: char| c.is_whitespace() || c == '=')?;
264        let key = &trimmed[..key_end];
265        if key.is_empty() {
266            return None;
267        }
268
269        // Skip whitespace, optional '=', and more whitespace after the keyword
270        let rest = trimmed[key_end..].trim_start();
271        let rest = rest.strip_prefix('=').unwrap_or(rest);
272        let value = rest.trim_start();
273
274        // Strip inline comments (# preceded by whitespace) from parsed value,
275        // but only outside quoted strings. Raw_line is untouched for round-trip fidelity.
276        let value = strip_inline_comment(value);
277
278        Some((key.to_string(), value.to_string()))
279    }
280}
281
282/// Strip an inline comment (`# ...` preceded by whitespace) from a parsed value,
283/// respecting double-quoted strings.
284fn strip_inline_comment(value: &str) -> &str {
285    let bytes = value.as_bytes();
286    let mut in_quote = false;
287    for i in 0..bytes.len() {
288        if bytes[i] == b'"' {
289            in_quote = !in_quote;
290        } else if !in_quote
291            && bytes[i] == b'#'
292            && i > 0
293            && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
294        {
295            return value[..i].trim_end();
296        }
297    }
298    value
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use std::path::PathBuf;
305
306    fn parse_str(content: &str) -> SshConfigFile {
307        SshConfigFile {
308            elements: SshConfigFile::parse_content(content),
309            path: PathBuf::from("/tmp/test_config"),
310            crlf: content.contains("\r\n"),
311        }
312    }
313
314    #[test]
315    fn test_empty_config() {
316        let config = parse_str("");
317        assert!(config.host_entries().is_empty());
318    }
319
320    #[test]
321    fn test_basic_host() {
322        let config = parse_str(
323            "Host myserver\n  HostName 192.168.1.10\n  User admin\n  Port 2222\n",
324        );
325        let entries = config.host_entries();
326        assert_eq!(entries.len(), 1);
327        assert_eq!(entries[0].alias, "myserver");
328        assert_eq!(entries[0].hostname, "192.168.1.10");
329        assert_eq!(entries[0].user, "admin");
330        assert_eq!(entries[0].port, 2222);
331    }
332
333    #[test]
334    fn test_multiple_hosts() {
335        let content = "\
336Host alpha
337  HostName alpha.example.com
338  User deploy
339
340Host beta
341  HostName beta.example.com
342  User root
343  Port 22022
344";
345        let config = parse_str(content);
346        let entries = config.host_entries();
347        assert_eq!(entries.len(), 2);
348        assert_eq!(entries[0].alias, "alpha");
349        assert_eq!(entries[1].alias, "beta");
350        assert_eq!(entries[1].port, 22022);
351    }
352
353    #[test]
354    fn test_wildcard_host_filtered() {
355        let content = "\
356Host *
357  ServerAliveInterval 60
358
359Host myserver
360  HostName 10.0.0.1
361";
362        let config = parse_str(content);
363        let entries = config.host_entries();
364        assert_eq!(entries.len(), 1);
365        assert_eq!(entries[0].alias, "myserver");
366    }
367
368    #[test]
369    fn test_comments_preserved() {
370        let content = "\
371# Global comment
372Host myserver
373  # This is a comment
374  HostName 10.0.0.1
375  User admin
376";
377        let config = parse_str(content);
378        // Check that the global comment is preserved
379        assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment"));
380        // Check that the host block has the comment directive
381        if let ConfigElement::HostBlock(block) = &config.elements[1] {
382            assert!(block.directives[0].is_non_directive);
383            assert_eq!(block.directives[0].raw_line, "  # This is a comment");
384        } else {
385            panic!("Expected HostBlock");
386        }
387    }
388
389    #[test]
390    fn test_identity_file_and_proxy_jump() {
391        let content = "\
392Host bastion
393  HostName bastion.example.com
394  User admin
395  IdentityFile ~/.ssh/id_ed25519
396  ProxyJump gateway
397";
398        let config = parse_str(content);
399        let entries = config.host_entries();
400        assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
401        assert_eq!(entries[0].proxy_jump, "gateway");
402    }
403
404    #[test]
405    fn test_unknown_directives_preserved() {
406        let content = "\
407Host myserver
408  HostName 10.0.0.1
409  ForwardAgent yes
410  LocalForward 8080 localhost:80
411";
412        let config = parse_str(content);
413        if let ConfigElement::HostBlock(block) = &config.elements[0] {
414            assert_eq!(block.directives.len(), 3);
415            assert_eq!(block.directives[1].key, "ForwardAgent");
416            assert_eq!(block.directives[1].value, "yes");
417            assert_eq!(block.directives[2].key, "LocalForward");
418        } else {
419            panic!("Expected HostBlock");
420        }
421    }
422
423    #[test]
424    fn test_include_directive_parsed() {
425        let content = "\
426Include config.d/*
427
428Host myserver
429  HostName 10.0.0.1
430";
431        let config = parse_str(content);
432        // parse_content uses no config_dir, so Include resolves to no files
433        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*"));
434        // Blank line becomes a GlobalLine between Include and HostBlock
435        assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
436        assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
437    }
438
439    #[test]
440    fn test_include_round_trip() {
441        let content = "\
442Include ~/.ssh/config.d/*
443
444Host myserver
445  HostName 10.0.0.1
446";
447        let config = parse_str(content);
448        assert_eq!(config.serialize(), content);
449    }
450
451    #[test]
452    fn test_ssh_command() {
453        use crate::ssh_config::model::HostEntry;
454        use std::path::PathBuf;
455        let entry = HostEntry {
456            alias: "myserver".to_string(),
457            hostname: "10.0.0.1".to_string(),
458            ..Default::default()
459        };
460        let default_path = dirs::home_dir().unwrap().join(".ssh/config");
461        assert_eq!(entry.ssh_command(&default_path), "ssh -- 'myserver'");
462        let custom_path = PathBuf::from("/tmp/my_config");
463        assert_eq!(entry.ssh_command(&custom_path), "ssh -F '/tmp/my_config' -- 'myserver'");
464    }
465
466    #[test]
467    fn test_unicode_comment_no_panic() {
468        // "# abcdeé" has byte 8 mid-character (é starts at byte 7, is 2 bytes)
469        // This must not panic in parse_include_line
470        let content = "# abcde\u{00e9} test\n\nHost myserver\n  HostName 10.0.0.1\n";
471        let config = parse_str(content);
472        let entries = config.host_entries();
473        assert_eq!(entries.len(), 1);
474        assert_eq!(entries[0].alias, "myserver");
475    }
476
477    #[test]
478    fn test_unicode_multibyte_line_no_panic() {
479        // Three 3-byte CJK characters: byte 8 falls mid-character
480        let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n  HostName 10.0.0.1\n";
481        let config = parse_str(content);
482        let entries = config.host_entries();
483        assert_eq!(entries.len(), 1);
484    }
485
486    #[test]
487    fn test_host_with_tab_separator() {
488        let content = "Host\tmyserver\n  HostName 10.0.0.1\n";
489        let config = parse_str(content);
490        let entries = config.host_entries();
491        assert_eq!(entries.len(), 1);
492        assert_eq!(entries[0].alias, "myserver");
493    }
494
495    #[test]
496    fn test_include_with_tab_separator() {
497        let content = "Include\tconfig.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
498        let config = parse_str(content);
499        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
500    }
501
502    #[test]
503    fn test_include_with_equals_separator() {
504        let content = "Include=config.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
505        let config = parse_str(content);
506        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
507    }
508
509    #[test]
510    fn test_include_with_space_equals_separator() {
511        let content = "Include =config.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
512        let config = parse_str(content);
513        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
514    }
515
516    #[test]
517    fn test_include_with_space_equals_space_separator() {
518        let content = "Include = config.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
519        let config = parse_str(content);
520        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
521    }
522
523    #[test]
524    fn test_hostname_not_confused_with_host() {
525        // "HostName" should not be parsed as a Host line
526        let content = "Host myserver\n  HostName example.com\n";
527        let config = parse_str(content);
528        let entries = config.host_entries();
529        assert_eq!(entries.len(), 1);
530        assert_eq!(entries[0].hostname, "example.com");
531    }
532
533    #[test]
534    fn test_equals_in_value_not_treated_as_separator() {
535        let content = "Host myserver\n  IdentityFile ~/.ssh/id=prod\n";
536        let config = parse_str(content);
537        let entries = config.host_entries();
538        assert_eq!(entries.len(), 1);
539        assert_eq!(entries[0].identity_file, "~/.ssh/id=prod");
540    }
541
542    #[test]
543    fn test_equals_syntax_key_value() {
544        let content = "Host myserver\n  HostName=10.0.0.1\n  User = admin\n";
545        let config = parse_str(content);
546        let entries = config.host_entries();
547        assert_eq!(entries.len(), 1);
548        assert_eq!(entries[0].hostname, "10.0.0.1");
549        assert_eq!(entries[0].user, "admin");
550    }
551
552    #[test]
553    fn test_inline_comment_inside_quotes_preserved() {
554        let content = "Host myserver\n  ProxyCommand ssh -W \"%h #test\" gateway\n";
555        let config = parse_str(content);
556        let entries = config.host_entries();
557        assert_eq!(entries.len(), 1);
558        // The value should preserve the # inside quotes
559        if let ConfigElement::HostBlock(block) = &config.elements[0] {
560            let proxy_cmd = block.directives.iter().find(|d| d.key == "ProxyCommand").unwrap();
561            assert_eq!(proxy_cmd.value, "ssh -W \"%h #test\" gateway");
562        } else {
563            panic!("Expected HostBlock");
564        }
565    }
566
567    #[test]
568    fn test_inline_comment_outside_quotes_stripped() {
569        let content = "Host myserver\n  HostName 10.0.0.1 # production\n";
570        let config = parse_str(content);
571        let entries = config.host_entries();
572        assert_eq!(entries[0].hostname, "10.0.0.1");
573    }
574
575    #[test]
576    fn test_host_inline_comment_stripped() {
577        let content = "Host alpha # this is a comment\n  HostName 10.0.0.1\n";
578        let config = parse_str(content);
579        let entries = config.host_entries();
580        assert_eq!(entries.len(), 1);
581        assert_eq!(entries[0].alias, "alpha");
582        // Raw line is preserved for round-trip fidelity
583        if let ConfigElement::HostBlock(block) = &config.elements[0] {
584            assert_eq!(block.raw_host_line, "Host alpha # this is a comment");
585            assert_eq!(block.host_pattern, "alpha");
586        } else {
587            panic!("Expected HostBlock");
588        }
589    }
590
591    #[test]
592    fn test_match_block_is_global_line() {
593        let content = "\
594Host myserver
595  HostName 10.0.0.1
596
597Match host *.example.com
598  ForwardAgent yes
599";
600        let config = parse_str(content);
601        // Match line should flush the Host block and become a GlobalLine
602        let host_count = config.elements.iter().filter(|e| matches!(e, ConfigElement::HostBlock(_))).count();
603        assert_eq!(host_count, 1);
604        // Match line itself
605        assert!(config.elements.iter().any(|e| matches!(e, ConfigElement::GlobalLine(s) if s == "Match host *.example.com")));
606        // Indented lines after Match (no current_block) become GlobalLines
607        assert!(config.elements.iter().any(|e| matches!(e, ConfigElement::GlobalLine(s) if s.contains("ForwardAgent"))));
608    }
609
610    #[test]
611    fn test_match_block_survives_host_deletion() {
612        let content = "\
613Host myserver
614  HostName 10.0.0.1
615
616Match host *.example.com
617  ForwardAgent yes
618
619Host other
620  HostName 10.0.0.2
621";
622        let mut config = parse_str(content);
623        config.delete_host("myserver");
624        let output = config.serialize();
625        assert!(output.contains("Match host *.example.com"));
626        assert!(output.contains("ForwardAgent yes"));
627        assert!(output.contains("Host other"));
628        assert!(!output.contains("Host myserver"));
629    }
630
631    #[test]
632    fn test_match_block_round_trip() {
633        let content = "\
634Host myserver
635  HostName 10.0.0.1
636
637Match host *.example.com
638  ForwardAgent yes
639";
640        let config = parse_str(content);
641        assert_eq!(config.serialize(), content);
642    }
643
644    #[test]
645    fn test_match_at_start_of_file() {
646        let content = "\
647Match all
648  ServerAliveInterval 60
649
650Host myserver
651  HostName 10.0.0.1
652";
653        let config = parse_str(content);
654        assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "Match all"));
655        assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.contains("ServerAliveInterval")));
656        let entries = config.host_entries();
657        assert_eq!(entries.len(), 1);
658        assert_eq!(entries[0].alias, "myserver");
659    }
660
661    #[test]
662    fn test_host_multi_pattern_with_inline_comment() {
663        // Multi-pattern host with inline comment: "prod staging # servers"
664        // The comment should be stripped, but "prod staging" is still multi-pattern
665        // and gets filtered by host_entries()
666        let content = "Host prod staging # servers\n  HostName 10.0.0.1\n";
667        let config = parse_str(content);
668        if let ConfigElement::HostBlock(block) = &config.elements[0] {
669            assert_eq!(block.host_pattern, "prod staging");
670        } else {
671            panic!("Expected HostBlock");
672        }
673        // Multi-pattern hosts are filtered out of host_entries
674        assert_eq!(config.host_entries().len(), 0);
675    }
676}