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