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