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 let is_indented = line.starts_with(' ') || line.starts_with('\t');
60 if !(current_block.is_some() && is_indented) {
61 if let Some(pattern) = Self::parse_include_line(trimmed) {
62 if let Some(block) = current_block.take() {
63 elements.push(ConfigElement::HostBlock(block));
64 }
65 let resolved = if depth < MAX_INCLUDE_DEPTH {
66 Self::resolve_include(pattern, config_dir, depth)
67 } else {
68 Vec::new()
69 };
70 elements.push(ConfigElement::Include(IncludeDirective {
71 raw_line: line.to_string(),
72 pattern: pattern.to_string(),
73 resolved_files: resolved,
74 }));
75 continue;
76 }
77 }
78
79 if let Some(pattern) = Self::parse_host_line(trimmed) {
81 if let Some(block) = current_block.take() {
83 elements.push(ConfigElement::HostBlock(block));
84 }
85 current_block = Some(HostBlock {
86 host_pattern: pattern,
87 raw_host_line: line.to_string(),
88 directives: Vec::new(),
89 });
90 continue;
91 }
92
93 if let Some(ref mut block) = current_block {
95 if trimmed.is_empty() || trimmed.starts_with('#') {
96 block.directives.push(Directive {
98 key: String::new(),
99 value: String::new(),
100 raw_line: line.to_string(),
101 is_non_directive: true,
102 });
103 } else if let Some((key, value)) = Self::parse_directive(trimmed) {
104 block.directives.push(Directive {
105 key,
106 value,
107 raw_line: line.to_string(),
108 is_non_directive: false,
109 });
110 } else {
111 block.directives.push(Directive {
113 key: String::new(),
114 value: String::new(),
115 raw_line: line.to_string(),
116 is_non_directive: true,
117 });
118 }
119 } else {
120 elements.push(ConfigElement::GlobalLine(line.to_string()));
122 }
123 }
124
125 if let Some(block) = current_block {
127 elements.push(ConfigElement::HostBlock(block));
128 }
129
130 elements
131 }
132
133 fn parse_include_line(trimmed: &str) -> Option<&str> {
136 let bytes = trimmed.as_bytes();
137 if bytes.len() > 8
139 && bytes[..7].eq_ignore_ascii_case(b"include")
140 && bytes[7].is_ascii_whitespace()
141 {
142 let pattern = trimmed[8..].trim();
144 if !pattern.is_empty() {
145 return Some(pattern);
146 }
147 }
148 None
149 }
150
151 fn resolve_include(
153 pattern: &str,
154 config_dir: Option<&Path>,
155 depth: usize,
156 ) -> Vec<IncludedFile> {
157 let expanded = Self::expand_tilde(pattern);
158
159 let glob_pattern = if expanded.starts_with('/') {
161 expanded
162 } else if let Some(dir) = config_dir {
163 dir.join(&expanded).to_string_lossy().to_string()
164 } else {
165 return Vec::new();
166 };
167
168 let mut files = Vec::new();
169 if let Ok(paths) = glob::glob(&glob_pattern) {
170 let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
171 matched.sort();
172 for path in matched {
173 if path.is_file() {
174 if let Ok(content) = std::fs::read_to_string(&path) {
175 let elements = Self::parse_content_with_includes(
176 &content,
177 path.parent(),
178 depth + 1,
179 );
180 files.push(IncludedFile {
181 path: path.clone(),
182 elements,
183 });
184 }
185 }
186 }
187 }
188 files
189 }
190
191 pub(crate) fn expand_tilde(pattern: &str) -> String {
193 if let Some(rest) = pattern.strip_prefix("~/") {
194 if let Some(home) = dirs::home_dir() {
195 return format!("{}/{}", home.display(), rest);
196 }
197 }
198 pattern.to_string()
199 }
200
201 fn parse_host_line(trimmed: &str) -> Option<String> {
205 let mut parts = trimmed.splitn(2, [' ', '\t']);
207 let keyword = parts.next()?;
208 if !keyword.eq_ignore_ascii_case("host") {
209 return None;
210 }
211 let pattern = parts.next()?.trim().to_string();
213 if !pattern.is_empty() {
214 return Some(pattern);
215 }
216 None
217 }
218
219 fn parse_directive(trimmed: &str) -> Option<(String, String)> {
224 let key_end = trimmed.find(|c: char| c.is_whitespace() || c == '=')?;
226 let key = &trimmed[..key_end];
227 if key.is_empty() {
228 return None;
229 }
230
231 let rest = trimmed[key_end..].trim_start();
233 let rest = rest.strip_prefix('=').unwrap_or(rest);
234 let value = rest.trim_start();
235
236 let value = strip_inline_comment(value);
239
240 Some((key.to_string(), value.to_string()))
241 }
242}
243
244fn strip_inline_comment(value: &str) -> &str {
247 let bytes = value.as_bytes();
248 let mut in_quote = false;
249 for i in 0..bytes.len() {
250 if bytes[i] == b'"' {
251 in_quote = !in_quote;
252 } else if !in_quote
253 && bytes[i] == b'#'
254 && i > 0
255 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
256 {
257 return value[..i].trim_end();
258 }
259 }
260 value
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use std::path::PathBuf;
267
268 fn parse_str(content: &str) -> SshConfigFile {
269 SshConfigFile {
270 elements: SshConfigFile::parse_content(content),
271 path: PathBuf::from("/tmp/test_config"),
272 crlf: content.contains("\r\n"),
273 }
274 }
275
276 #[test]
277 fn test_empty_config() {
278 let config = parse_str("");
279 assert!(config.host_entries().is_empty());
280 }
281
282 #[test]
283 fn test_basic_host() {
284 let config = parse_str(
285 "Host myserver\n HostName 192.168.1.10\n User admin\n Port 2222\n",
286 );
287 let entries = config.host_entries();
288 assert_eq!(entries.len(), 1);
289 assert_eq!(entries[0].alias, "myserver");
290 assert_eq!(entries[0].hostname, "192.168.1.10");
291 assert_eq!(entries[0].user, "admin");
292 assert_eq!(entries[0].port, 2222);
293 }
294
295 #[test]
296 fn test_multiple_hosts() {
297 let content = "\
298Host alpha
299 HostName alpha.example.com
300 User deploy
301
302Host beta
303 HostName beta.example.com
304 User root
305 Port 22022
306";
307 let config = parse_str(content);
308 let entries = config.host_entries();
309 assert_eq!(entries.len(), 2);
310 assert_eq!(entries[0].alias, "alpha");
311 assert_eq!(entries[1].alias, "beta");
312 assert_eq!(entries[1].port, 22022);
313 }
314
315 #[test]
316 fn test_wildcard_host_filtered() {
317 let content = "\
318Host *
319 ServerAliveInterval 60
320
321Host myserver
322 HostName 10.0.0.1
323";
324 let config = parse_str(content);
325 let entries = config.host_entries();
326 assert_eq!(entries.len(), 1);
327 assert_eq!(entries[0].alias, "myserver");
328 }
329
330 #[test]
331 fn test_comments_preserved() {
332 let content = "\
333# Global comment
334Host myserver
335 # This is a comment
336 HostName 10.0.0.1
337 User admin
338";
339 let config = parse_str(content);
340 assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment"));
342 if let ConfigElement::HostBlock(block) = &config.elements[1] {
344 assert!(block.directives[0].is_non_directive);
345 assert_eq!(block.directives[0].raw_line, " # This is a comment");
346 } else {
347 panic!("Expected HostBlock");
348 }
349 }
350
351 #[test]
352 fn test_identity_file_and_proxy_jump() {
353 let content = "\
354Host bastion
355 HostName bastion.example.com
356 User admin
357 IdentityFile ~/.ssh/id_ed25519
358 ProxyJump gateway
359";
360 let config = parse_str(content);
361 let entries = config.host_entries();
362 assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
363 assert_eq!(entries[0].proxy_jump, "gateway");
364 }
365
366 #[test]
367 fn test_unknown_directives_preserved() {
368 let content = "\
369Host myserver
370 HostName 10.0.0.1
371 ForwardAgent yes
372 LocalForward 8080 localhost:80
373";
374 let config = parse_str(content);
375 if let ConfigElement::HostBlock(block) = &config.elements[0] {
376 assert_eq!(block.directives.len(), 3);
377 assert_eq!(block.directives[1].key, "ForwardAgent");
378 assert_eq!(block.directives[1].value, "yes");
379 assert_eq!(block.directives[2].key, "LocalForward");
380 } else {
381 panic!("Expected HostBlock");
382 }
383 }
384
385 #[test]
386 fn test_include_directive_parsed() {
387 let content = "\
388Include config.d/*
389
390Host myserver
391 HostName 10.0.0.1
392";
393 let config = parse_str(content);
394 assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*"));
396 assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
398 assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
399 }
400
401 #[test]
402 fn test_include_round_trip() {
403 let content = "\
404Include ~/.ssh/config.d/*
405
406Host myserver
407 HostName 10.0.0.1
408";
409 let config = parse_str(content);
410 assert_eq!(config.serialize(), content);
411 }
412
413 #[test]
414 fn test_ssh_command() {
415 use crate::ssh_config::model::HostEntry;
416 let entry = HostEntry {
417 alias: "myserver".to_string(),
418 hostname: "10.0.0.1".to_string(),
419 ..Default::default()
420 };
421 assert_eq!(entry.ssh_command(), "ssh -- 'myserver'");
422 }
423
424 #[test]
425 fn test_unicode_comment_no_panic() {
426 let content = "# abcde\u{00e9} test\n\nHost myserver\n HostName 10.0.0.1\n";
429 let config = parse_str(content);
430 let entries = config.host_entries();
431 assert_eq!(entries.len(), 1);
432 assert_eq!(entries[0].alias, "myserver");
433 }
434
435 #[test]
436 fn test_unicode_multibyte_line_no_panic() {
437 let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n HostName 10.0.0.1\n";
439 let config = parse_str(content);
440 let entries = config.host_entries();
441 assert_eq!(entries.len(), 1);
442 }
443
444 #[test]
445 fn test_host_with_tab_separator() {
446 let content = "Host\tmyserver\n HostName 10.0.0.1\n";
447 let config = parse_str(content);
448 let entries = config.host_entries();
449 assert_eq!(entries.len(), 1);
450 assert_eq!(entries[0].alias, "myserver");
451 }
452
453 #[test]
454 fn test_include_with_tab_separator() {
455 let content = "Include\tconfig.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
456 let config = parse_str(content);
457 assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
458 }
459
460 #[test]
461 fn test_hostname_not_confused_with_host() {
462 let content = "Host myserver\n HostName example.com\n";
464 let config = parse_str(content);
465 let entries = config.host_entries();
466 assert_eq!(entries.len(), 1);
467 assert_eq!(entries[0].hostname, "example.com");
468 }
469
470 #[test]
471 fn test_equals_in_value_not_treated_as_separator() {
472 let content = "Host myserver\n IdentityFile ~/.ssh/id=prod\n";
473 let config = parse_str(content);
474 let entries = config.host_entries();
475 assert_eq!(entries.len(), 1);
476 assert_eq!(entries[0].identity_file, "~/.ssh/id=prod");
477 }
478
479 #[test]
480 fn test_equals_syntax_key_value() {
481 let content = "Host myserver\n HostName=10.0.0.1\n User = admin\n";
482 let config = parse_str(content);
483 let entries = config.host_entries();
484 assert_eq!(entries.len(), 1);
485 assert_eq!(entries[0].hostname, "10.0.0.1");
486 assert_eq!(entries[0].user, "admin");
487 }
488
489 #[test]
490 fn test_inline_comment_inside_quotes_preserved() {
491 let content = "Host myserver\n ProxyCommand ssh -W \"%h #test\" gateway\n";
492 let config = parse_str(content);
493 let entries = config.host_entries();
494 assert_eq!(entries.len(), 1);
495 if let ConfigElement::HostBlock(block) = &config.elements[0] {
497 let proxy_cmd = block.directives.iter().find(|d| d.key == "ProxyCommand").unwrap();
498 assert_eq!(proxy_cmd.value, "ssh -W \"%h #test\" gateway");
499 } else {
500 panic!("Expected HostBlock");
501 }
502 }
503
504 #[test]
505 fn test_inline_comment_outside_quotes_stripped() {
506 let content = "Host myserver\n HostName 10.0.0.1 # production\n";
507 let config = parse_str(content);
508 let entries = config.host_entries();
509 assert_eq!(entries[0].hostname, "10.0.0.1");
510 }
511}