Skip to main content

purple_ssh/ssh_config/
parser.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5use super::model::{
6    ConfigElement, Directive, HostBlock, IncludeDirective, IncludedFile, SshConfigFile,
7};
8
9const MAX_INCLUDE_DEPTH: usize = 5;
10
11impl SshConfigFile {
12    /// Parse an SSH config file from the given path.
13    /// Preserves all formatting, comments, and unknown directives for round-trip fidelity.
14    pub fn parse(path: &Path) -> Result<Self> {
15        Self::parse_with_depth(path, 0)
16    }
17
18    fn parse_with_depth(path: &Path, depth: usize) -> Result<Self> {
19        let content = if path.exists() {
20            std::fs::read_to_string(path)
21                .with_context(|| format!("Failed to read SSH config at {}", path.display()))?
22        } else {
23            String::new()
24        };
25
26        let crlf = content.contains("\r\n");
27        let config_dir = path.parent().map(|p| p.to_path_buf());
28        let elements = Self::parse_content_with_includes(&content, config_dir.as_deref(), depth);
29
30        Ok(SshConfigFile {
31            elements,
32            path: path.to_path_buf(),
33            crlf,
34        })
35    }
36
37    /// Parse SSH config content from a string (without Include resolution).
38    /// Used by tests to create SshConfigFile from inline strings.
39    #[allow(dead_code)]
40    pub fn parse_content(content: &str) -> Vec<ConfigElement> {
41        Self::parse_content_with_includes(content, None, MAX_INCLUDE_DEPTH)
42    }
43
44    /// Parse SSH config content, optionally resolving Include directives.
45    fn parse_content_with_includes(
46        content: &str,
47        config_dir: Option<&Path>,
48        depth: usize,
49    ) -> Vec<ConfigElement> {
50        let mut elements = Vec::new();
51        let mut current_block: Option<HostBlock> = None;
52
53        for line in content.lines() {
54            let trimmed = line.trim();
55
56            // Check for Include directive (at any level — flush current block if needed)
57            if let Some(pattern) = Self::parse_include_line(trimmed) {
58                if let Some(block) = current_block.take() {
59                    elements.push(ConfigElement::HostBlock(block));
60                }
61                let resolved = if depth < MAX_INCLUDE_DEPTH {
62                    Self::resolve_include(pattern, config_dir, depth)
63                } else {
64                    Vec::new()
65                };
66                elements.push(ConfigElement::Include(IncludeDirective {
67                    raw_line: line.to_string(),
68                    pattern: pattern.to_string(),
69                    resolved_files: resolved,
70                }));
71                continue;
72            }
73
74            // Check if this line starts a new Host block
75            if let Some(pattern) = Self::parse_host_line(trimmed) {
76                // Flush the previous block if any
77                if let Some(block) = current_block.take() {
78                    elements.push(ConfigElement::HostBlock(block));
79                }
80                current_block = Some(HostBlock {
81                    host_pattern: pattern,
82                    raw_host_line: line.to_string(),
83                    directives: Vec::new(),
84                });
85                continue;
86            }
87
88            // If we're inside a Host block, add this line as a directive
89            if let Some(ref mut block) = current_block {
90                if trimmed.is_empty() || trimmed.starts_with('#') {
91                    // Comment or blank line inside a host block
92                    block.directives.push(Directive {
93                        key: String::new(),
94                        value: String::new(),
95                        raw_line: line.to_string(),
96                        is_non_directive: true,
97                    });
98                } else if let Some((key, value)) = Self::parse_directive(trimmed) {
99                    block.directives.push(Directive {
100                        key,
101                        value,
102                        raw_line: line.to_string(),
103                        is_non_directive: false,
104                    });
105                } else {
106                    // Unrecognized line format — preserve verbatim
107                    block.directives.push(Directive {
108                        key: String::new(),
109                        value: String::new(),
110                        raw_line: line.to_string(),
111                        is_non_directive: true,
112                    });
113                }
114            } else {
115                // Global line (before any Host block)
116                elements.push(ConfigElement::GlobalLine(line.to_string()));
117            }
118        }
119
120        // Flush the last block
121        if let Some(block) = current_block {
122            elements.push(ConfigElement::HostBlock(block));
123        }
124
125        elements
126    }
127
128    /// Parse an Include directive line. Returns the pattern if it matches.
129    /// Handles both space and tab between keyword and value (SSH allows either).
130    fn parse_include_line(trimmed: &str) -> Option<&str> {
131        let bytes = trimmed.as_bytes();
132        // "include" is 7 ASCII bytes; byte 7 must be ASCII whitespace (space or tab)
133        if bytes.len() > 8
134            && bytes[..7].eq_ignore_ascii_case(b"include")
135            && bytes[7].is_ascii_whitespace()
136        {
137            // byte 8 is safe to slice at: bytes 0-7 are ASCII, so byte 8 is a char boundary
138            let pattern = trimmed[8..].trim();
139            if !pattern.is_empty() {
140                return Some(pattern);
141            }
142        }
143        None
144    }
145
146    /// Resolve an Include pattern to a list of included files.
147    fn resolve_include(
148        pattern: &str,
149        config_dir: Option<&Path>,
150        depth: usize,
151    ) -> Vec<IncludedFile> {
152        let expanded = Self::expand_tilde(pattern);
153
154        // If relative path, resolve against config dir
155        let glob_pattern = if expanded.starts_with('/') {
156            expanded
157        } else if let Some(dir) = config_dir {
158            dir.join(&expanded).to_string_lossy().to_string()
159        } else {
160            return Vec::new();
161        };
162
163        let mut files = Vec::new();
164        if let Ok(paths) = glob::glob(&glob_pattern) {
165            let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
166            matched.sort();
167            for path in matched {
168                if path.is_file() {
169                    if let Ok(content) = std::fs::read_to_string(&path) {
170                        let elements = Self::parse_content_with_includes(
171                            &content,
172                            path.parent(),
173                            depth + 1,
174                        );
175                        files.push(IncludedFile {
176                            path: path.clone(),
177                            elements,
178                        });
179                    }
180                }
181            }
182        }
183        files
184    }
185
186    /// Expand ~ to the home directory.
187    fn expand_tilde(pattern: &str) -> String {
188        if let Some(rest) = pattern.strip_prefix("~/") {
189            if let Some(home) = dirs::home_dir() {
190                return format!("{}/{}", home.display(), rest);
191            }
192        }
193        pattern.to_string()
194    }
195
196    /// Check if a line is a "Host <pattern>" line.
197    /// Returns the pattern if it is.
198    /// Handles both space and tab between keyword and value (SSH allows either).
199    fn parse_host_line(trimmed: &str) -> Option<String> {
200        // Split on first space or tab to isolate the keyword
201        let mut parts = trimmed.splitn(2, [' ', '\t']);
202        let keyword = parts.next()?;
203        if !keyword.eq_ignore_ascii_case("host") {
204            return None;
205        }
206        // "hostname" splits as keyword="hostname" which fails the check above
207        let pattern = parts.next()?.trim().to_string();
208        if !pattern.is_empty() {
209            return Some(pattern);
210        }
211        None
212    }
213
214    /// Parse a "Key Value" directive line.
215    fn parse_directive(trimmed: &str) -> Option<(String, String)> {
216        // SSH config format: Key Value (space-separated) or Key=Value
217        let (key, value) = if let Some(eq_pos) = trimmed.find('=') {
218            let key = trimmed[..eq_pos].trim();
219            let value = trimmed[eq_pos + 1..].trim();
220            (key, value)
221        } else {
222            let mut parts = trimmed.splitn(2, char::is_whitespace);
223            let key = parts.next()?;
224            let value = parts.next().unwrap_or("").trim();
225            (key, value)
226        };
227
228        if key.is_empty() {
229            return None;
230        }
231
232        Some((key.to_string(), value.to_string()))
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use std::path::PathBuf;
240
241    fn parse_str(content: &str) -> SshConfigFile {
242        SshConfigFile {
243            elements: SshConfigFile::parse_content(content),
244            path: PathBuf::from("/tmp/test_config"),
245            crlf: content.contains("\r\n"),
246        }
247    }
248
249    #[test]
250    fn test_empty_config() {
251        let config = parse_str("");
252        assert!(config.host_entries().is_empty());
253    }
254
255    #[test]
256    fn test_basic_host() {
257        let config = parse_str(
258            "Host myserver\n  HostName 192.168.1.10\n  User admin\n  Port 2222\n",
259        );
260        let entries = config.host_entries();
261        assert_eq!(entries.len(), 1);
262        assert_eq!(entries[0].alias, "myserver");
263        assert_eq!(entries[0].hostname, "192.168.1.10");
264        assert_eq!(entries[0].user, "admin");
265        assert_eq!(entries[0].port, 2222);
266    }
267
268    #[test]
269    fn test_multiple_hosts() {
270        let content = "\
271Host alpha
272  HostName alpha.example.com
273  User deploy
274
275Host beta
276  HostName beta.example.com
277  User root
278  Port 22022
279";
280        let config = parse_str(content);
281        let entries = config.host_entries();
282        assert_eq!(entries.len(), 2);
283        assert_eq!(entries[0].alias, "alpha");
284        assert_eq!(entries[1].alias, "beta");
285        assert_eq!(entries[1].port, 22022);
286    }
287
288    #[test]
289    fn test_wildcard_host_filtered() {
290        let content = "\
291Host *
292  ServerAliveInterval 60
293
294Host myserver
295  HostName 10.0.0.1
296";
297        let config = parse_str(content);
298        let entries = config.host_entries();
299        assert_eq!(entries.len(), 1);
300        assert_eq!(entries[0].alias, "myserver");
301    }
302
303    #[test]
304    fn test_comments_preserved() {
305        let content = "\
306# Global comment
307Host myserver
308  # This is a comment
309  HostName 10.0.0.1
310  User admin
311";
312        let config = parse_str(content);
313        // Check that the global comment is preserved
314        assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment"));
315        // Check that the host block has the comment directive
316        if let ConfigElement::HostBlock(block) = &config.elements[1] {
317            assert!(block.directives[0].is_non_directive);
318            assert_eq!(block.directives[0].raw_line, "  # This is a comment");
319        } else {
320            panic!("Expected HostBlock");
321        }
322    }
323
324    #[test]
325    fn test_identity_file_and_proxy_jump() {
326        let content = "\
327Host bastion
328  HostName bastion.example.com
329  User admin
330  IdentityFile ~/.ssh/id_ed25519
331  ProxyJump gateway
332";
333        let config = parse_str(content);
334        let entries = config.host_entries();
335        assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
336        assert_eq!(entries[0].proxy_jump, "gateway");
337    }
338
339    #[test]
340    fn test_unknown_directives_preserved() {
341        let content = "\
342Host myserver
343  HostName 10.0.0.1
344  ForwardAgent yes
345  LocalForward 8080 localhost:80
346";
347        let config = parse_str(content);
348        if let ConfigElement::HostBlock(block) = &config.elements[0] {
349            assert_eq!(block.directives.len(), 3);
350            assert_eq!(block.directives[1].key, "ForwardAgent");
351            assert_eq!(block.directives[1].value, "yes");
352            assert_eq!(block.directives[2].key, "LocalForward");
353        } else {
354            panic!("Expected HostBlock");
355        }
356    }
357
358    #[test]
359    fn test_include_directive_parsed() {
360        let content = "\
361Include config.d/*
362
363Host myserver
364  HostName 10.0.0.1
365";
366        let config = parse_str(content);
367        // parse_content uses no config_dir, so Include resolves to no files
368        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*"));
369        // Blank line becomes a GlobalLine between Include and HostBlock
370        assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
371        assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
372    }
373
374    #[test]
375    fn test_include_round_trip() {
376        let content = "\
377Include ~/.ssh/config.d/*
378
379Host myserver
380  HostName 10.0.0.1
381";
382        let config = parse_str(content);
383        assert_eq!(config.serialize(), content);
384    }
385
386    #[test]
387    fn test_ssh_command() {
388        use crate::ssh_config::model::HostEntry;
389        let entry = HostEntry {
390            alias: "myserver".to_string(),
391            hostname: "10.0.0.1".to_string(),
392            ..Default::default()
393        };
394        assert_eq!(entry.ssh_command(), "ssh myserver");
395    }
396
397    #[test]
398    fn test_unicode_comment_no_panic() {
399        // "# abcdeé" has byte 8 mid-character (é starts at byte 7, is 2 bytes)
400        // This must not panic in parse_include_line
401        let content = "# abcde\u{00e9} test\n\nHost myserver\n  HostName 10.0.0.1\n";
402        let config = parse_str(content);
403        let entries = config.host_entries();
404        assert_eq!(entries.len(), 1);
405        assert_eq!(entries[0].alias, "myserver");
406    }
407
408    #[test]
409    fn test_unicode_multibyte_line_no_panic() {
410        // Three 3-byte CJK characters: byte 8 falls mid-character
411        let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n  HostName 10.0.0.1\n";
412        let config = parse_str(content);
413        let entries = config.host_entries();
414        assert_eq!(entries.len(), 1);
415    }
416
417    #[test]
418    fn test_host_with_tab_separator() {
419        let content = "Host\tmyserver\n  HostName 10.0.0.1\n";
420        let config = parse_str(content);
421        let entries = config.host_entries();
422        assert_eq!(entries.len(), 1);
423        assert_eq!(entries[0].alias, "myserver");
424    }
425
426    #[test]
427    fn test_include_with_tab_separator() {
428        let content = "Include\tconfig.d/*\n\nHost myserver\n  HostName 10.0.0.1\n";
429        let config = parse_str(content);
430        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
431    }
432
433    #[test]
434    fn test_hostname_not_confused_with_host() {
435        // "HostName" should not be parsed as a Host line
436        let content = "Host myserver\n  HostName example.com\n";
437        let config = parse_str(content);
438        let entries = config.host_entries();
439        assert_eq!(entries.len(), 1);
440        assert_eq!(entries[0].hostname, "example.com");
441    }
442}