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