Skip to main content

purple_ssh/ssh_config/
parser.rs

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