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