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