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