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