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