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 config_dir = path.parent().map(|p| p.to_path_buf());
27        let elements = Self::parse_content_with_includes(&content, config_dir.as_deref(), depth);
28
29        Ok(SshConfigFile {
30            elements,
31            path: path.to_path_buf(),
32        })
33    }
34
35    /// Parse SSH config content from a string (without Include resolution).
36    /// Used by tests to create SshConfigFile from inline strings.
37    #[allow(dead_code)]
38    pub fn parse_content(content: &str) -> Vec<ConfigElement> {
39        Self::parse_content_with_includes(content, None, MAX_INCLUDE_DEPTH)
40    }
41
42    /// Parse SSH config content, optionally resolving Include directives.
43    fn parse_content_with_includes(
44        content: &str,
45        config_dir: Option<&Path>,
46        depth: usize,
47    ) -> Vec<ConfigElement> {
48        let mut elements = Vec::new();
49        let mut current_block: Option<HostBlock> = None;
50
51        for line in content.lines() {
52            let trimmed = line.trim();
53
54            // Check for Include directive (at any level — flush current block if needed)
55            if let Some(pattern) = Self::parse_include_line(trimmed) {
56                if let Some(block) = current_block.take() {
57                    elements.push(ConfigElement::HostBlock(block));
58                }
59                let resolved = if depth < MAX_INCLUDE_DEPTH {
60                    Self::resolve_include(pattern, config_dir, depth)
61                } else {
62                    Vec::new()
63                };
64                elements.push(ConfigElement::Include(IncludeDirective {
65                    raw_line: line.to_string(),
66                    pattern: pattern.to_string(),
67                    resolved_files: resolved,
68                }));
69                continue;
70            }
71
72            // Check if this line starts a new Host block
73            if let Some(pattern) = Self::parse_host_line(trimmed) {
74                // Flush the previous block if any
75                if let Some(block) = current_block.take() {
76                    elements.push(ConfigElement::HostBlock(block));
77                }
78                current_block = Some(HostBlock {
79                    host_pattern: pattern,
80                    raw_host_line: line.to_string(),
81                    directives: Vec::new(),
82                });
83                continue;
84            }
85
86            // If we're inside a Host block, add this line as a directive
87            if let Some(ref mut block) = current_block {
88                if trimmed.is_empty() || trimmed.starts_with('#') {
89                    // Comment or blank line inside a host block
90                    block.directives.push(Directive {
91                        key: String::new(),
92                        value: String::new(),
93                        raw_line: line.to_string(),
94                        is_non_directive: true,
95                    });
96                } else if let Some((key, value)) = Self::parse_directive(trimmed) {
97                    block.directives.push(Directive {
98                        key,
99                        value,
100                        raw_line: line.to_string(),
101                        is_non_directive: false,
102                    });
103                } else {
104                    // Unrecognized line format — preserve verbatim
105                    block.directives.push(Directive {
106                        key: String::new(),
107                        value: String::new(),
108                        raw_line: line.to_string(),
109                        is_non_directive: true,
110                    });
111                }
112            } else {
113                // Global line (before any Host block)
114                elements.push(ConfigElement::GlobalLine(line.to_string()));
115            }
116        }
117
118        // Flush the last block
119        if let Some(block) = current_block {
120            elements.push(ConfigElement::HostBlock(block));
121        }
122
123        elements
124    }
125
126    /// Parse an Include directive line. Returns the pattern if it matches.
127    fn parse_include_line(trimmed: &str) -> Option<&str> {
128        // Case-insensitive check: "Include" is 7 chars + space
129        if trimmed.len() > 8 && trimmed[..8].eq_ignore_ascii_case("include ") {
130            let pattern = trimmed[8..].trim();
131            if !pattern.is_empty() {
132                return Some(pattern);
133            }
134        }
135        None
136    }
137
138    /// Resolve an Include pattern to a list of included files.
139    fn resolve_include(
140        pattern: &str,
141        config_dir: Option<&Path>,
142        depth: usize,
143    ) -> Vec<IncludedFile> {
144        let expanded = Self::expand_tilde(pattern);
145
146        // If relative path, resolve against config dir
147        let glob_pattern = if expanded.starts_with('/') {
148            expanded
149        } else if let Some(dir) = config_dir {
150            dir.join(&expanded).to_string_lossy().to_string()
151        } else {
152            return Vec::new();
153        };
154
155        let mut files = Vec::new();
156        if let Ok(paths) = glob::glob(&glob_pattern) {
157            let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
158            matched.sort();
159            for path in matched {
160                if path.is_file() {
161                    if let Ok(content) = std::fs::read_to_string(&path) {
162                        let elements = Self::parse_content_with_includes(
163                            &content,
164                            path.parent(),
165                            depth + 1,
166                        );
167                        files.push(IncludedFile {
168                            path: path.clone(),
169                            elements,
170                        });
171                    }
172                }
173            }
174        }
175        files
176    }
177
178    /// Expand ~ to the home directory.
179    fn expand_tilde(pattern: &str) -> String {
180        if let Some(rest) = pattern.strip_prefix("~/") {
181            if let Some(home) = dirs::home_dir() {
182                return format!("{}/{}", home.display(), rest);
183            }
184        }
185        pattern.to_string()
186    }
187
188    /// Check if a line is a "Host <pattern>" line.
189    /// Returns the pattern if it is.
190    fn parse_host_line(trimmed: &str) -> Option<String> {
191        let lower = trimmed.to_lowercase();
192        if lower.starts_with("host ") && !lower.starts_with("hostname") {
193            let pattern = trimmed[5..].trim().to_string();
194            if !pattern.is_empty() {
195                return Some(pattern);
196            }
197        }
198        None
199    }
200
201    /// Parse a "Key Value" directive line.
202    fn parse_directive(trimmed: &str) -> Option<(String, String)> {
203        // SSH config format: Key Value (space-separated) or Key=Value
204        let (key, value) = if let Some(eq_pos) = trimmed.find('=') {
205            let key = trimmed[..eq_pos].trim();
206            let value = trimmed[eq_pos + 1..].trim();
207            (key, value)
208        } else {
209            let mut parts = trimmed.splitn(2, char::is_whitespace);
210            let key = parts.next()?;
211            let value = parts.next().unwrap_or("").trim();
212            (key, value)
213        };
214
215        if key.is_empty() {
216            return None;
217        }
218
219        Some((key.to_string(), value.to_string()))
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use std::path::PathBuf;
227
228    fn parse_str(content: &str) -> SshConfigFile {
229        SshConfigFile {
230            elements: SshConfigFile::parse_content(content),
231            path: PathBuf::from("/tmp/test_config"),
232        }
233    }
234
235    #[test]
236    fn test_empty_config() {
237        let config = parse_str("");
238        assert!(config.host_entries().is_empty());
239    }
240
241    #[test]
242    fn test_basic_host() {
243        let config = parse_str(
244            "Host myserver\n  HostName 192.168.1.10\n  User admin\n  Port 2222\n",
245        );
246        let entries = config.host_entries();
247        assert_eq!(entries.len(), 1);
248        assert_eq!(entries[0].alias, "myserver");
249        assert_eq!(entries[0].hostname, "192.168.1.10");
250        assert_eq!(entries[0].user, "admin");
251        assert_eq!(entries[0].port, 2222);
252    }
253
254    #[test]
255    fn test_multiple_hosts() {
256        let content = "\
257Host alpha
258  HostName alpha.example.com
259  User deploy
260
261Host beta
262  HostName beta.example.com
263  User root
264  Port 22022
265";
266        let config = parse_str(content);
267        let entries = config.host_entries();
268        assert_eq!(entries.len(), 2);
269        assert_eq!(entries[0].alias, "alpha");
270        assert_eq!(entries[1].alias, "beta");
271        assert_eq!(entries[1].port, 22022);
272    }
273
274    #[test]
275    fn test_wildcard_host_filtered() {
276        let content = "\
277Host *
278  ServerAliveInterval 60
279
280Host myserver
281  HostName 10.0.0.1
282";
283        let config = parse_str(content);
284        let entries = config.host_entries();
285        assert_eq!(entries.len(), 1);
286        assert_eq!(entries[0].alias, "myserver");
287    }
288
289    #[test]
290    fn test_comments_preserved() {
291        let content = "\
292# Global comment
293Host myserver
294  # This is a comment
295  HostName 10.0.0.1
296  User admin
297";
298        let config = parse_str(content);
299        // Check that the global comment is preserved
300        assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment"));
301        // Check that the host block has the comment directive
302        if let ConfigElement::HostBlock(block) = &config.elements[1] {
303            assert!(block.directives[0].is_non_directive);
304            assert_eq!(block.directives[0].raw_line, "  # This is a comment");
305        } else {
306            panic!("Expected HostBlock");
307        }
308    }
309
310    #[test]
311    fn test_identity_file_and_proxy_jump() {
312        let content = "\
313Host bastion
314  HostName bastion.example.com
315  User admin
316  IdentityFile ~/.ssh/id_ed25519
317  ProxyJump gateway
318";
319        let config = parse_str(content);
320        let entries = config.host_entries();
321        assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
322        assert_eq!(entries[0].proxy_jump, "gateway");
323    }
324
325    #[test]
326    fn test_unknown_directives_preserved() {
327        let content = "\
328Host myserver
329  HostName 10.0.0.1
330  ForwardAgent yes
331  LocalForward 8080 localhost:80
332";
333        let config = parse_str(content);
334        if let ConfigElement::HostBlock(block) = &config.elements[0] {
335            assert_eq!(block.directives.len(), 3);
336            assert_eq!(block.directives[1].key, "ForwardAgent");
337            assert_eq!(block.directives[1].value, "yes");
338            assert_eq!(block.directives[2].key, "LocalForward");
339        } else {
340            panic!("Expected HostBlock");
341        }
342    }
343
344    #[test]
345    fn test_include_directive_parsed() {
346        let content = "\
347Include config.d/*
348
349Host myserver
350  HostName 10.0.0.1
351";
352        let config = parse_str(content);
353        // parse_content uses no config_dir, so Include resolves to no files
354        assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*"));
355        // Blank line becomes a GlobalLine between Include and HostBlock
356        assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
357        assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
358    }
359
360    #[test]
361    fn test_include_round_trip() {
362        let content = "\
363Include ~/.ssh/config.d/*
364
365Host myserver
366  HostName 10.0.0.1
367";
368        let config = parse_str(content);
369        assert_eq!(config.serialize(), content);
370    }
371
372    #[test]
373    fn test_ssh_command() {
374        use crate::ssh_config::model::HostEntry;
375        let entry = HostEntry {
376            alias: "myserver".to_string(),
377            hostname: "10.0.0.1".to_string(),
378            ..Default::default()
379        };
380        assert_eq!(entry.ssh_command(), "ssh myserver");
381    }
382}