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