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 !is_indented && Self::is_match_line(trimmed) {
82 if let Some(block) = current_block.take() {
83 elements.push(ConfigElement::HostBlock(block));
84 }
85 elements.push(ConfigElement::GlobalLine(line.to_string()));
86 continue;
87 }
88
89 if let Some(pattern) = Self::parse_host_line(trimmed) {
91 if let Some(block) = current_block.take() {
93 elements.push(ConfigElement::HostBlock(block));
94 }
95 current_block = Some(HostBlock {
96 host_pattern: pattern,
97 raw_host_line: line.to_string(),
98 directives: Vec::new(),
99 });
100 continue;
101 }
102
103 if let Some(ref mut block) = current_block {
105 if trimmed.is_empty() || trimmed.starts_with('#') {
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 } else if let Some((key, value)) = Self::parse_directive(trimmed) {
114 block.directives.push(Directive {
115 key,
116 value,
117 raw_line: line.to_string(),
118 is_non_directive: false,
119 });
120 } else {
121 block.directives.push(Directive {
123 key: String::new(),
124 value: String::new(),
125 raw_line: line.to_string(),
126 is_non_directive: true,
127 });
128 }
129 } else {
130 elements.push(ConfigElement::GlobalLine(line.to_string()));
132 }
133 }
134
135 if let Some(block) = current_block {
137 elements.push(ConfigElement::HostBlock(block));
138 }
139
140 elements
141 }
142
143 fn parse_include_line(trimmed: &str) -> Option<&str> {
147 let bytes = trimmed.as_bytes();
148 if bytes.len() > 7
150 && bytes[..7].eq_ignore_ascii_case(b"include")
151 {
152 let sep = bytes[7];
153 if sep.is_ascii_whitespace() || sep == b'=' {
154 let rest = trimmed[7..].trim_start();
157 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
158 if !rest.is_empty() {
159 return Some(rest);
160 }
161 }
162 }
163 None
164 }
165
166 fn resolve_include(
169 pattern: &str,
170 config_dir: Option<&Path>,
171 depth: usize,
172 ) -> Vec<IncludedFile> {
173 let mut files = Vec::new();
174 let mut seen = std::collections::HashSet::new();
175
176 for single in pattern.split_whitespace() {
177 let expanded = Self::expand_tilde(single);
178
179 let glob_pattern = if expanded.starts_with('/') {
181 expanded
182 } else if let Some(dir) = config_dir {
183 dir.join(&expanded).to_string_lossy().to_string()
184 } else {
185 continue;
186 };
187
188 if let Ok(paths) = glob::glob(&glob_pattern) {
189 let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
190 matched.sort();
191 for path in matched {
192 if path.is_file() && seen.insert(path.clone()) {
193 match std::fs::read_to_string(&path) {
194 Ok(content) => {
195 let elements = Self::parse_content_with_includes(
196 &content,
197 path.parent(),
198 depth + 1,
199 );
200 files.push(IncludedFile {
201 path: path.clone(),
202 elements,
203 });
204 }
205 Err(e) => {
206 eprintln!(
207 "! Could not read Include file {}: {}",
208 path.display(),
209 e
210 );
211 }
212 }
213 }
214 }
215 }
216 }
217 files
218 }
219
220 pub(crate) fn expand_tilde(pattern: &str) -> String {
222 if let Some(rest) = pattern.strip_prefix("~/") {
223 if let Some(home) = dirs::home_dir() {
224 return format!("{}/{}", home.display(), rest);
225 }
226 }
227 pattern.to_string()
228 }
229
230 fn parse_host_line(trimmed: &str) -> Option<String> {
235 let mut parts = trimmed.splitn(2, [' ', '\t']);
237 let keyword = parts.next()?;
238 if !keyword.eq_ignore_ascii_case("host") {
239 return None;
240 }
241 let raw_pattern = parts.next()?.trim();
243 let pattern = strip_inline_comment(raw_pattern).to_string();
244 if !pattern.is_empty() {
245 return Some(pattern);
246 }
247 None
248 }
249
250 fn is_match_line(trimmed: &str) -> bool {
252 let mut parts = trimmed.splitn(2, [' ', '\t']);
253 let keyword = parts.next().unwrap_or("");
254 keyword.eq_ignore_ascii_case("match")
255 }
256
257 fn parse_directive(trimmed: &str) -> Option<(String, String)> {
262 let key_end = trimmed.find(|c: char| c.is_whitespace() || c == '=')?;
264 let key = &trimmed[..key_end];
265 if key.is_empty() {
266 return None;
267 }
268
269 let rest = trimmed[key_end..].trim_start();
271 let rest = rest.strip_prefix('=').unwrap_or(rest);
272 let value = rest.trim_start();
273
274 let value = strip_inline_comment(value);
277
278 Some((key.to_string(), value.to_string()))
279 }
280}
281
282fn strip_inline_comment(value: &str) -> &str {
285 let bytes = value.as_bytes();
286 let mut in_quote = false;
287 for i in 0..bytes.len() {
288 if bytes[i] == b'"' {
289 in_quote = !in_quote;
290 } else if !in_quote
291 && bytes[i] == b'#'
292 && i > 0
293 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
294 {
295 return value[..i].trim_end();
296 }
297 }
298 value
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use std::path::PathBuf;
305
306 fn parse_str(content: &str) -> SshConfigFile {
307 SshConfigFile {
308 elements: SshConfigFile::parse_content(content),
309 path: PathBuf::from("/tmp/test_config"),
310 crlf: content.contains("\r\n"),
311 }
312 }
313
314 #[test]
315 fn test_empty_config() {
316 let config = parse_str("");
317 assert!(config.host_entries().is_empty());
318 }
319
320 #[test]
321 fn test_basic_host() {
322 let config = parse_str(
323 "Host myserver\n HostName 192.168.1.10\n User admin\n Port 2222\n",
324 );
325 let entries = config.host_entries();
326 assert_eq!(entries.len(), 1);
327 assert_eq!(entries[0].alias, "myserver");
328 assert_eq!(entries[0].hostname, "192.168.1.10");
329 assert_eq!(entries[0].user, "admin");
330 assert_eq!(entries[0].port, 2222);
331 }
332
333 #[test]
334 fn test_multiple_hosts() {
335 let content = "\
336Host alpha
337 HostName alpha.example.com
338 User deploy
339
340Host beta
341 HostName beta.example.com
342 User root
343 Port 22022
344";
345 let config = parse_str(content);
346 let entries = config.host_entries();
347 assert_eq!(entries.len(), 2);
348 assert_eq!(entries[0].alias, "alpha");
349 assert_eq!(entries[1].alias, "beta");
350 assert_eq!(entries[1].port, 22022);
351 }
352
353 #[test]
354 fn test_wildcard_host_filtered() {
355 let content = "\
356Host *
357 ServerAliveInterval 60
358
359Host myserver
360 HostName 10.0.0.1
361";
362 let config = parse_str(content);
363 let entries = config.host_entries();
364 assert_eq!(entries.len(), 1);
365 assert_eq!(entries[0].alias, "myserver");
366 }
367
368 #[test]
369 fn test_comments_preserved() {
370 let content = "\
371# Global comment
372Host myserver
373 # This is a comment
374 HostName 10.0.0.1
375 User admin
376";
377 let config = parse_str(content);
378 assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment"));
380 if let ConfigElement::HostBlock(block) = &config.elements[1] {
382 assert!(block.directives[0].is_non_directive);
383 assert_eq!(block.directives[0].raw_line, " # This is a comment");
384 } else {
385 panic!("Expected HostBlock");
386 }
387 }
388
389 #[test]
390 fn test_identity_file_and_proxy_jump() {
391 let content = "\
392Host bastion
393 HostName bastion.example.com
394 User admin
395 IdentityFile ~/.ssh/id_ed25519
396 ProxyJump gateway
397";
398 let config = parse_str(content);
399 let entries = config.host_entries();
400 assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
401 assert_eq!(entries[0].proxy_jump, "gateway");
402 }
403
404 #[test]
405 fn test_unknown_directives_preserved() {
406 let content = "\
407Host myserver
408 HostName 10.0.0.1
409 ForwardAgent yes
410 LocalForward 8080 localhost:80
411";
412 let config = parse_str(content);
413 if let ConfigElement::HostBlock(block) = &config.elements[0] {
414 assert_eq!(block.directives.len(), 3);
415 assert_eq!(block.directives[1].key, "ForwardAgent");
416 assert_eq!(block.directives[1].value, "yes");
417 assert_eq!(block.directives[2].key, "LocalForward");
418 } else {
419 panic!("Expected HostBlock");
420 }
421 }
422
423 #[test]
424 fn test_include_directive_parsed() {
425 let content = "\
426Include config.d/*
427
428Host myserver
429 HostName 10.0.0.1
430";
431 let config = parse_str(content);
432 assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*"));
434 assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
436 assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
437 }
438
439 #[test]
440 fn test_include_round_trip() {
441 let content = "\
442Include ~/.ssh/config.d/*
443
444Host myserver
445 HostName 10.0.0.1
446";
447 let config = parse_str(content);
448 assert_eq!(config.serialize(), content);
449 }
450
451 #[test]
452 fn test_ssh_command() {
453 use crate::ssh_config::model::HostEntry;
454 use std::path::PathBuf;
455 let entry = HostEntry {
456 alias: "myserver".to_string(),
457 hostname: "10.0.0.1".to_string(),
458 ..Default::default()
459 };
460 let default_path = dirs::home_dir().unwrap().join(".ssh/config");
461 assert_eq!(entry.ssh_command(&default_path), "ssh -- 'myserver'");
462 let custom_path = PathBuf::from("/tmp/my_config");
463 assert_eq!(entry.ssh_command(&custom_path), "ssh -F '/tmp/my_config' -- 'myserver'");
464 }
465
466 #[test]
467 fn test_unicode_comment_no_panic() {
468 let content = "# abcde\u{00e9} test\n\nHost myserver\n HostName 10.0.0.1\n";
471 let config = parse_str(content);
472 let entries = config.host_entries();
473 assert_eq!(entries.len(), 1);
474 assert_eq!(entries[0].alias, "myserver");
475 }
476
477 #[test]
478 fn test_unicode_multibyte_line_no_panic() {
479 let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n HostName 10.0.0.1\n";
481 let config = parse_str(content);
482 let entries = config.host_entries();
483 assert_eq!(entries.len(), 1);
484 }
485
486 #[test]
487 fn test_host_with_tab_separator() {
488 let content = "Host\tmyserver\n HostName 10.0.0.1\n";
489 let config = parse_str(content);
490 let entries = config.host_entries();
491 assert_eq!(entries.len(), 1);
492 assert_eq!(entries[0].alias, "myserver");
493 }
494
495 #[test]
496 fn test_include_with_tab_separator() {
497 let content = "Include\tconfig.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
498 let config = parse_str(content);
499 assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
500 }
501
502 #[test]
503 fn test_include_with_equals_separator() {
504 let content = "Include=config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
505 let config = parse_str(content);
506 assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
507 }
508
509 #[test]
510 fn test_include_with_space_equals_separator() {
511 let content = "Include =config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
512 let config = parse_str(content);
513 assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
514 }
515
516 #[test]
517 fn test_include_with_space_equals_space_separator() {
518 let content = "Include = config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
519 let config = parse_str(content);
520 assert!(matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*"));
521 }
522
523 #[test]
524 fn test_hostname_not_confused_with_host() {
525 let content = "Host myserver\n HostName example.com\n";
527 let config = parse_str(content);
528 let entries = config.host_entries();
529 assert_eq!(entries.len(), 1);
530 assert_eq!(entries[0].hostname, "example.com");
531 }
532
533 #[test]
534 fn test_equals_in_value_not_treated_as_separator() {
535 let content = "Host myserver\n IdentityFile ~/.ssh/id=prod\n";
536 let config = parse_str(content);
537 let entries = config.host_entries();
538 assert_eq!(entries.len(), 1);
539 assert_eq!(entries[0].identity_file, "~/.ssh/id=prod");
540 }
541
542 #[test]
543 fn test_equals_syntax_key_value() {
544 let content = "Host myserver\n HostName=10.0.0.1\n User = admin\n";
545 let config = parse_str(content);
546 let entries = config.host_entries();
547 assert_eq!(entries.len(), 1);
548 assert_eq!(entries[0].hostname, "10.0.0.1");
549 assert_eq!(entries[0].user, "admin");
550 }
551
552 #[test]
553 fn test_inline_comment_inside_quotes_preserved() {
554 let content = "Host myserver\n ProxyCommand ssh -W \"%h #test\" gateway\n";
555 let config = parse_str(content);
556 let entries = config.host_entries();
557 assert_eq!(entries.len(), 1);
558 if let ConfigElement::HostBlock(block) = &config.elements[0] {
560 let proxy_cmd = block.directives.iter().find(|d| d.key == "ProxyCommand").unwrap();
561 assert_eq!(proxy_cmd.value, "ssh -W \"%h #test\" gateway");
562 } else {
563 panic!("Expected HostBlock");
564 }
565 }
566
567 #[test]
568 fn test_inline_comment_outside_quotes_stripped() {
569 let content = "Host myserver\n HostName 10.0.0.1 # production\n";
570 let config = parse_str(content);
571 let entries = config.host_entries();
572 assert_eq!(entries[0].hostname, "10.0.0.1");
573 }
574
575 #[test]
576 fn test_host_inline_comment_stripped() {
577 let content = "Host alpha # this is a comment\n HostName 10.0.0.1\n";
578 let config = parse_str(content);
579 let entries = config.host_entries();
580 assert_eq!(entries.len(), 1);
581 assert_eq!(entries[0].alias, "alpha");
582 if let ConfigElement::HostBlock(block) = &config.elements[0] {
584 assert_eq!(block.raw_host_line, "Host alpha # this is a comment");
585 assert_eq!(block.host_pattern, "alpha");
586 } else {
587 panic!("Expected HostBlock");
588 }
589 }
590
591 #[test]
592 fn test_match_block_is_global_line() {
593 let content = "\
594Host myserver
595 HostName 10.0.0.1
596
597Match host *.example.com
598 ForwardAgent yes
599";
600 let config = parse_str(content);
601 let host_count = config.elements.iter().filter(|e| matches!(e, ConfigElement::HostBlock(_))).count();
603 assert_eq!(host_count, 1);
604 assert!(config.elements.iter().any(|e| matches!(e, ConfigElement::GlobalLine(s) if s == "Match host *.example.com")));
606 assert!(config.elements.iter().any(|e| matches!(e, ConfigElement::GlobalLine(s) if s.contains("ForwardAgent"))));
608 }
609
610 #[test]
611 fn test_match_block_survives_host_deletion() {
612 let content = "\
613Host myserver
614 HostName 10.0.0.1
615
616Match host *.example.com
617 ForwardAgent yes
618
619Host other
620 HostName 10.0.0.2
621";
622 let mut config = parse_str(content);
623 config.delete_host("myserver");
624 let output = config.serialize();
625 assert!(output.contains("Match host *.example.com"));
626 assert!(output.contains("ForwardAgent yes"));
627 assert!(output.contains("Host other"));
628 assert!(!output.contains("Host myserver"));
629 }
630
631 #[test]
632 fn test_match_block_round_trip() {
633 let content = "\
634Host myserver
635 HostName 10.0.0.1
636
637Match host *.example.com
638 ForwardAgent yes
639";
640 let config = parse_str(content);
641 assert_eq!(config.serialize(), content);
642 }
643
644 #[test]
645 fn test_match_at_start_of_file() {
646 let content = "\
647Match all
648 ServerAliveInterval 60
649
650Host myserver
651 HostName 10.0.0.1
652";
653 let config = parse_str(content);
654 assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "Match all"));
655 assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.contains("ServerAliveInterval")));
656 let entries = config.host_entries();
657 assert_eq!(entries.len(), 1);
658 assert_eq!(entries[0].alias, "myserver");
659 }
660
661 #[test]
662 fn test_host_multi_pattern_with_inline_comment() {
663 let content = "Host prod staging # servers\n HostName 10.0.0.1\n";
667 let config = parse_str(content);
668 if let ConfigElement::HostBlock(block) = &config.elements[0] {
669 assert_eq!(block.host_pattern, "prod staging");
670 } else {
671 panic!("Expected HostBlock");
672 }
673 assert_eq!(config.host_entries().len(), 0);
675 }
676}