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                                log::warn!(
305                                    "[config] 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    #[allow(unused_imports)]
446    use std::path::PathBuf;
447
448    fn parse_str(content: &str) -> SshConfigFile {
449        SshConfigFile {
450            elements: SshConfigFile::parse_content(content),
451            path: tempfile::tempdir()
452                .expect("tempdir")
453                .keep()
454                .join("test_config"),
455            crlf: content.contains("\r\n"),
456            bom: false,
457        }
458    }
459
460    #[test]
461    fn test_empty_config() {
462        let config = parse_str("");
463        assert!(config.host_entries().is_empty());
464    }
465
466    #[test]
467    fn test_basic_host() {
468        let config =
469            parse_str("Host myserver\n  HostName 192.168.1.10\n  User admin\n  Port 2222\n");
470        let entries = config.host_entries();
471        assert_eq!(entries.len(), 1);
472        assert_eq!(entries[0].alias, "myserver");
473        assert_eq!(entries[0].hostname, "192.168.1.10");
474        assert_eq!(entries[0].user, "admin");
475        assert_eq!(entries[0].port, 2222);
476    }
477
478    #[test]
479    fn test_multiple_hosts() {
480        let content = "\
481Host alpha
482  HostName alpha.example.com
483  User deploy
484
485Host beta
486  HostName beta.example.com
487  User root
488  Port 22022
489";
490        let config = parse_str(content);
491        let entries = config.host_entries();
492        assert_eq!(entries.len(), 2);
493        assert_eq!(entries[0].alias, "alpha");
494        assert_eq!(entries[1].alias, "beta");
495        assert_eq!(entries[1].port, 22022);
496    }
497
498    #[test]
499    fn test_wildcard_host_filtered() {
500        let content = "\
501Host *
502  ServerAliveInterval 60
503
504Host myserver
505  HostName 10.0.0.1
506";
507        let config = parse_str(content);
508        let entries = config.host_entries();
509        assert_eq!(entries.len(), 1);
510        assert_eq!(entries[0].alias, "myserver");
511    }
512
513    #[test]
514    fn test_comments_preserved() {
515        let content = "\
516# Global comment
517Host myserver
518  # This is a comment
519  HostName 10.0.0.1
520  User admin
521";
522        let config = parse_str(content);
523        // Check that the global comment is preserved
524        assert!(
525            matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment")
526        );
527        // Check that the host block has the comment directive
528        if let ConfigElement::HostBlock(block) = &config.elements[1] {
529            assert!(block.directives[0].is_non_directive);
530            assert_eq!(block.directives[0].raw_line, "  # This is a comment");
531        } else {
532            panic!("Expected HostBlock");
533        }
534    }
535
536    #[test]
537    fn test_identity_file_and_proxy_jump() {
538        let content = "\
539Host bastion
540  HostName bastion.example.com
541  User admin
542  IdentityFile ~/.ssh/id_ed25519
543  ProxyJump gateway
544";
545        let config = parse_str(content);
546        let entries = config.host_entries();
547        assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
548        assert_eq!(entries[0].proxy_jump, "gateway");
549    }
550
551    #[test]
552    fn test_unknown_directives_preserved() {
553        let content = "\
554Host myserver
555  HostName 10.0.0.1
556  ForwardAgent yes
557  LocalForward 8080 localhost:80
558";
559        let config = parse_str(content);
560        if let ConfigElement::HostBlock(block) = &config.elements[0] {
561            assert_eq!(block.directives.len(), 3);
562            assert_eq!(block.directives[1].key, "ForwardAgent");
563            assert_eq!(block.directives[1].value, "yes");
564            assert_eq!(block.directives[2].key, "LocalForward");
565        } else {
566            panic!("Expected HostBlock");
567        }
568    }
569
570    #[test]
571    fn test_include_directive_parsed() {
572        let content = "\
573Include config.d/*
574
575Host myserver
576  HostName 10.0.0.1
577";
578        let config = parse_str(content);
579        // parse_content uses no config_dir, so Include resolves to no files
580        assert!(
581            matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*")
582        );
583        // Blank line becomes a GlobalLine between Include and HostBlock
584        assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
585        assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
586    }
587
588    #[test]
589    fn test_include_round_trip() {
590        let content = "\
591Include ~/.ssh/config.d/*
592
593Host myserver
594  HostName 10.0.0.1
595";
596        let config = parse_str(content);
597        assert_eq!(config.serialize(), content);
598    }
599
600    #[test]
601    fn test_ssh_command() {
602        use crate::ssh_config::model::HostEntry;
603        use std::path::PathBuf;
604        let entry = HostEntry {
605            alias: "myserver".to_string(),
606            hostname: "10.0.0.1".to_string(),
607            ..Default::default()
608        };
609        let default_path = dirs::home_dir().unwrap().join(".ssh/config");
610        assert_eq!(entry.ssh_command(&default_path), "ssh -- 'myserver'");
611        let custom_path = PathBuf::from("/tmp/my_config");
612        assert_eq!(
613            entry.ssh_command(&custom_path),
614            "ssh -F '/tmp/my_config' -- 'myserver'"
615        );
616    }
617
618    #[test]
619    fn test_unicode_comment_no_panic() {
620        // "# abcdeé" has byte 8 mid-character (é starts at byte 7, is 2 bytes)
621        // This must not panic in parse_include_line
622        let content = "# abcde\u{00e9} test\n\nHost myserver\n  HostName 10.0.0.1\n";
623        let config = parse_str(content);
624        let entries = config.host_entries();
625        assert_eq!(entries.len(), 1);
626        assert_eq!(entries[0].alias, "myserver");
627    }
628
629    #[test]
630    fn test_unicode_multibyte_line_no_panic() {
631        // Three 3-byte CJK characters: byte 8 falls mid-character
632        let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n  HostName 10.0.0.1\n";
633        let config = parse_str(content);
634        let entries = config.host_entries();
635        assert_eq!(entries.len(), 1);
636    }
637
638    #[test]
639    fn test_host_with_tab_separator() {
640        let content = "Host\tmyserver\n  HostName 10.0.0.1\n";
641        let config = parse_str(content);
642        let entries = config.host_entries();
643        assert_eq!(entries.len(), 1);
644        assert_eq!(entries[0].alias, "myserver");
645    }
646
647    #[test]
648    fn test_include_with_tab_separator() {
649        let content = "Include\tconfig.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
650        let config = parse_str(content);
651        assert!(
652            matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
653        );
654    }
655
656    #[test]
657    fn test_include_with_equals_separator() {
658        let content = "Include=config.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
659        let config = parse_str(content);
660        assert!(
661            matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
662        );
663    }
664
665    #[test]
666    fn test_include_with_space_equals_separator() {
667        let content = "Include =config.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
668        let config = parse_str(content);
669        assert!(
670            matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
671        );
672    }
673
674    #[test]
675    fn test_include_with_space_equals_space_separator() {
676        let content = "Include = config.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
677        let config = parse_str(content);
678        assert!(
679            matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
680        );
681    }
682
683    #[test]
684    fn test_hostname_not_confused_with_host() {
685        // "HostName" should not be parsed as a Host line
686        let content = "Host myserver\n  HostName example.com\n";
687        let config = parse_str(content);
688        let entries = config.host_entries();
689        assert_eq!(entries.len(), 1);
690        assert_eq!(entries[0].hostname, "example.com");
691    }
692
693    #[test]
694    fn test_equals_in_value_not_treated_as_separator() {
695        let content = "Host myserver\n  IdentityFile ~/.ssh/id=prod\n";
696        let config = parse_str(content);
697        let entries = config.host_entries();
698        assert_eq!(entries.len(), 1);
699        assert_eq!(entries[0].identity_file, "~/.ssh/id=prod");
700    }
701
702    #[test]
703    fn test_equals_syntax_key_value() {
704        let content = "Host myserver\n  HostName=10.0.0.1\n  User = admin\n";
705        let config = parse_str(content);
706        let entries = config.host_entries();
707        assert_eq!(entries.len(), 1);
708        assert_eq!(entries[0].hostname, "10.0.0.1");
709        assert_eq!(entries[0].user, "admin");
710    }
711
712    #[test]
713    fn test_inline_comment_inside_quotes_preserved() {
714        let content = "Host myserver\n  ProxyCommand ssh -W \"%h #test\" gateway\n";
715        let config = parse_str(content);
716        let entries = config.host_entries();
717        assert_eq!(entries.len(), 1);
718        // The value should preserve the # inside quotes
719        if let ConfigElement::HostBlock(block) = &config.elements[0] {
720            let proxy_cmd = block
721                .directives
722                .iter()
723                .find(|d| d.key == "ProxyCommand")
724                .unwrap();
725            assert_eq!(proxy_cmd.value, "ssh -W \"%h #test\" gateway");
726        } else {
727            panic!("Expected HostBlock");
728        }
729    }
730
731    #[test]
732    fn test_inline_comment_outside_quotes_stripped() {
733        let content = "Host myserver\n  HostName 10.0.0.1 # production\n";
734        let config = parse_str(content);
735        let entries = config.host_entries();
736        assert_eq!(entries[0].hostname, "10.0.0.1");
737    }
738
739    #[test]
740    fn test_host_inline_comment_stripped() {
741        let content = "Host alpha # this is a comment\n  HostName 10.0.0.1\n";
742        let config = parse_str(content);
743        let entries = config.host_entries();
744        assert_eq!(entries.len(), 1);
745        assert_eq!(entries[0].alias, "alpha");
746        // Raw line is preserved for round-trip fidelity
747        if let ConfigElement::HostBlock(block) = &config.elements[0] {
748            assert_eq!(block.raw_host_line, "Host alpha # this is a comment");
749            assert_eq!(block.host_pattern, "alpha");
750        } else {
751            panic!("Expected HostBlock");
752        }
753    }
754
755    #[test]
756    fn test_match_block_is_global_line() {
757        let content = "\
758Host myserver
759  HostName 10.0.0.1
760
761Match host *.example.com
762  ForwardAgent yes
763";
764        let config = parse_str(content);
765        // Match line should flush the Host block and become a GlobalLine
766        let host_count = config
767            .elements
768            .iter()
769            .filter(|e| matches!(e, ConfigElement::HostBlock(_)))
770            .count();
771        assert_eq!(host_count, 1);
772        // Match line itself
773        assert!(
774            config.elements.iter().any(
775                |e| matches!(e, ConfigElement::GlobalLine(s) if s == "Match host *.example.com")
776            )
777        );
778        // Indented lines after Match (no current_block) become GlobalLines
779        assert!(
780            config
781                .elements
782                .iter()
783                .any(|e| matches!(e, ConfigElement::GlobalLine(s) if s.contains("ForwardAgent")))
784        );
785    }
786
787    #[test]
788    fn test_match_block_survives_host_deletion() {
789        let content = "\
790Host myserver
791  HostName 10.0.0.1
792
793Match host *.example.com
794  ForwardAgent yes
795
796Host other
797  HostName 10.0.0.2
798";
799        let mut config = parse_str(content);
800        config.delete_host("myserver");
801        let output = config.serialize();
802        assert!(output.contains("Match host *.example.com"));
803        assert!(output.contains("ForwardAgent yes"));
804        assert!(output.contains("Host other"));
805        assert!(!output.contains("Host myserver"));
806    }
807
808    #[test]
809    fn test_match_block_round_trip() {
810        let content = "\
811Host myserver
812  HostName 10.0.0.1
813
814Match host *.example.com
815  ForwardAgent yes
816";
817        let config = parse_str(content);
818        assert_eq!(config.serialize(), content);
819    }
820
821    #[test]
822    fn test_match_at_start_of_file() {
823        let content = "\
824Match all
825  ServerAliveInterval 60
826
827Host myserver
828  HostName 10.0.0.1
829";
830        let config = parse_str(content);
831        assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "Match all"));
832        assert!(
833            matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.contains("ServerAliveInterval"))
834        );
835        let entries = config.host_entries();
836        assert_eq!(entries.len(), 1);
837        assert_eq!(entries[0].alias, "myserver");
838    }
839
840    #[test]
841    fn test_host_equals_syntax() {
842        let config = parse_str("Host=foo\n  HostName 10.0.0.1\n");
843        let entries = config.host_entries();
844        assert_eq!(entries.len(), 1);
845        assert_eq!(entries[0].alias, "foo");
846    }
847
848    #[test]
849    fn test_host_space_equals_syntax() {
850        let config = parse_str("Host =foo\n  HostName 10.0.0.1\n");
851        let entries = config.host_entries();
852        assert_eq!(entries.len(), 1);
853        assert_eq!(entries[0].alias, "foo");
854    }
855
856    #[test]
857    fn test_host_equals_space_syntax() {
858        let config = parse_str("Host= foo\n  HostName 10.0.0.1\n");
859        let entries = config.host_entries();
860        assert_eq!(entries.len(), 1);
861        assert_eq!(entries[0].alias, "foo");
862    }
863
864    #[test]
865    fn test_host_space_equals_space_syntax() {
866        let config = parse_str("Host = foo\n  HostName 10.0.0.1\n");
867        let entries = config.host_entries();
868        assert_eq!(entries.len(), 1);
869        assert_eq!(entries[0].alias, "foo");
870    }
871
872    #[test]
873    fn test_host_equals_case_insensitive() {
874        let config = parse_str("HOST=foo\n  HostName 10.0.0.1\n");
875        let entries = config.host_entries();
876        assert_eq!(entries.len(), 1);
877        assert_eq!(entries[0].alias, "foo");
878    }
879
880    #[test]
881    fn test_hostname_equals_not_parsed_as_host() {
882        // "HostName=example.com" must NOT be parsed as a Host line
883        let config = parse_str("Host myserver\n  HostName=example.com\n");
884        let entries = config.host_entries();
885        assert_eq!(entries.len(), 1);
886        assert_eq!(entries[0].alias, "myserver");
887        assert_eq!(entries[0].hostname, "example.com");
888    }
889
890    #[test]
891    fn test_host_multi_pattern_with_inline_comment() {
892        // Multi-pattern host with inline comment: "prod staging # servers"
893        // The comment should be stripped, but "prod staging" is still multi-pattern
894        // and gets filtered by host_entries()
895        let content = "Host prod staging # servers\n  HostName 10.0.0.1\n";
896        let config = parse_str(content);
897        if let ConfigElement::HostBlock(block) = &config.elements[0] {
898            assert_eq!(block.host_pattern, "prod staging");
899        } else {
900            panic!("Expected HostBlock");
901        }
902        // Multi-pattern hosts are filtered out of host_entries
903        assert_eq!(config.host_entries().len(), 0);
904    }
905
906    #[test]
907    fn test_expand_env_vars_basic() {
908        // SAFETY: test-only, single-threaded context
909        unsafe { std::env::set_var("_PURPLE_TEST_VAR", "/custom/path") };
910        let result = SshConfigFile::expand_env_vars("${_PURPLE_TEST_VAR}/.ssh/config");
911        assert_eq!(result, "/custom/path/.ssh/config");
912        unsafe { std::env::remove_var("_PURPLE_TEST_VAR") };
913    }
914
915    #[test]
916    fn test_expand_env_vars_multiple() {
917        // SAFETY: test-only, single-threaded context
918        unsafe { std::env::set_var("_PURPLE_TEST_A", "hello") };
919        unsafe { std::env::set_var("_PURPLE_TEST_B", "world") };
920        let result = SshConfigFile::expand_env_vars("${_PURPLE_TEST_A}/${_PURPLE_TEST_B}");
921        assert_eq!(result, "hello/world");
922        unsafe { std::env::remove_var("_PURPLE_TEST_A") };
923        unsafe { std::env::remove_var("_PURPLE_TEST_B") };
924    }
925
926    #[test]
927    fn test_expand_env_vars_unknown_preserved() {
928        let result = SshConfigFile::expand_env_vars("${_PURPLE_NONEXISTENT_VAR}/path");
929        assert_eq!(result, "${_PURPLE_NONEXISTENT_VAR}/path");
930    }
931
932    #[test]
933    fn test_expand_env_vars_no_vars() {
934        let result = SshConfigFile::expand_env_vars("~/.ssh/config.d/*");
935        assert_eq!(result, "~/.ssh/config.d/*");
936    }
937
938    #[test]
939    fn test_expand_env_vars_unclosed_brace() {
940        let result = SshConfigFile::expand_env_vars("${UNCLOSED/path");
941        assert_eq!(result, "${UNCLOSED/path");
942    }
943
944    #[test]
945    fn test_expand_env_vars_dollar_without_brace() {
946        let result = SshConfigFile::expand_env_vars("$HOME/.ssh/config");
947        // Only ${VAR} syntax should be expanded, not bare $VAR
948        assert_eq!(result, "$HOME/.ssh/config");
949    }
950
951    #[test]
952    fn test_max_include_depth_matches_openssh() {
953        assert_eq!(MAX_INCLUDE_DEPTH, 16);
954    }
955
956    #[test]
957    fn test_split_include_patterns_single_unquoted() {
958        let result = SshConfigFile::split_include_patterns("config.d/*");
959        assert_eq!(result, vec!["config.d/*"]);
960    }
961
962    #[test]
963    fn test_split_include_patterns_quoted_with_spaces() {
964        let result = SshConfigFile::split_include_patterns("\"/path/with spaces/config\"");
965        assert_eq!(result, vec!["/path/with spaces/config"]);
966    }
967
968    #[test]
969    fn test_split_include_patterns_mixed() {
970        let result =
971            SshConfigFile::split_include_patterns("\"/path/with spaces/*\" ~/.ssh/config.d/*");
972        assert_eq!(result, vec!["/path/with spaces/*", "~/.ssh/config.d/*"]);
973    }
974
975    #[test]
976    fn test_split_include_patterns_quoted_no_spaces() {
977        let result = SshConfigFile::split_include_patterns("\"config.d/*\"");
978        assert_eq!(result, vec!["config.d/*"]);
979    }
980
981    #[test]
982    fn test_split_include_patterns_multiple_unquoted() {
983        let result = SshConfigFile::split_include_patterns("~/.ssh/conf.d/* /etc/ssh/config.d/*");
984        assert_eq!(result, vec!["~/.ssh/conf.d/*", "/etc/ssh/config.d/*"]);
985    }
986}