Skip to main content

purple_ssh/ssh_config/
parser.rs

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