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