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 eprintln!(
305 "! 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 use std::path::PathBuf;
446
447 fn parse_str(content: &str) -> SshConfigFile {
448 SshConfigFile {
449 elements: SshConfigFile::parse_content(content),
450 path: PathBuf::from("/tmp/test_config"),
451 crlf: content.contains("\r\n"),
452 bom: false,
453 }
454 }
455
456 #[test]
457 fn test_empty_config() {
458 let config = parse_str("");
459 assert!(config.host_entries().is_empty());
460 }
461
462 #[test]
463 fn test_basic_host() {
464 let config =
465 parse_str("Host myserver\n HostName 192.168.1.10\n User admin\n Port 2222\n");
466 let entries = config.host_entries();
467 assert_eq!(entries.len(), 1);
468 assert_eq!(entries[0].alias, "myserver");
469 assert_eq!(entries[0].hostname, "192.168.1.10");
470 assert_eq!(entries[0].user, "admin");
471 assert_eq!(entries[0].port, 2222);
472 }
473
474 #[test]
475 fn test_multiple_hosts() {
476 let content = "\
477Host alpha
478 HostName alpha.example.com
479 User deploy
480
481Host beta
482 HostName beta.example.com
483 User root
484 Port 22022
485";
486 let config = parse_str(content);
487 let entries = config.host_entries();
488 assert_eq!(entries.len(), 2);
489 assert_eq!(entries[0].alias, "alpha");
490 assert_eq!(entries[1].alias, "beta");
491 assert_eq!(entries[1].port, 22022);
492 }
493
494 #[test]
495 fn test_wildcard_host_filtered() {
496 let content = "\
497Host *
498 ServerAliveInterval 60
499
500Host myserver
501 HostName 10.0.0.1
502";
503 let config = parse_str(content);
504 let entries = config.host_entries();
505 assert_eq!(entries.len(), 1);
506 assert_eq!(entries[0].alias, "myserver");
507 }
508
509 #[test]
510 fn test_comments_preserved() {
511 let content = "\
512# Global comment
513Host myserver
514 # This is a comment
515 HostName 10.0.0.1
516 User admin
517";
518 let config = parse_str(content);
519 assert!(
521 matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment")
522 );
523 if let ConfigElement::HostBlock(block) = &config.elements[1] {
525 assert!(block.directives[0].is_non_directive);
526 assert_eq!(block.directives[0].raw_line, " # This is a comment");
527 } else {
528 panic!("Expected HostBlock");
529 }
530 }
531
532 #[test]
533 fn test_identity_file_and_proxy_jump() {
534 let content = "\
535Host bastion
536 HostName bastion.example.com
537 User admin
538 IdentityFile ~/.ssh/id_ed25519
539 ProxyJump gateway
540";
541 let config = parse_str(content);
542 let entries = config.host_entries();
543 assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
544 assert_eq!(entries[0].proxy_jump, "gateway");
545 }
546
547 #[test]
548 fn test_unknown_directives_preserved() {
549 let content = "\
550Host myserver
551 HostName 10.0.0.1
552 ForwardAgent yes
553 LocalForward 8080 localhost:80
554";
555 let config = parse_str(content);
556 if let ConfigElement::HostBlock(block) = &config.elements[0] {
557 assert_eq!(block.directives.len(), 3);
558 assert_eq!(block.directives[1].key, "ForwardAgent");
559 assert_eq!(block.directives[1].value, "yes");
560 assert_eq!(block.directives[2].key, "LocalForward");
561 } else {
562 panic!("Expected HostBlock");
563 }
564 }
565
566 #[test]
567 fn test_include_directive_parsed() {
568 let content = "\
569Include config.d/*
570
571Host myserver
572 HostName 10.0.0.1
573";
574 let config = parse_str(content);
575 assert!(
577 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*")
578 );
579 assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
581 assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
582 }
583
584 #[test]
585 fn test_include_round_trip() {
586 let content = "\
587Include ~/.ssh/config.d/*
588
589Host myserver
590 HostName 10.0.0.1
591";
592 let config = parse_str(content);
593 assert_eq!(config.serialize(), content);
594 }
595
596 #[test]
597 fn test_ssh_command() {
598 use crate::ssh_config::model::HostEntry;
599 use std::path::PathBuf;
600 let entry = HostEntry {
601 alias: "myserver".to_string(),
602 hostname: "10.0.0.1".to_string(),
603 ..Default::default()
604 };
605 let default_path = dirs::home_dir().unwrap().join(".ssh/config");
606 assert_eq!(entry.ssh_command(&default_path), "ssh -- 'myserver'");
607 let custom_path = PathBuf::from("/tmp/my_config");
608 assert_eq!(
609 entry.ssh_command(&custom_path),
610 "ssh -F '/tmp/my_config' -- 'myserver'"
611 );
612 }
613
614 #[test]
615 fn test_unicode_comment_no_panic() {
616 let content = "# abcde\u{00e9} test\n\nHost myserver\n HostName 10.0.0.1\n";
619 let config = parse_str(content);
620 let entries = config.host_entries();
621 assert_eq!(entries.len(), 1);
622 assert_eq!(entries[0].alias, "myserver");
623 }
624
625 #[test]
626 fn test_unicode_multibyte_line_no_panic() {
627 let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n HostName 10.0.0.1\n";
629 let config = parse_str(content);
630 let entries = config.host_entries();
631 assert_eq!(entries.len(), 1);
632 }
633
634 #[test]
635 fn test_host_with_tab_separator() {
636 let content = "Host\tmyserver\n HostName 10.0.0.1\n";
637 let config = parse_str(content);
638 let entries = config.host_entries();
639 assert_eq!(entries.len(), 1);
640 assert_eq!(entries[0].alias, "myserver");
641 }
642
643 #[test]
644 fn test_include_with_tab_separator() {
645 let content = "Include\tconfig.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
646 let config = parse_str(content);
647 assert!(
648 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
649 );
650 }
651
652 #[test]
653 fn test_include_with_equals_separator() {
654 let content = "Include=config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
655 let config = parse_str(content);
656 assert!(
657 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
658 );
659 }
660
661 #[test]
662 fn test_include_with_space_equals_separator() {
663 let content = "Include =config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
664 let config = parse_str(content);
665 assert!(
666 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
667 );
668 }
669
670 #[test]
671 fn test_include_with_space_equals_space_separator() {
672 let content = "Include = config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
673 let config = parse_str(content);
674 assert!(
675 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
676 );
677 }
678
679 #[test]
680 fn test_hostname_not_confused_with_host() {
681 let content = "Host myserver\n HostName example.com\n";
683 let config = parse_str(content);
684 let entries = config.host_entries();
685 assert_eq!(entries.len(), 1);
686 assert_eq!(entries[0].hostname, "example.com");
687 }
688
689 #[test]
690 fn test_equals_in_value_not_treated_as_separator() {
691 let content = "Host myserver\n IdentityFile ~/.ssh/id=prod\n";
692 let config = parse_str(content);
693 let entries = config.host_entries();
694 assert_eq!(entries.len(), 1);
695 assert_eq!(entries[0].identity_file, "~/.ssh/id=prod");
696 }
697
698 #[test]
699 fn test_equals_syntax_key_value() {
700 let content = "Host myserver\n HostName=10.0.0.1\n User = admin\n";
701 let config = parse_str(content);
702 let entries = config.host_entries();
703 assert_eq!(entries.len(), 1);
704 assert_eq!(entries[0].hostname, "10.0.0.1");
705 assert_eq!(entries[0].user, "admin");
706 }
707
708 #[test]
709 fn test_inline_comment_inside_quotes_preserved() {
710 let content = "Host myserver\n ProxyCommand ssh -W \"%h #test\" gateway\n";
711 let config = parse_str(content);
712 let entries = config.host_entries();
713 assert_eq!(entries.len(), 1);
714 if let ConfigElement::HostBlock(block) = &config.elements[0] {
716 let proxy_cmd = block
717 .directives
718 .iter()
719 .find(|d| d.key == "ProxyCommand")
720 .unwrap();
721 assert_eq!(proxy_cmd.value, "ssh -W \"%h #test\" gateway");
722 } else {
723 panic!("Expected HostBlock");
724 }
725 }
726
727 #[test]
728 fn test_inline_comment_outside_quotes_stripped() {
729 let content = "Host myserver\n HostName 10.0.0.1 # production\n";
730 let config = parse_str(content);
731 let entries = config.host_entries();
732 assert_eq!(entries[0].hostname, "10.0.0.1");
733 }
734
735 #[test]
736 fn test_host_inline_comment_stripped() {
737 let content = "Host alpha # this is a comment\n HostName 10.0.0.1\n";
738 let config = parse_str(content);
739 let entries = config.host_entries();
740 assert_eq!(entries.len(), 1);
741 assert_eq!(entries[0].alias, "alpha");
742 if let ConfigElement::HostBlock(block) = &config.elements[0] {
744 assert_eq!(block.raw_host_line, "Host alpha # this is a comment");
745 assert_eq!(block.host_pattern, "alpha");
746 } else {
747 panic!("Expected HostBlock");
748 }
749 }
750
751 #[test]
752 fn test_match_block_is_global_line() {
753 let content = "\
754Host myserver
755 HostName 10.0.0.1
756
757Match host *.example.com
758 ForwardAgent yes
759";
760 let config = parse_str(content);
761 let host_count = config
763 .elements
764 .iter()
765 .filter(|e| matches!(e, ConfigElement::HostBlock(_)))
766 .count();
767 assert_eq!(host_count, 1);
768 assert!(
770 config.elements.iter().any(
771 |e| matches!(e, ConfigElement::GlobalLine(s) if s == "Match host *.example.com")
772 )
773 );
774 assert!(
776 config
777 .elements
778 .iter()
779 .any(|e| matches!(e, ConfigElement::GlobalLine(s) if s.contains("ForwardAgent")))
780 );
781 }
782
783 #[test]
784 fn test_match_block_survives_host_deletion() {
785 let content = "\
786Host myserver
787 HostName 10.0.0.1
788
789Match host *.example.com
790 ForwardAgent yes
791
792Host other
793 HostName 10.0.0.2
794";
795 let mut config = parse_str(content);
796 config.delete_host("myserver");
797 let output = config.serialize();
798 assert!(output.contains("Match host *.example.com"));
799 assert!(output.contains("ForwardAgent yes"));
800 assert!(output.contains("Host other"));
801 assert!(!output.contains("Host myserver"));
802 }
803
804 #[test]
805 fn test_match_block_round_trip() {
806 let content = "\
807Host myserver
808 HostName 10.0.0.1
809
810Match host *.example.com
811 ForwardAgent yes
812";
813 let config = parse_str(content);
814 assert_eq!(config.serialize(), content);
815 }
816
817 #[test]
818 fn test_match_at_start_of_file() {
819 let content = "\
820Match all
821 ServerAliveInterval 60
822
823Host myserver
824 HostName 10.0.0.1
825";
826 let config = parse_str(content);
827 assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "Match all"));
828 assert!(
829 matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.contains("ServerAliveInterval"))
830 );
831 let entries = config.host_entries();
832 assert_eq!(entries.len(), 1);
833 assert_eq!(entries[0].alias, "myserver");
834 }
835
836 #[test]
837 fn test_host_equals_syntax() {
838 let config = parse_str("Host=foo\n HostName 10.0.0.1\n");
839 let entries = config.host_entries();
840 assert_eq!(entries.len(), 1);
841 assert_eq!(entries[0].alias, "foo");
842 }
843
844 #[test]
845 fn test_host_space_equals_syntax() {
846 let config = parse_str("Host =foo\n HostName 10.0.0.1\n");
847 let entries = config.host_entries();
848 assert_eq!(entries.len(), 1);
849 assert_eq!(entries[0].alias, "foo");
850 }
851
852 #[test]
853 fn test_host_equals_space_syntax() {
854 let config = parse_str("Host= foo\n HostName 10.0.0.1\n");
855 let entries = config.host_entries();
856 assert_eq!(entries.len(), 1);
857 assert_eq!(entries[0].alias, "foo");
858 }
859
860 #[test]
861 fn test_host_space_equals_space_syntax() {
862 let config = parse_str("Host = foo\n HostName 10.0.0.1\n");
863 let entries = config.host_entries();
864 assert_eq!(entries.len(), 1);
865 assert_eq!(entries[0].alias, "foo");
866 }
867
868 #[test]
869 fn test_host_equals_case_insensitive() {
870 let config = parse_str("HOST=foo\n HostName 10.0.0.1\n");
871 let entries = config.host_entries();
872 assert_eq!(entries.len(), 1);
873 assert_eq!(entries[0].alias, "foo");
874 }
875
876 #[test]
877 fn test_hostname_equals_not_parsed_as_host() {
878 let config = parse_str("Host myserver\n HostName=example.com\n");
880 let entries = config.host_entries();
881 assert_eq!(entries.len(), 1);
882 assert_eq!(entries[0].alias, "myserver");
883 assert_eq!(entries[0].hostname, "example.com");
884 }
885
886 #[test]
887 fn test_host_multi_pattern_with_inline_comment() {
888 let content = "Host prod staging # servers\n HostName 10.0.0.1\n";
892 let config = parse_str(content);
893 if let ConfigElement::HostBlock(block) = &config.elements[0] {
894 assert_eq!(block.host_pattern, "prod staging");
895 } else {
896 panic!("Expected HostBlock");
897 }
898 assert_eq!(config.host_entries().len(), 0);
900 }
901
902 #[test]
903 fn test_expand_env_vars_basic() {
904 unsafe { std::env::set_var("_PURPLE_TEST_VAR", "/custom/path") };
906 let result = SshConfigFile::expand_env_vars("${_PURPLE_TEST_VAR}/.ssh/config");
907 assert_eq!(result, "/custom/path/.ssh/config");
908 unsafe { std::env::remove_var("_PURPLE_TEST_VAR") };
909 }
910
911 #[test]
912 fn test_expand_env_vars_multiple() {
913 unsafe { std::env::set_var("_PURPLE_TEST_A", "hello") };
915 unsafe { std::env::set_var("_PURPLE_TEST_B", "world") };
916 let result = SshConfigFile::expand_env_vars("${_PURPLE_TEST_A}/${_PURPLE_TEST_B}");
917 assert_eq!(result, "hello/world");
918 unsafe { std::env::remove_var("_PURPLE_TEST_A") };
919 unsafe { std::env::remove_var("_PURPLE_TEST_B") };
920 }
921
922 #[test]
923 fn test_expand_env_vars_unknown_preserved() {
924 let result = SshConfigFile::expand_env_vars("${_PURPLE_NONEXISTENT_VAR}/path");
925 assert_eq!(result, "${_PURPLE_NONEXISTENT_VAR}/path");
926 }
927
928 #[test]
929 fn test_expand_env_vars_no_vars() {
930 let result = SshConfigFile::expand_env_vars("~/.ssh/config.d/*");
931 assert_eq!(result, "~/.ssh/config.d/*");
932 }
933
934 #[test]
935 fn test_expand_env_vars_unclosed_brace() {
936 let result = SshConfigFile::expand_env_vars("${UNCLOSED/path");
937 assert_eq!(result, "${UNCLOSED/path");
938 }
939
940 #[test]
941 fn test_expand_env_vars_dollar_without_brace() {
942 let result = SshConfigFile::expand_env_vars("$HOME/.ssh/config");
943 assert_eq!(result, "$HOME/.ssh/config");
945 }
946
947 #[test]
948 fn test_max_include_depth_matches_openssh() {
949 assert_eq!(MAX_INCLUDE_DEPTH, 16);
950 }
951
952 #[test]
953 fn test_split_include_patterns_single_unquoted() {
954 let result = SshConfigFile::split_include_patterns("config.d/*");
955 assert_eq!(result, vec!["config.d/*"]);
956 }
957
958 #[test]
959 fn test_split_include_patterns_quoted_with_spaces() {
960 let result = SshConfigFile::split_include_patterns("\"/path/with spaces/config\"");
961 assert_eq!(result, vec!["/path/with spaces/config"]);
962 }
963
964 #[test]
965 fn test_split_include_patterns_mixed() {
966 let result =
967 SshConfigFile::split_include_patterns("\"/path/with spaces/*\" ~/.ssh/config.d/*");
968 assert_eq!(result, vec!["/path/with spaces/*", "~/.ssh/config.d/*"]);
969 }
970
971 #[test]
972 fn test_split_include_patterns_quoted_no_spaces() {
973 let result = SshConfigFile::split_include_patterns("\"config.d/*\"");
974 assert_eq!(result, vec!["config.d/*"]);
975 }
976
977 #[test]
978 fn test_split_include_patterns_multiple_unquoted() {
979 let result = SshConfigFile::split_include_patterns("~/.ssh/conf.d/* /etc/ssh/config.d/*");
980 assert_eq!(result, vec!["~/.ssh/conf.d/*", "/etc/ssh/config.d/*"]);
981 }
982}