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 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 #[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 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 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 if let Some(pattern) = Self::parse_host_line(trimmed) {
76 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 let Some(ref mut block) = current_block {
90 if trimmed.is_empty() || trimmed.starts_with('#') {
91 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 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 elements.push(ConfigElement::GlobalLine(line.to_string()));
117 }
118 }
119
120 if let Some(block) = current_block {
122 elements.push(ConfigElement::HostBlock(block));
123 }
124
125 elements
126 }
127
128 fn parse_include_line(trimmed: &str) -> Option<&str> {
131 let bytes = trimmed.as_bytes();
132 if bytes.len() > 8
134 && bytes[..7].eq_ignore_ascii_case(b"include")
135 && bytes[7].is_ascii_whitespace()
136 {
137 let pattern = trimmed[8..].trim();
139 if !pattern.is_empty() {
140 return Some(pattern);
141 }
142 }
143 None
144 }
145
146 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 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 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 fn parse_host_line(trimmed: &str) -> Option<String> {
200 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 let pattern = parts.next()?.trim().to_string();
208 if !pattern.is_empty() {
209 return Some(pattern);
210 }
211 None
212 }
213
214 fn parse_directive(trimmed: &str) -> Option<(String, String)> {
216 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 assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment"));
315 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 assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*"));
369 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 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 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 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}