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 = 16;
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 (bom, content) = match content.strip_prefix('\u{FEFF}') {
28 Some(stripped) => (true, stripped),
29 None => (false, content.as_str()),
30 };
31
32 let crlf = content.contains("\r\n");
33 let config_dir = path.parent().map(|p| p.to_path_buf());
34 let elements = Self::parse_content_with_includes(content, config_dir.as_deref(), depth);
35
36 Ok(SshConfigFile {
37 elements,
38 path: path.to_path_buf(),
39 crlf,
40 bom,
41 })
42 }
43
44 pub fn from_content(content: &str, synthetic_path: PathBuf) -> Self {
47 let elements = Self::parse_content_with_includes(content, None, MAX_INCLUDE_DEPTH);
48 SshConfigFile {
49 elements,
50 path: synthetic_path,
51 crlf: false,
52 bom: false,
53 }
54 }
55
56 #[allow(dead_code)]
59 pub fn parse_content(content: &str) -> Vec<ConfigElement> {
60 Self::parse_content_with_includes(content, None, MAX_INCLUDE_DEPTH)
61 }
62
63 fn parse_content_with_includes(
65 content: &str,
66 config_dir: Option<&Path>,
67 depth: usize,
68 ) -> Vec<ConfigElement> {
69 let mut elements = Vec::new();
70 let mut current_block: Option<HostBlock> = None;
71
72 for raw_line in content.lines() {
73 let line = raw_line.trim_end_matches('\r');
80 let trimmed = line.trim();
81
82 let is_indented = line.starts_with(' ') || line.starts_with('\t');
86 if !(current_block.is_some() && is_indented) {
87 if let Some(pattern) = Self::parse_include_line(trimmed) {
88 if let Some(block) = current_block.take() {
89 elements.push(ConfigElement::HostBlock(block));
90 }
91 let resolved = if depth < MAX_INCLUDE_DEPTH {
92 Self::resolve_include(pattern, config_dir, depth)
93 } else {
94 Vec::new()
95 };
96 elements.push(ConfigElement::Include(IncludeDirective {
97 raw_line: line.to_string(),
98 pattern: pattern.to_string(),
99 resolved_files: resolved,
100 }));
101 continue;
102 }
103 }
104
105 if !is_indented && Self::is_match_line(trimmed) {
108 if let Some(block) = current_block.take() {
109 elements.push(ConfigElement::HostBlock(block));
110 }
111 elements.push(ConfigElement::GlobalLine(line.to_string()));
112 continue;
113 }
114
115 if !is_indented && trimmed.starts_with("# purple:group ") {
118 if let Some(block) = current_block.take() {
119 elements.push(ConfigElement::HostBlock(block));
120 }
121 elements.push(ConfigElement::GlobalLine(line.to_string()));
122 continue;
123 }
124
125 if let Some(pattern) = Self::parse_host_line(trimmed) {
127 if let Some(block) = current_block.take() {
129 elements.push(ConfigElement::HostBlock(block));
130 }
131 current_block = Some(HostBlock {
132 host_pattern: pattern,
133 raw_host_line: line.to_string(),
134 directives: Vec::new(),
135 });
136 continue;
137 }
138
139 if let Some(ref mut block) = current_block {
141 if trimmed.is_empty() || trimmed.starts_with('#') {
142 block.directives.push(Directive {
144 key: String::new(),
145 value: String::new(),
146 raw_line: line.to_string(),
147 is_non_directive: true,
148 });
149 } else if let Some((key, value)) = Self::parse_directive(trimmed) {
150 block.directives.push(Directive {
151 key,
152 value,
153 raw_line: line.to_string(),
154 is_non_directive: false,
155 });
156 } else {
157 block.directives.push(Directive {
159 key: String::new(),
160 value: String::new(),
161 raw_line: line.to_string(),
162 is_non_directive: true,
163 });
164 }
165 } else {
166 elements.push(ConfigElement::GlobalLine(line.to_string()));
168 }
169 }
170
171 if let Some(block) = current_block {
173 elements.push(ConfigElement::HostBlock(block));
174 }
175
176 elements
177 }
178
179 fn parse_include_line(trimmed: &str) -> Option<&str> {
183 let bytes = trimmed.as_bytes();
184 if bytes.len() > 7 && bytes[..7].eq_ignore_ascii_case(b"include") {
186 let sep = bytes[7];
187 if sep.is_ascii_whitespace() || sep == b'=' {
188 let rest = trimmed[7..].trim_start();
191 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
192 if !rest.is_empty() {
193 return Some(rest);
194 }
195 }
196 }
197 None
198 }
199
200 pub(crate) fn split_include_patterns(pattern: &str) -> Vec<&str> {
203 let mut result = Vec::new();
204 let mut chars = pattern.char_indices().peekable();
205 while let Some(&(i, c)) = chars.peek() {
206 if c.is_whitespace() {
207 chars.next();
208 continue;
209 }
210 if c == '"' {
211 chars.next(); let start = i + 1;
213 let mut end = pattern.len();
214 for (j, ch) in chars.by_ref() {
215 if ch == '"' {
216 end = j;
217 break;
218 }
219 }
220 let token = &pattern[start..end];
221 if !token.is_empty() {
222 result.push(token);
223 }
224 } else {
225 let start = i;
226 let mut end = pattern.len();
227 for (j, ch) in chars.by_ref() {
228 if ch.is_whitespace() {
229 end = j;
230 break;
231 }
232 }
233 result.push(&pattern[start..end]);
234 }
235 }
236 result
237 }
238
239 fn resolve_include(
243 pattern: &str,
244 config_dir: Option<&Path>,
245 depth: usize,
246 ) -> Vec<IncludedFile> {
247 let mut files = Vec::new();
248 let mut seen = std::collections::HashSet::new();
249
250 for single in Self::split_include_patterns(pattern) {
251 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
252
253 let glob_pattern = if expanded.starts_with('/') {
255 expanded
256 } else if let Some(dir) = config_dir {
257 dir.join(&expanded).to_string_lossy().to_string()
258 } else {
259 continue;
260 };
261
262 if let Ok(paths) = glob::glob(&glob_pattern) {
263 let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
264 matched.sort();
265 for path in matched {
266 if path.is_file() && seen.insert(path.clone()) {
267 match std::fs::read_to_string(&path) {
268 Ok(content) => {
269 let content = content.strip_prefix('\u{FEFF}').unwrap_or(&content);
271 let elements = Self::parse_content_with_includes(
272 content,
273 path.parent(),
274 depth + 1,
275 );
276 files.push(IncludedFile {
277 path: path.clone(),
278 elements,
279 });
280 }
281 Err(e) => {
282 eprintln!(
283 "! Could not read Include file {}: {}",
284 path.display(),
285 e
286 );
287 }
288 }
289 }
290 }
291 }
292 }
293 files
294 }
295
296 pub(crate) fn expand_tilde(pattern: &str) -> String {
298 if let Some(rest) = pattern.strip_prefix("~/") {
299 if let Some(home) = dirs::home_dir() {
300 return format!("{}/{}", home.display(), rest);
301 }
302 }
303 pattern.to_string()
304 }
305
306 pub(crate) fn expand_env_vars(s: &str) -> String {
309 let mut result = String::with_capacity(s.len());
310 let mut chars = s.char_indices().peekable();
311 while let Some((i, c)) = chars.next() {
312 if c == '$' {
313 if let Some(&(_, '{')) = chars.peek() {
314 chars.next(); if let Some(close) = s[i + 2..].find('}') {
316 let var_name = &s[i + 2..i + 2 + close];
317 if let Ok(val) = std::env::var(var_name) {
318 result.push_str(&val);
319 } else {
320 result.push_str(&s[i..i + 2 + close + 1]);
322 }
323 while let Some(&(j, _)) = chars.peek() {
325 if j <= i + 2 + close {
326 chars.next();
327 } else {
328 break;
329 }
330 }
331 continue;
332 }
333 result.push('$');
335 result.push('{');
336 continue;
337 }
338 }
339 result.push(c);
340 }
341 result
342 }
343
344 fn parse_host_line(trimmed: &str) -> Option<String> {
350 let bytes = trimmed.as_bytes();
351 if bytes.len() > 4 && bytes[..4].eq_ignore_ascii_case(b"host") {
353 let sep = bytes[4];
354 if sep.is_ascii_whitespace() || sep == b'=' {
355 let rest = trimmed[4..].trim_start();
359 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
360 let pattern = strip_inline_comment(rest).to_string();
361 if !pattern.is_empty() {
362 return Some(pattern);
363 }
364 }
365 }
366 None
367 }
368
369 fn is_match_line(trimmed: &str) -> bool {
371 let mut parts = trimmed.splitn(2, [' ', '\t']);
372 let keyword = parts.next().unwrap_or("");
373 keyword.eq_ignore_ascii_case("match")
374 }
375
376 fn parse_directive(trimmed: &str) -> Option<(String, String)> {
381 let key_end = trimmed.find(|c: char| c.is_whitespace() || c == '=')?;
383 let key = &trimmed[..key_end];
384 if key.is_empty() {
385 return None;
386 }
387
388 let rest = trimmed[key_end..].trim_start();
390 let rest = rest.strip_prefix('=').unwrap_or(rest);
391 let value = rest.trim_start();
392
393 let value = strip_inline_comment(value);
396
397 Some((key.to_string(), value.to_string()))
398 }
399}
400
401fn strip_inline_comment(value: &str) -> &str {
404 let bytes = value.as_bytes();
405 let mut in_quote = false;
406 for i in 0..bytes.len() {
407 if bytes[i] == b'"' {
408 in_quote = !in_quote;
409 } else if !in_quote
410 && bytes[i] == b'#'
411 && i > 0
412 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
413 {
414 return value[..i].trim_end();
415 }
416 }
417 value
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use std::path::PathBuf;
424
425 fn parse_str(content: &str) -> SshConfigFile {
426 SshConfigFile {
427 elements: SshConfigFile::parse_content(content),
428 path: PathBuf::from("/tmp/test_config"),
429 crlf: content.contains("\r\n"),
430 bom: false,
431 }
432 }
433
434 #[test]
435 fn test_empty_config() {
436 let config = parse_str("");
437 assert!(config.host_entries().is_empty());
438 }
439
440 #[test]
441 fn test_basic_host() {
442 let config =
443 parse_str("Host myserver\n HostName 192.168.1.10\n User admin\n Port 2222\n");
444 let entries = config.host_entries();
445 assert_eq!(entries.len(), 1);
446 assert_eq!(entries[0].alias, "myserver");
447 assert_eq!(entries[0].hostname, "192.168.1.10");
448 assert_eq!(entries[0].user, "admin");
449 assert_eq!(entries[0].port, 2222);
450 }
451
452 #[test]
453 fn test_multiple_hosts() {
454 let content = "\
455Host alpha
456 HostName alpha.example.com
457 User deploy
458
459Host beta
460 HostName beta.example.com
461 User root
462 Port 22022
463";
464 let config = parse_str(content);
465 let entries = config.host_entries();
466 assert_eq!(entries.len(), 2);
467 assert_eq!(entries[0].alias, "alpha");
468 assert_eq!(entries[1].alias, "beta");
469 assert_eq!(entries[1].port, 22022);
470 }
471
472 #[test]
473 fn test_wildcard_host_filtered() {
474 let content = "\
475Host *
476 ServerAliveInterval 60
477
478Host myserver
479 HostName 10.0.0.1
480";
481 let config = parse_str(content);
482 let entries = config.host_entries();
483 assert_eq!(entries.len(), 1);
484 assert_eq!(entries[0].alias, "myserver");
485 }
486
487 #[test]
488 fn test_comments_preserved() {
489 let content = "\
490# Global comment
491Host myserver
492 # This is a comment
493 HostName 10.0.0.1
494 User admin
495";
496 let config = parse_str(content);
497 assert!(
499 matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment")
500 );
501 if let ConfigElement::HostBlock(block) = &config.elements[1] {
503 assert!(block.directives[0].is_non_directive);
504 assert_eq!(block.directives[0].raw_line, " # This is a comment");
505 } else {
506 panic!("Expected HostBlock");
507 }
508 }
509
510 #[test]
511 fn test_identity_file_and_proxy_jump() {
512 let content = "\
513Host bastion
514 HostName bastion.example.com
515 User admin
516 IdentityFile ~/.ssh/id_ed25519
517 ProxyJump gateway
518";
519 let config = parse_str(content);
520 let entries = config.host_entries();
521 assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
522 assert_eq!(entries[0].proxy_jump, "gateway");
523 }
524
525 #[test]
526 fn test_unknown_directives_preserved() {
527 let content = "\
528Host myserver
529 HostName 10.0.0.1
530 ForwardAgent yes
531 LocalForward 8080 localhost:80
532";
533 let config = parse_str(content);
534 if let ConfigElement::HostBlock(block) = &config.elements[0] {
535 assert_eq!(block.directives.len(), 3);
536 assert_eq!(block.directives[1].key, "ForwardAgent");
537 assert_eq!(block.directives[1].value, "yes");
538 assert_eq!(block.directives[2].key, "LocalForward");
539 } else {
540 panic!("Expected HostBlock");
541 }
542 }
543
544 #[test]
545 fn test_include_directive_parsed() {
546 let content = "\
547Include config.d/*
548
549Host myserver
550 HostName 10.0.0.1
551";
552 let config = parse_str(content);
553 assert!(
555 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*")
556 );
557 assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
559 assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
560 }
561
562 #[test]
563 fn test_include_round_trip() {
564 let content = "\
565Include ~/.ssh/config.d/*
566
567Host myserver
568 HostName 10.0.0.1
569";
570 let config = parse_str(content);
571 assert_eq!(config.serialize(), content);
572 }
573
574 #[test]
575 fn test_ssh_command() {
576 use crate::ssh_config::model::HostEntry;
577 use std::path::PathBuf;
578 let entry = HostEntry {
579 alias: "myserver".to_string(),
580 hostname: "10.0.0.1".to_string(),
581 ..Default::default()
582 };
583 let default_path = dirs::home_dir().unwrap().join(".ssh/config");
584 assert_eq!(entry.ssh_command(&default_path), "ssh -- 'myserver'");
585 let custom_path = PathBuf::from("/tmp/my_config");
586 assert_eq!(
587 entry.ssh_command(&custom_path),
588 "ssh -F '/tmp/my_config' -- 'myserver'"
589 );
590 }
591
592 #[test]
593 fn test_unicode_comment_no_panic() {
594 let content = "# abcde\u{00e9} test\n\nHost myserver\n HostName 10.0.0.1\n";
597 let config = parse_str(content);
598 let entries = config.host_entries();
599 assert_eq!(entries.len(), 1);
600 assert_eq!(entries[0].alias, "myserver");
601 }
602
603 #[test]
604 fn test_unicode_multibyte_line_no_panic() {
605 let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n HostName 10.0.0.1\n";
607 let config = parse_str(content);
608 let entries = config.host_entries();
609 assert_eq!(entries.len(), 1);
610 }
611
612 #[test]
613 fn test_host_with_tab_separator() {
614 let content = "Host\tmyserver\n HostName 10.0.0.1\n";
615 let config = parse_str(content);
616 let entries = config.host_entries();
617 assert_eq!(entries.len(), 1);
618 assert_eq!(entries[0].alias, "myserver");
619 }
620
621 #[test]
622 fn test_include_with_tab_separator() {
623 let content = "Include\tconfig.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
624 let config = parse_str(content);
625 assert!(
626 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
627 );
628 }
629
630 #[test]
631 fn test_include_with_equals_separator() {
632 let content = "Include=config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
633 let config = parse_str(content);
634 assert!(
635 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
636 );
637 }
638
639 #[test]
640 fn test_include_with_space_equals_separator() {
641 let content = "Include =config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
642 let config = parse_str(content);
643 assert!(
644 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
645 );
646 }
647
648 #[test]
649 fn test_include_with_space_equals_space_separator() {
650 let content = "Include = config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
651 let config = parse_str(content);
652 assert!(
653 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
654 );
655 }
656
657 #[test]
658 fn test_hostname_not_confused_with_host() {
659 let content = "Host myserver\n HostName example.com\n";
661 let config = parse_str(content);
662 let entries = config.host_entries();
663 assert_eq!(entries.len(), 1);
664 assert_eq!(entries[0].hostname, "example.com");
665 }
666
667 #[test]
668 fn test_equals_in_value_not_treated_as_separator() {
669 let content = "Host myserver\n IdentityFile ~/.ssh/id=prod\n";
670 let config = parse_str(content);
671 let entries = config.host_entries();
672 assert_eq!(entries.len(), 1);
673 assert_eq!(entries[0].identity_file, "~/.ssh/id=prod");
674 }
675
676 #[test]
677 fn test_equals_syntax_key_value() {
678 let content = "Host myserver\n HostName=10.0.0.1\n User = admin\n";
679 let config = parse_str(content);
680 let entries = config.host_entries();
681 assert_eq!(entries.len(), 1);
682 assert_eq!(entries[0].hostname, "10.0.0.1");
683 assert_eq!(entries[0].user, "admin");
684 }
685
686 #[test]
687 fn test_inline_comment_inside_quotes_preserved() {
688 let content = "Host myserver\n ProxyCommand ssh -W \"%h #test\" gateway\n";
689 let config = parse_str(content);
690 let entries = config.host_entries();
691 assert_eq!(entries.len(), 1);
692 if let ConfigElement::HostBlock(block) = &config.elements[0] {
694 let proxy_cmd = block
695 .directives
696 .iter()
697 .find(|d| d.key == "ProxyCommand")
698 .unwrap();
699 assert_eq!(proxy_cmd.value, "ssh -W \"%h #test\" gateway");
700 } else {
701 panic!("Expected HostBlock");
702 }
703 }
704
705 #[test]
706 fn test_inline_comment_outside_quotes_stripped() {
707 let content = "Host myserver\n HostName 10.0.0.1 # production\n";
708 let config = parse_str(content);
709 let entries = config.host_entries();
710 assert_eq!(entries[0].hostname, "10.0.0.1");
711 }
712
713 #[test]
714 fn test_host_inline_comment_stripped() {
715 let content = "Host alpha # this is a comment\n HostName 10.0.0.1\n";
716 let config = parse_str(content);
717 let entries = config.host_entries();
718 assert_eq!(entries.len(), 1);
719 assert_eq!(entries[0].alias, "alpha");
720 if let ConfigElement::HostBlock(block) = &config.elements[0] {
722 assert_eq!(block.raw_host_line, "Host alpha # this is a comment");
723 assert_eq!(block.host_pattern, "alpha");
724 } else {
725 panic!("Expected HostBlock");
726 }
727 }
728
729 #[test]
730 fn test_match_block_is_global_line() {
731 let content = "\
732Host myserver
733 HostName 10.0.0.1
734
735Match host *.example.com
736 ForwardAgent yes
737";
738 let config = parse_str(content);
739 let host_count = config
741 .elements
742 .iter()
743 .filter(|e| matches!(e, ConfigElement::HostBlock(_)))
744 .count();
745 assert_eq!(host_count, 1);
746 assert!(
748 config.elements.iter().any(
749 |e| matches!(e, ConfigElement::GlobalLine(s) if s == "Match host *.example.com")
750 )
751 );
752 assert!(
754 config
755 .elements
756 .iter()
757 .any(|e| matches!(e, ConfigElement::GlobalLine(s) if s.contains("ForwardAgent")))
758 );
759 }
760
761 #[test]
762 fn test_match_block_survives_host_deletion() {
763 let content = "\
764Host myserver
765 HostName 10.0.0.1
766
767Match host *.example.com
768 ForwardAgent yes
769
770Host other
771 HostName 10.0.0.2
772";
773 let mut config = parse_str(content);
774 config.delete_host("myserver");
775 let output = config.serialize();
776 assert!(output.contains("Match host *.example.com"));
777 assert!(output.contains("ForwardAgent yes"));
778 assert!(output.contains("Host other"));
779 assert!(!output.contains("Host myserver"));
780 }
781
782 #[test]
783 fn test_match_block_round_trip() {
784 let content = "\
785Host myserver
786 HostName 10.0.0.1
787
788Match host *.example.com
789 ForwardAgent yes
790";
791 let config = parse_str(content);
792 assert_eq!(config.serialize(), content);
793 }
794
795 #[test]
796 fn test_match_at_start_of_file() {
797 let content = "\
798Match all
799 ServerAliveInterval 60
800
801Host myserver
802 HostName 10.0.0.1
803";
804 let config = parse_str(content);
805 assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "Match all"));
806 assert!(
807 matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.contains("ServerAliveInterval"))
808 );
809 let entries = config.host_entries();
810 assert_eq!(entries.len(), 1);
811 assert_eq!(entries[0].alias, "myserver");
812 }
813
814 #[test]
815 fn test_host_equals_syntax() {
816 let config = parse_str("Host=foo\n HostName 10.0.0.1\n");
817 let entries = config.host_entries();
818 assert_eq!(entries.len(), 1);
819 assert_eq!(entries[0].alias, "foo");
820 }
821
822 #[test]
823 fn test_host_space_equals_syntax() {
824 let config = parse_str("Host =foo\n HostName 10.0.0.1\n");
825 let entries = config.host_entries();
826 assert_eq!(entries.len(), 1);
827 assert_eq!(entries[0].alias, "foo");
828 }
829
830 #[test]
831 fn test_host_equals_space_syntax() {
832 let config = parse_str("Host= foo\n HostName 10.0.0.1\n");
833 let entries = config.host_entries();
834 assert_eq!(entries.len(), 1);
835 assert_eq!(entries[0].alias, "foo");
836 }
837
838 #[test]
839 fn test_host_space_equals_space_syntax() {
840 let config = parse_str("Host = foo\n HostName 10.0.0.1\n");
841 let entries = config.host_entries();
842 assert_eq!(entries.len(), 1);
843 assert_eq!(entries[0].alias, "foo");
844 }
845
846 #[test]
847 fn test_host_equals_case_insensitive() {
848 let config = parse_str("HOST=foo\n HostName 10.0.0.1\n");
849 let entries = config.host_entries();
850 assert_eq!(entries.len(), 1);
851 assert_eq!(entries[0].alias, "foo");
852 }
853
854 #[test]
855 fn test_hostname_equals_not_parsed_as_host() {
856 let config = parse_str("Host myserver\n HostName=example.com\n");
858 let entries = config.host_entries();
859 assert_eq!(entries.len(), 1);
860 assert_eq!(entries[0].alias, "myserver");
861 assert_eq!(entries[0].hostname, "example.com");
862 }
863
864 #[test]
865 fn test_host_multi_pattern_with_inline_comment() {
866 let content = "Host prod staging # servers\n HostName 10.0.0.1\n";
870 let config = parse_str(content);
871 if let ConfigElement::HostBlock(block) = &config.elements[0] {
872 assert_eq!(block.host_pattern, "prod staging");
873 } else {
874 panic!("Expected HostBlock");
875 }
876 assert_eq!(config.host_entries().len(), 0);
878 }
879
880 #[test]
881 fn test_expand_env_vars_basic() {
882 unsafe { std::env::set_var("_PURPLE_TEST_VAR", "/custom/path") };
884 let result = SshConfigFile::expand_env_vars("${_PURPLE_TEST_VAR}/.ssh/config");
885 assert_eq!(result, "/custom/path/.ssh/config");
886 unsafe { std::env::remove_var("_PURPLE_TEST_VAR") };
887 }
888
889 #[test]
890 fn test_expand_env_vars_multiple() {
891 unsafe { std::env::set_var("_PURPLE_TEST_A", "hello") };
893 unsafe { std::env::set_var("_PURPLE_TEST_B", "world") };
894 let result = SshConfigFile::expand_env_vars("${_PURPLE_TEST_A}/${_PURPLE_TEST_B}");
895 assert_eq!(result, "hello/world");
896 unsafe { std::env::remove_var("_PURPLE_TEST_A") };
897 unsafe { std::env::remove_var("_PURPLE_TEST_B") };
898 }
899
900 #[test]
901 fn test_expand_env_vars_unknown_preserved() {
902 let result = SshConfigFile::expand_env_vars("${_PURPLE_NONEXISTENT_VAR}/path");
903 assert_eq!(result, "${_PURPLE_NONEXISTENT_VAR}/path");
904 }
905
906 #[test]
907 fn test_expand_env_vars_no_vars() {
908 let result = SshConfigFile::expand_env_vars("~/.ssh/config.d/*");
909 assert_eq!(result, "~/.ssh/config.d/*");
910 }
911
912 #[test]
913 fn test_expand_env_vars_unclosed_brace() {
914 let result = SshConfigFile::expand_env_vars("${UNCLOSED/path");
915 assert_eq!(result, "${UNCLOSED/path");
916 }
917
918 #[test]
919 fn test_expand_env_vars_dollar_without_brace() {
920 let result = SshConfigFile::expand_env_vars("$HOME/.ssh/config");
921 assert_eq!(result, "$HOME/.ssh/config");
923 }
924
925 #[test]
926 fn test_max_include_depth_matches_openssh() {
927 assert_eq!(MAX_INCLUDE_DEPTH, 16);
928 }
929
930 #[test]
931 fn test_split_include_patterns_single_unquoted() {
932 let result = SshConfigFile::split_include_patterns("config.d/*");
933 assert_eq!(result, vec!["config.d/*"]);
934 }
935
936 #[test]
937 fn test_split_include_patterns_quoted_with_spaces() {
938 let result = SshConfigFile::split_include_patterns("\"/path/with spaces/config\"");
939 assert_eq!(result, vec!["/path/with spaces/config"]);
940 }
941
942 #[test]
943 fn test_split_include_patterns_mixed() {
944 let result =
945 SshConfigFile::split_include_patterns("\"/path/with spaces/*\" ~/.ssh/config.d/*");
946 assert_eq!(result, vec!["/path/with spaces/*", "~/.ssh/config.d/*"]);
947 }
948
949 #[test]
950 fn test_split_include_patterns_quoted_no_spaces() {
951 let result = SshConfigFile::split_include_patterns("\"config.d/*\"");
952 assert_eq!(result, vec!["config.d/*"]);
953 }
954
955 #[test]
956 fn test_split_include_patterns_multiple_unquoted() {
957 let result = SshConfigFile::split_include_patterns("~/.ssh/conf.d/* /etc/ssh/config.d/*");
958 assert_eq!(result, vec!["~/.ssh/conf.d/*", "/etc/ssh/config.d/*"]);
959 }
960}