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 = 16;
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        // Strip UTF-8 BOM if present (Windows editors like Notepad add this).
27        let (bom, content) = match content.strip_prefix('\u{FEFF}') {
28            Some(stripped) => (true, stripped),
29            None => (false, content.as_str()),
30        };
31
32        let crlf = content.contains("\r\n");
33        let config_dir = path.parent().map(|p| p.to_path_buf());
34        let elements = Self::parse_content_with_includes(content, config_dir.as_deref(), depth);
35
36        Ok(SshConfigFile {
37            elements,
38            path: path.to_path_buf(),
39            crlf,
40            bom,
41        })
42    }
43
44    /// Parse SSH config content from a string (without Include resolution).
45    /// Used by tests to create SshConfigFile from inline strings.
46    #[allow(dead_code)]
47    pub fn parse_content(content: &str) -> Vec<ConfigElement> {
48        Self::parse_content_with_includes(content, None, MAX_INCLUDE_DEPTH)
49    }
50
51    /// Parse SSH config content, optionally resolving Include directives.
52    fn parse_content_with_includes(
53        content: &str,
54        config_dir: Option<&Path>,
55        depth: usize,
56    ) -> Vec<ConfigElement> {
57        let mut elements = Vec::new();
58        let mut current_block: Option<HostBlock> = None;
59
60        for line in content.lines() {
61            let trimmed = line.trim();
62
63            // Check for Include directive.
64            // An indented Include inside a Host block is preserved as a directive
65            // (not a top-level Include). A non-indented Include flushes the block.
66            let is_indented = line.starts_with(' ') || line.starts_with('\t');
67            if !(current_block.is_some() && is_indented) {
68                if let Some(pattern) = Self::parse_include_line(trimmed) {
69                    if let Some(block) = current_block.take() {
70                        elements.push(ConfigElement::HostBlock(block));
71                    }
72                    let resolved = if depth < MAX_INCLUDE_DEPTH {
73                        Self::resolve_include(pattern, config_dir, depth)
74                    } else {
75                        Vec::new()
76                    };
77                    elements.push(ConfigElement::Include(IncludeDirective {
78                        raw_line: line.to_string(),
79                        pattern: pattern.to_string(),
80                        resolved_files: resolved,
81                    }));
82                    continue;
83                }
84            }
85
86            // Non-indented Match line = block boundary (flush current Host block).
87            // Match blocks are stored as GlobalLines (inert, never edited/deleted).
88            if !is_indented && Self::is_match_line(trimmed) {
89                if let Some(block) = current_block.take() {
90                    elements.push(ConfigElement::HostBlock(block));
91                }
92                elements.push(ConfigElement::GlobalLine(line.to_string()));
93                continue;
94            }
95
96            // Non-indented purple:group comment = block boundary (visual separator
97            // between provider groups, written as GlobalLine by the sync engine).
98            if !is_indented && trimmed.starts_with("# purple:group ") {
99                if let Some(block) = current_block.take() {
100                    elements.push(ConfigElement::HostBlock(block));
101                }
102                elements.push(ConfigElement::GlobalLine(line.to_string()));
103                continue;
104            }
105
106            // Check if this line starts a new Host block
107            if let Some(pattern) = Self::parse_host_line(trimmed) {
108                // Flush the previous block if any
109                if let Some(block) = current_block.take() {
110                    elements.push(ConfigElement::HostBlock(block));
111                }
112                current_block = Some(HostBlock {
113                    host_pattern: pattern,
114                    raw_host_line: line.to_string(),
115                    directives: Vec::new(),
116                });
117                continue;
118            }
119
120            // If we're inside a Host block, add this line as a directive
121            if let Some(ref mut block) = current_block {
122                if trimmed.is_empty() || trimmed.starts_with('#') {
123                    // Comment or blank line inside a host block
124                    block.directives.push(Directive {
125                        key: String::new(),
126                        value: String::new(),
127                        raw_line: line.to_string(),
128                        is_non_directive: true,
129                    });
130                } else if let Some((key, value)) = Self::parse_directive(trimmed) {
131                    block.directives.push(Directive {
132                        key,
133                        value,
134                        raw_line: line.to_string(),
135                        is_non_directive: false,
136                    });
137                } else {
138                    // Unrecognized line format — preserve verbatim
139                    block.directives.push(Directive {
140                        key: String::new(),
141                        value: String::new(),
142                        raw_line: line.to_string(),
143                        is_non_directive: true,
144                    });
145                }
146            } else {
147                // Global line (before any Host block)
148                elements.push(ConfigElement::GlobalLine(line.to_string()));
149            }
150        }
151
152        // Flush the last block
153        if let Some(block) = current_block {
154            elements.push(ConfigElement::HostBlock(block));
155        }
156
157        elements
158    }
159
160    /// Parse an Include directive line. Returns the pattern if it matches.
161    /// Handles space, tab and `=` between keyword and value (SSH allows all three).
162    /// Matches OpenSSH behavior: skip whitespace, optional `=`, more whitespace.
163    fn parse_include_line(trimmed: &str) -> Option<&str> {
164        let bytes = trimmed.as_bytes();
165        // "include" is 7 ASCII bytes; byte 7 must be whitespace or '='
166        if bytes.len() > 7
167            && bytes[..7].eq_ignore_ascii_case(b"include")
168        {
169            let sep = bytes[7];
170            if sep.is_ascii_whitespace() || sep == b'=' {
171                // Skip whitespace, optional '=', and more whitespace after keyword.
172                // All bytes 0..7 are ASCII so byte 7 onward is a valid slice point.
173                let rest = trimmed[7..].trim_start();
174                let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
175                if !rest.is_empty() {
176                    return Some(rest);
177                }
178            }
179        }
180        None
181    }
182
183    /// Split Include patterns respecting double-quoted paths.
184    /// OpenSSH supports `Include "path with spaces" other_path`.
185    pub(crate) fn split_include_patterns(pattern: &str) -> Vec<&str> {
186        let mut result = Vec::new();
187        let mut chars = pattern.char_indices().peekable();
188        while let Some(&(i, c)) = chars.peek() {
189            if c.is_whitespace() {
190                chars.next();
191                continue;
192            }
193            if c == '"' {
194                chars.next(); // skip opening quote
195                let start = i + 1;
196                let mut end = pattern.len();
197                for (j, ch) in chars.by_ref() {
198                    if ch == '"' {
199                        end = j;
200                        break;
201                    }
202                }
203                let token = &pattern[start..end];
204                if !token.is_empty() {
205                    result.push(token);
206                }
207            } else {
208                let start = i;
209                let mut end = pattern.len();
210                for (j, ch) in chars.by_ref() {
211                    if ch.is_whitespace() {
212                        end = j;
213                        break;
214                    }
215                }
216                result.push(&pattern[start..end]);
217            }
218        }
219        result
220    }
221
222    /// Resolve an Include pattern to a list of included files.
223    /// Supports multiple space-separated patterns on one line (SSH spec).
224    /// Handles quoted paths for paths containing spaces.
225    fn resolve_include(
226        pattern: &str,
227        config_dir: Option<&Path>,
228        depth: usize,
229    ) -> Vec<IncludedFile> {
230        let mut files = Vec::new();
231        let mut seen = std::collections::HashSet::new();
232
233        for single in Self::split_include_patterns(pattern) {
234            let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
235
236            // If relative path, resolve against config dir
237            let glob_pattern = if expanded.starts_with('/') {
238                expanded
239            } else if let Some(dir) = config_dir {
240                dir.join(&expanded).to_string_lossy().to_string()
241            } else {
242                continue;
243            };
244
245            if let Ok(paths) = glob::glob(&glob_pattern) {
246                let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
247                matched.sort();
248                for path in matched {
249                    if path.is_file() && seen.insert(path.clone()) {
250                        match std::fs::read_to_string(&path) {
251                            Ok(content) => {
252                                // Strip UTF-8 BOM if present (same as main config)
253                                let content = content
254                                    .strip_prefix('\u{FEFF}')
255                                    .unwrap_or(&content);
256                                let elements = Self::parse_content_with_includes(
257                                    content,
258                                    path.parent(),
259                                    depth + 1,
260                                );
261                                files.push(IncludedFile {
262                                    path: path.clone(),
263                                    elements,
264                                });
265                            }
266                            Err(e) => {
267                                eprintln!(
268                                    "! Could not read Include file {}: {}",
269                                    path.display(),
270                                    e
271                                );
272                            }
273                        }
274                    }
275                }
276            }
277        }
278        files
279    }
280
281    /// Expand ~ to the home directory.
282    pub(crate) fn expand_tilde(pattern: &str) -> String {
283        if let Some(rest) = pattern.strip_prefix("~/") {
284            if let Some(home) = dirs::home_dir() {
285                return format!("{}/{}", home.display(), rest);
286            }
287        }
288        pattern.to_string()
289    }
290
291    /// Expand `${VAR}` environment variable references (matches OpenSSH behavior).
292    /// Unknown variables are preserved as-is so that SSH itself can report the error.
293    pub(crate) fn expand_env_vars(s: &str) -> String {
294        let mut result = String::with_capacity(s.len());
295        let mut chars = s.char_indices().peekable();
296        while let Some((i, c)) = chars.next() {
297            if c == '$' {
298                if let Some(&(_, '{')) = chars.peek() {
299                    chars.next(); // consume '{'
300                    if let Some(close) = s[i + 2..].find('}') {
301                        let var_name = &s[i + 2..i + 2 + close];
302                        if let Ok(val) = std::env::var(var_name) {
303                            result.push_str(&val);
304                        } else {
305                            // Preserve unknown vars as-is
306                            result.push_str(&s[i..i + 2 + close + 1]);
307                        }
308                        // Advance past the closing '}'
309                        while let Some(&(j, _)) = chars.peek() {
310                            if j <= i + 2 + close {
311                                chars.next();
312                            } else {
313                                break;
314                            }
315                        }
316                        continue;
317                    }
318                    // No closing '}' — preserve literally
319                    result.push('$');
320                    result.push('{');
321                    continue;
322                }
323            }
324            result.push(c);
325        }
326        result
327    }
328
329    /// Check if a line is a "Host <pattern>" line.
330    /// Returns the pattern if it is.
331    /// Handles space, tab and `=` between keyword and value (SSH allows all three).
332    /// Matches OpenSSH behavior: skip whitespace, optional `=`, more whitespace.
333    /// Strips inline comments (`# ...` preceded by whitespace) from the pattern.
334    fn parse_host_line(trimmed: &str) -> Option<String> {
335        let bytes = trimmed.as_bytes();
336        // "host" is 4 ASCII bytes; byte 4 must be whitespace or '='
337        if bytes.len() > 4 && bytes[..4].eq_ignore_ascii_case(b"host") {
338            let sep = bytes[4];
339            if sep.is_ascii_whitespace() || sep == b'=' {
340                // Reject "hostname", "hostkey" etc: after "host" + separator,
341                // the keyword must end. If sep is alphanumeric, it's a different keyword.
342                // Skip whitespace, optional '=', and more whitespace after keyword.
343                let rest = trimmed[4..].trim_start();
344                let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
345                let pattern = strip_inline_comment(rest).to_string();
346                if !pattern.is_empty() {
347                    return Some(pattern);
348                }
349            }
350        }
351        None
352    }
353
354    /// Check if a line is a "Match ..." line (block boundary).
355    fn is_match_line(trimmed: &str) -> bool {
356        let mut parts = trimmed.splitn(2, [' ', '\t']);
357        let keyword = parts.next().unwrap_or("");
358        keyword.eq_ignore_ascii_case("match")
359    }
360
361    /// Parse a "Key Value" directive line.
362    /// Matches OpenSSH behavior: keyword ends at first whitespace or `=`.
363    /// An `=` in the value portion (e.g. `IdentityFile ~/.ssh/id=prod`) is
364    /// NOT treated as a separator.
365    fn parse_directive(trimmed: &str) -> Option<(String, String)> {
366        // Find end of keyword: first whitespace or '='
367        let key_end = trimmed.find(|c: char| c.is_whitespace() || c == '=')?;
368        let key = &trimmed[..key_end];
369        if key.is_empty() {
370            return None;
371        }
372
373        // Skip whitespace, optional '=', and more whitespace after the keyword
374        let rest = trimmed[key_end..].trim_start();
375        let rest = rest.strip_prefix('=').unwrap_or(rest);
376        let value = rest.trim_start();
377
378        // Strip inline comments (# preceded by whitespace) from parsed value,
379        // but only outside quoted strings. Raw_line is untouched for round-trip fidelity.
380        let value = strip_inline_comment(value);
381
382        Some((key.to_string(), value.to_string()))
383    }
384}
385
386/// Strip an inline comment (`# ...` preceded by whitespace) from a parsed value,
387/// respecting double-quoted strings.
388fn strip_inline_comment(value: &str) -> &str {
389    let bytes = value.as_bytes();
390    let mut in_quote = false;
391    for i in 0..bytes.len() {
392        if bytes[i] == b'"' {
393            in_quote = !in_quote;
394        } else if !in_quote
395            && bytes[i] == b'#'
396            && i > 0
397            && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
398        {
399            return value[..i].trim_end();
400        }
401    }
402    value
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use std::path::PathBuf;
409
410    fn parse_str(content: &str) -> SshConfigFile {
411        SshConfigFile {
412            elements: SshConfigFile::parse_content(content),
413            path: PathBuf::from("/tmp/test_config"),
414            crlf: content.contains("\r\n"),
415            bom: false,
416        }
417    }
418
419    #[test]
420    fn test_empty_config() {
421        let config = parse_str("");
422        assert!(config.host_entries().is_empty());
423    }
424
425    #[test]
426    fn test_basic_host() {
427        let config = parse_str(
428            "Host myserver\n  HostName 192.168.1.10\n  User admin\n  Port 2222\n",
429        );
430        let entries = config.host_entries();
431        assert_eq!(entries.len(), 1);
432        assert_eq!(entries[0].alias, "myserver");
433        assert_eq!(entries[0].hostname, "192.168.1.10");
434        assert_eq!(entries[0].user, "admin");
435        assert_eq!(entries[0].port, 2222);
436    }
437
438    #[test]
439    fn test_multiple_hosts() {
440        let content = "\
441Host alpha
442  HostName alpha.example.com
443  User deploy
444
445Host beta
446  HostName beta.example.com
447  User root
448  Port 22022
449";
450        let config = parse_str(content);
451        let entries = config.host_entries();
452        assert_eq!(entries.len(), 2);
453        assert_eq!(entries[0].alias, "alpha");
454        assert_eq!(entries[1].alias, "beta");
455        assert_eq!(entries[1].port, 22022);
456    }
457
458    #[test]
459    fn test_wildcard_host_filtered() {
460        let content = "\
461Host *
462  ServerAliveInterval 60
463
464Host myserver
465  HostName 10.0.0.1
466";
467        let config = parse_str(content);
468        let entries = config.host_entries();
469        assert_eq!(entries.len(), 1);
470        assert_eq!(entries[0].alias, "myserver");
471    }
472
473    #[test]
474    fn test_comments_preserved() {
475        let content = "\
476# Global comment
477Host myserver
478  # This is a comment
479  HostName 10.0.0.1
480  User admin
481";
482        let config = parse_str(content);
483        // Check that the global comment is preserved
484        assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment"));
485        // Check that the host block has the comment directive
486        if let ConfigElement::HostBlock(block) = &config.elements[1] {
487            assert!(block.directives[0].is_non_directive);
488            assert_eq!(block.directives[0].raw_line, "  # This is a comment");
489        } else {
490            panic!("Expected HostBlock");
491        }
492    }
493
494    #[test]
495    fn test_identity_file_and_proxy_jump() {
496        let content = "\
497Host bastion
498  HostName bastion.example.com
499  User admin
500  IdentityFile ~/.ssh/id_ed25519
501  ProxyJump gateway
502";
503        let config = parse_str(content);
504        let entries = config.host_entries();
505        assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
506        assert_eq!(entries[0].proxy_jump, "gateway");
507    }
508
509    #[test]
510    fn test_unknown_directives_preserved() {
511        let content = "\
512Host myserver
513  HostName 10.0.0.1
514  ForwardAgent yes
515  LocalForward 8080 localhost:80
516";
517        let config = parse_str(content);
518        if let ConfigElement::HostBlock(block) = &config.elements[0] {
519            assert_eq!(block.directives.len(), 3);
520            assert_eq!(block.directives[1].key, "ForwardAgent");
521            assert_eq!(block.directives[1].value, "yes");
522            assert_eq!(block.directives[2].key, "LocalForward");
523        } else {
524            panic!("Expected HostBlock");
525        }
526    }
527
528    #[test]
529    fn test_include_directive_parsed() {
530        let content = "\
531Include config.d/*
532
533Host myserver
534  HostName 10.0.0.1
535";
536        let config = parse_str(content);
537        // parse_content uses no config_dir, so Include resolves to no files
538        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*"));
539        // Blank line becomes a GlobalLine between Include and HostBlock
540        assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
541        assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
542    }
543
544    #[test]
545    fn test_include_round_trip() {
546        let content = "\
547Include ~/.ssh/config.d/*
548
549Host myserver
550  HostName 10.0.0.1
551";
552        let config = parse_str(content);
553        assert_eq!(config.serialize(), content);
554    }
555
556    #[test]
557    fn test_ssh_command() {
558        use crate::ssh_config::model::HostEntry;
559        use std::path::PathBuf;
560        let entry = HostEntry {
561            alias: "myserver".to_string(),
562            hostname: "10.0.0.1".to_string(),
563            ..Default::default()
564        };
565        let default_path = dirs::home_dir().unwrap().join(".ssh/config");
566        assert_eq!(entry.ssh_command(&default_path), "ssh -- 'myserver'");
567        let custom_path = PathBuf::from("/tmp/my_config");
568        assert_eq!(entry.ssh_command(&custom_path), "ssh -F '/tmp/my_config' -- 'myserver'");
569    }
570
571    #[test]
572    fn test_unicode_comment_no_panic() {
573        // "# abcdeé" has byte 8 mid-character (é starts at byte 7, is 2 bytes)
574        // This must not panic in parse_include_line
575        let content = "# abcde\u{00e9} test\n\nHost myserver\n  HostName 10.0.0.1\n";
576        let config = parse_str(content);
577        let entries = config.host_entries();
578        assert_eq!(entries.len(), 1);
579        assert_eq!(entries[0].alias, "myserver");
580    }
581
582    #[test]
583    fn test_unicode_multibyte_line_no_panic() {
584        // Three 3-byte CJK characters: byte 8 falls mid-character
585        let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n  HostName 10.0.0.1\n";
586        let config = parse_str(content);
587        let entries = config.host_entries();
588        assert_eq!(entries.len(), 1);
589    }
590
591    #[test]
592    fn test_host_with_tab_separator() {
593        let content = "Host\tmyserver\n  HostName 10.0.0.1\n";
594        let config = parse_str(content);
595        let entries = config.host_entries();
596        assert_eq!(entries.len(), 1);
597        assert_eq!(entries[0].alias, "myserver");
598    }
599
600    #[test]
601    fn test_include_with_tab_separator() {
602        let content = "Include\tconfig.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
603        let config = parse_str(content);
604        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
605    }
606
607    #[test]
608    fn test_include_with_equals_separator() {
609        let content = "Include=config.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
610        let config = parse_str(content);
611        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
612    }
613
614    #[test]
615    fn test_include_with_space_equals_separator() {
616        let content = "Include =config.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
617        let config = parse_str(content);
618        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
619    }
620
621    #[test]
622    fn test_include_with_space_equals_space_separator() {
623        let content = "Include = config.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
624        let config = parse_str(content);
625        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
626    }
627
628    #[test]
629    fn test_hostname_not_confused_with_host() {
630        // "HostName" should not be parsed as a Host line
631        let content = "Host myserver\n  HostName example.com\n";
632        let config = parse_str(content);
633        let entries = config.host_entries();
634        assert_eq!(entries.len(), 1);
635        assert_eq!(entries[0].hostname, "example.com");
636    }
637
638    #[test]
639    fn test_equals_in_value_not_treated_as_separator() {
640        let content = "Host myserver\n  IdentityFile ~/.ssh/id=prod\n";
641        let config = parse_str(content);
642        let entries = config.host_entries();
643        assert_eq!(entries.len(), 1);
644        assert_eq!(entries[0].identity_file, "~/.ssh/id=prod");
645    }
646
647    #[test]
648    fn test_equals_syntax_key_value() {
649        let content = "Host myserver\n  HostName=10.0.0.1\n  User = admin\n";
650        let config = parse_str(content);
651        let entries = config.host_entries();
652        assert_eq!(entries.len(), 1);
653        assert_eq!(entries[0].hostname, "10.0.0.1");
654        assert_eq!(entries[0].user, "admin");
655    }
656
657    #[test]
658    fn test_inline_comment_inside_quotes_preserved() {
659        let content = "Host myserver\n  ProxyCommand ssh -W \"%h #test\" gateway\n";
660        let config = parse_str(content);
661        let entries = config.host_entries();
662        assert_eq!(entries.len(), 1);
663        // The value should preserve the # inside quotes
664        if let ConfigElement::HostBlock(block) = &config.elements[0] {
665            let proxy_cmd = block.directives.iter().find(|d| d.key == "ProxyCommand").unwrap();
666            assert_eq!(proxy_cmd.value, "ssh -W \"%h #test\" gateway");
667        } else {
668            panic!("Expected HostBlock");
669        }
670    }
671
672    #[test]
673    fn test_inline_comment_outside_quotes_stripped() {
674        let content = "Host myserver\n  HostName 10.0.0.1 # production\n";
675        let config = parse_str(content);
676        let entries = config.host_entries();
677        assert_eq!(entries[0].hostname, "10.0.0.1");
678    }
679
680    #[test]
681    fn test_host_inline_comment_stripped() {
682        let content = "Host alpha # this is a comment\n  HostName 10.0.0.1\n";
683        let config = parse_str(content);
684        let entries = config.host_entries();
685        assert_eq!(entries.len(), 1);
686        assert_eq!(entries[0].alias, "alpha");
687        // Raw line is preserved for round-trip fidelity
688        if let ConfigElement::HostBlock(block) = &config.elements[0] {
689            assert_eq!(block.raw_host_line, "Host alpha # this is a comment");
690            assert_eq!(block.host_pattern, "alpha");
691        } else {
692            panic!("Expected HostBlock");
693        }
694    }
695
696    #[test]
697    fn test_match_block_is_global_line() {
698        let content = "\
699Host myserver
700  HostName 10.0.0.1
701
702Match host *.example.com
703  ForwardAgent yes
704";
705        let config = parse_str(content);
706        // Match line should flush the Host block and become a GlobalLine
707        let host_count = config.elements.iter().filter(|e| matches!(e, ConfigElement::HostBlock(_))).count();
708        assert_eq!(host_count, 1);
709        // Match line itself
710        assert!(config.elements.iter().any(|e| matches!(e, ConfigElement::GlobalLine(s) if s == "Match host *.example.com")));
711        // Indented lines after Match (no current_block) become GlobalLines
712        assert!(config.elements.iter().any(|e| matches!(e, ConfigElement::GlobalLine(s) if s.contains("ForwardAgent"))));
713    }
714
715    #[test]
716    fn test_match_block_survives_host_deletion() {
717        let content = "\
718Host myserver
719  HostName 10.0.0.1
720
721Match host *.example.com
722  ForwardAgent yes
723
724Host other
725  HostName 10.0.0.2
726";
727        let mut config = parse_str(content);
728        config.delete_host("myserver");
729        let output = config.serialize();
730        assert!(output.contains("Match host *.example.com"));
731        assert!(output.contains("ForwardAgent yes"));
732        assert!(output.contains("Host other"));
733        assert!(!output.contains("Host myserver"));
734    }
735
736    #[test]
737    fn test_match_block_round_trip() {
738        let content = "\
739Host myserver
740  HostName 10.0.0.1
741
742Match host *.example.com
743  ForwardAgent yes
744";
745        let config = parse_str(content);
746        assert_eq!(config.serialize(), content);
747    }
748
749    #[test]
750    fn test_match_at_start_of_file() {
751        let content = "\
752Match all
753  ServerAliveInterval 60
754
755Host myserver
756  HostName 10.0.0.1
757";
758        let config = parse_str(content);
759        assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "Match all"));
760        assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.contains("ServerAliveInterval")));
761        let entries = config.host_entries();
762        assert_eq!(entries.len(), 1);
763        assert_eq!(entries[0].alias, "myserver");
764    }
765
766    #[test]
767    fn test_host_equals_syntax() {
768        let config = parse_str("Host=foo\n  HostName 10.0.0.1\n");
769        let entries = config.host_entries();
770        assert_eq!(entries.len(), 1);
771        assert_eq!(entries[0].alias, "foo");
772    }
773
774    #[test]
775    fn test_host_space_equals_syntax() {
776        let config = parse_str("Host =foo\n  HostName 10.0.0.1\n");
777        let entries = config.host_entries();
778        assert_eq!(entries.len(), 1);
779        assert_eq!(entries[0].alias, "foo");
780    }
781
782    #[test]
783    fn test_host_equals_space_syntax() {
784        let config = parse_str("Host= foo\n  HostName 10.0.0.1\n");
785        let entries = config.host_entries();
786        assert_eq!(entries.len(), 1);
787        assert_eq!(entries[0].alias, "foo");
788    }
789
790    #[test]
791    fn test_host_space_equals_space_syntax() {
792        let config = parse_str("Host = foo\n  HostName 10.0.0.1\n");
793        let entries = config.host_entries();
794        assert_eq!(entries.len(), 1);
795        assert_eq!(entries[0].alias, "foo");
796    }
797
798    #[test]
799    fn test_host_equals_case_insensitive() {
800        let config = parse_str("HOST=foo\n  HostName 10.0.0.1\n");
801        let entries = config.host_entries();
802        assert_eq!(entries.len(), 1);
803        assert_eq!(entries[0].alias, "foo");
804    }
805
806    #[test]
807    fn test_hostname_equals_not_parsed_as_host() {
808        // "HostName=example.com" must NOT be parsed as a Host line
809        let config = parse_str("Host myserver\n  HostName=example.com\n");
810        let entries = config.host_entries();
811        assert_eq!(entries.len(), 1);
812        assert_eq!(entries[0].alias, "myserver");
813        assert_eq!(entries[0].hostname, "example.com");
814    }
815
816    #[test]
817    fn test_host_multi_pattern_with_inline_comment() {
818        // Multi-pattern host with inline comment: "prod staging # servers"
819        // The comment should be stripped, but "prod staging" is still multi-pattern
820        // and gets filtered by host_entries()
821        let content = "Host prod staging # servers\n  HostName 10.0.0.1\n";
822        let config = parse_str(content);
823        if let ConfigElement::HostBlock(block) = &config.elements[0] {
824            assert_eq!(block.host_pattern, "prod staging");
825        } else {
826            panic!("Expected HostBlock");
827        }
828        // Multi-pattern hosts are filtered out of host_entries
829        assert_eq!(config.host_entries().len(), 0);
830    }
831
832    #[test]
833    fn test_expand_env_vars_basic() {
834        // SAFETY: test-only, single-threaded context
835        unsafe { std::env::set_var("_PURPLE_TEST_VAR", "/custom/path") };
836        let result = SshConfigFile::expand_env_vars("${_PURPLE_TEST_VAR}/.ssh/config");
837        assert_eq!(result, "/custom/path/.ssh/config");
838        unsafe { std::env::remove_var("_PURPLE_TEST_VAR") };
839    }
840
841    #[test]
842    fn test_expand_env_vars_multiple() {
843        // SAFETY: test-only, single-threaded context
844        unsafe { std::env::set_var("_PURPLE_TEST_A", "hello") };
845        unsafe { std::env::set_var("_PURPLE_TEST_B", "world") };
846        let result = SshConfigFile::expand_env_vars("${_PURPLE_TEST_A}/${_PURPLE_TEST_B}");
847        assert_eq!(result, "hello/world");
848        unsafe { std::env::remove_var("_PURPLE_TEST_A") };
849        unsafe { std::env::remove_var("_PURPLE_TEST_B") };
850    }
851
852    #[test]
853    fn test_expand_env_vars_unknown_preserved() {
854        let result = SshConfigFile::expand_env_vars("${_PURPLE_NONEXISTENT_VAR}/path");
855        assert_eq!(result, "${_PURPLE_NONEXISTENT_VAR}/path");
856    }
857
858    #[test]
859    fn test_expand_env_vars_no_vars() {
860        let result = SshConfigFile::expand_env_vars("~/.ssh/config.d/*");
861        assert_eq!(result, "~/.ssh/config.d/*");
862    }
863
864    #[test]
865    fn test_expand_env_vars_unclosed_brace() {
866        let result = SshConfigFile::expand_env_vars("${UNCLOSED/path");
867        assert_eq!(result, "${UNCLOSED/path");
868    }
869
870    #[test]
871    fn test_expand_env_vars_dollar_without_brace() {
872        let result = SshConfigFile::expand_env_vars("$HOME/.ssh/config");
873        // Only ${VAR} syntax should be expanded, not bare $VAR
874        assert_eq!(result, "$HOME/.ssh/config");
875    }
876
877    #[test]
878    fn test_max_include_depth_matches_openssh() {
879        assert_eq!(MAX_INCLUDE_DEPTH, 16);
880    }
881
882    #[test]
883    fn test_split_include_patterns_single_unquoted() {
884        let result = SshConfigFile::split_include_patterns("config.d/*");
885        assert_eq!(result, vec!["config.d/*"]);
886    }
887
888    #[test]
889    fn test_split_include_patterns_quoted_with_spaces() {
890        let result = SshConfigFile::split_include_patterns("\"/path/with spaces/config\"");
891        assert_eq!(result, vec!["/path/with spaces/config"]);
892    }
893
894    #[test]
895    fn test_split_include_patterns_mixed() {
896        let result =
897            SshConfigFile::split_include_patterns("\"/path/with spaces/*\" ~/.ssh/config.d/*");
898        assert_eq!(result, vec!["/path/with spaces/*", "~/.ssh/config.d/*"]);
899    }
900
901    #[test]
902    fn test_split_include_patterns_quoted_no_spaces() {
903        let result = SshConfigFile::split_include_patterns("\"config.d/*\"");
904        assert_eq!(result, vec!["config.d/*"]);
905    }
906
907    #[test]
908    fn test_split_include_patterns_multiple_unquoted() {
909        let result = SshConfigFile::split_include_patterns("~/.ssh/conf.d/* /etc/ssh/config.d/*");
910        assert_eq!(
911            result,
912            vec!["~/.ssh/conf.d/*", "/etc/ssh/config.d/*"]
913        );
914    }
915}