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 mut visited = std::collections::HashSet::new();
28 if let Ok(canonical) = std::fs::canonicalize(path) {
29 visited.insert(canonical);
30 }
31 Self::parse_with_depth_visited(path, depth, &mut visited)
32 }
33
34 fn parse_with_depth_visited(
35 path: &Path,
36 depth: usize,
37 visited: &mut std::collections::HashSet<PathBuf>,
38 ) -> Result<Self> {
39 let content = if path.exists() {
40 std::fs::read_to_string(path)
41 .with_context(|| format!("Failed to read SSH config at {}", path.display()))?
42 } else {
43 String::new()
44 };
45
46 let (bom, content) = match content.strip_prefix('\u{FEFF}') {
48 Some(stripped) => (true, stripped),
49 None => (false, content.as_str()),
50 };
51
52 let crlf = detect_crlf_majority(content);
53 let config_dir = path.parent().map(|p| p.to_path_buf());
58 let elements = Self::parse_content_with_includes(
59 content,
60 config_dir.as_deref(),
61 depth,
62 Some(path),
63 Some(visited),
64 );
65
66 let host_count = elements
67 .iter()
68 .filter(|e| matches!(e, super::model::ConfigElement::HostBlock(_)))
69 .count();
70 debug!(
71 "SSH config loaded: {} ({} hosts)",
72 path.display(),
73 host_count
74 );
75
76 Ok(SshConfigFile {
77 elements,
78 path: path.to_path_buf(),
79 crlf,
80 bom,
81 })
82 }
83
84 pub fn from_content(content: &str, synthetic_path: PathBuf) -> Self {
87 let elements =
88 Self::parse_content_with_includes(content, None, MAX_INCLUDE_DEPTH, None, None);
89 SshConfigFile {
90 elements,
91 path: synthetic_path,
92 crlf: false,
93 bom: false,
94 }
95 }
96
97 #[allow(dead_code)]
100 pub fn parse_content(content: &str) -> Vec<ConfigElement> {
101 Self::parse_content_with_includes(content, None, MAX_INCLUDE_DEPTH, None, None)
102 }
103
104 fn parse_content_with_includes(
110 content: &str,
111 config_dir: Option<&Path>,
112 depth: usize,
113 config_path: Option<&Path>,
114 mut visited: Option<&mut std::collections::HashSet<PathBuf>>,
115 ) -> Vec<ConfigElement> {
116 let mut elements = Vec::new();
117 let mut current_block: Option<HostBlock> = None;
118
119 for (line_idx, raw_line) in content.lines().enumerate() {
120 let line_num = line_idx + 1;
121 let line = raw_line.trim_end_matches('\r');
128 let trimmed = line.trim();
129
130 if let Some(pattern) = Self::parse_include_line(trimmed) {
135 if let Some(block) = current_block.take() {
136 elements.push(ConfigElement::HostBlock(block));
137 }
138 let resolved = if depth < MAX_INCLUDE_DEPTH {
139 Self::resolve_include(pattern, config_dir, depth, visited.as_deref_mut())
140 } else {
141 Vec::new()
142 };
143 elements.push(ConfigElement::Include(IncludeDirective {
144 raw_line: line.to_string(),
145 pattern: pattern.to_string(),
146 resolved_files: resolved,
147 }));
148 continue;
149 }
150
151 if Self::is_match_line(trimmed) {
156 if let Some(block) = current_block.take() {
157 elements.push(ConfigElement::HostBlock(block));
158 }
159 elements.push(ConfigElement::GlobalLine(line.to_string()));
160 continue;
161 }
162
163 let is_indented = line.starts_with(' ') || line.starts_with('\t');
166 if !is_indented && trimmed.starts_with("# purple:group ") {
167 if let Some(block) = current_block.take() {
168 elements.push(ConfigElement::HostBlock(block));
169 }
170 elements.push(ConfigElement::GlobalLine(line.to_string()));
171 continue;
172 }
173
174 if let Some(pattern) = Self::parse_host_line(trimmed) {
176 if let Some(block) = current_block.take() {
178 elements.push(ConfigElement::HostBlock(block));
179 }
180 current_block = Some(HostBlock {
181 host_pattern: pattern,
182 raw_host_line: line.to_string(),
183 directives: Vec::new(),
184 });
185 continue;
186 }
187
188 if let Some(ref mut block) = current_block {
190 if trimmed.is_empty() || trimmed.starts_with('#') {
191 block.directives.push(Directive {
193 key: String::new(),
194 value: String::new(),
195 raw_line: line.to_string(),
196 is_non_directive: true,
197 });
198 } else if let Some((key, value)) = Self::parse_directive(trimmed) {
199 block.directives.push(Directive {
200 key,
201 value,
202 raw_line: line.to_string(),
203 is_non_directive: false,
204 });
205 } else {
206 if let Some(p) = config_path {
208 debug!(
209 "[config] SSH config: unrecognized line {} in {}",
210 line_num,
211 p.display()
212 );
213 }
214 block.directives.push(Directive {
215 key: String::new(),
216 value: String::new(),
217 raw_line: line.to_string(),
218 is_non_directive: true,
219 });
220 }
221 } else {
222 elements.push(ConfigElement::GlobalLine(line.to_string()));
224 }
225 }
226
227 if let Some(block) = current_block {
229 elements.push(ConfigElement::HostBlock(block));
230 }
231
232 elements
233 }
234
235 fn parse_include_line(trimmed: &str) -> Option<&str> {
239 let bytes = trimmed.as_bytes();
240 if bytes.len() > 7 && bytes[..7].eq_ignore_ascii_case(b"include") {
242 let sep = bytes[7];
243 if sep.is_ascii_whitespace() || sep == b'=' {
244 let rest = trimmed[7..].trim_start();
247 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
248 if !rest.is_empty() {
249 return Some(rest);
250 }
251 }
252 }
253 None
254 }
255
256 pub(crate) fn split_include_patterns(pattern: &str) -> Vec<&str> {
259 let mut result = Vec::new();
260 let mut chars = pattern.char_indices().peekable();
261 while let Some(&(i, c)) = chars.peek() {
262 if c.is_whitespace() {
263 chars.next();
264 continue;
265 }
266 if c == '"' {
267 chars.next(); let start = i + 1;
269 let mut end = pattern.len();
270 for (j, ch) in chars.by_ref() {
271 if ch == '"' {
272 end = j;
273 break;
274 }
275 }
276 let token = &pattern[start..end];
277 if !token.is_empty() {
278 result.push(token);
279 }
280 } else {
281 let start = i;
282 let mut end = pattern.len();
283 for (j, ch) in chars.by_ref() {
284 if ch.is_whitespace() {
285 end = j;
286 break;
287 }
288 }
289 result.push(&pattern[start..end]);
290 }
291 }
292 result
293 }
294
295 fn resolve_include(
305 pattern: &str,
306 config_dir: Option<&Path>,
307 depth: usize,
308 mut visited: Option<&mut std::collections::HashSet<PathBuf>>,
309 ) -> Vec<IncludedFile> {
310 let mut files = Vec::new();
311 let mut seen = std::collections::HashSet::new();
312
313 for single in Self::split_include_patterns(pattern) {
314 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
315
316 let glob_pattern = if expanded.starts_with('/') {
318 expanded
319 } else if let Some(dir) = config_dir {
320 dir.join(&expanded).to_string_lossy().to_string()
321 } else {
322 continue;
323 };
324
325 if let Ok(paths) = glob::glob(&glob_pattern) {
326 let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
327 matched.sort();
328 for path in matched {
329 if path.is_file() && seen.insert(path.clone()) {
330 let canonical =
335 std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
336 if let Some(ref mut v) = visited {
337 if !v.insert(canonical) {
338 log::warn!(
339 "[config] Include cycle detected, skipping {} (already visited up the chain)",
340 path.display()
341 );
342 continue;
343 }
344 }
345 match std::fs::read_to_string(&path) {
346 Ok(content) => {
347 let content = content.strip_prefix('\u{FEFF}').unwrap_or(&content);
349 let elements = Self::parse_content_with_includes(
355 content,
356 config_dir,
357 depth + 1,
358 Some(&path),
359 visited.as_deref_mut(),
360 );
361 files.push(IncludedFile {
362 path: path.clone(),
363 elements,
364 });
365 }
366 Err(e) => {
367 log::warn!(
368 "[config] Could not read Include file {}: {}",
369 path.display(),
370 e
371 );
372 }
373 }
374 }
375 }
376 }
377 }
378 files
379 }
380
381 pub(crate) fn expand_tilde(pattern: &str) -> String {
383 if let Some(rest) = pattern.strip_prefix("~/") {
384 if let Some(home) = dirs::home_dir() {
385 return format!("{}/{}", home.display(), rest);
386 }
387 }
388 pattern.to_string()
389 }
390
391 pub(crate) fn expand_env_vars(s: &str) -> String {
394 let mut result = String::with_capacity(s.len());
395 let mut chars = s.char_indices().peekable();
396 while let Some((i, c)) = chars.next() {
397 if c == '$' {
398 if let Some(&(_, '{')) = chars.peek() {
399 chars.next(); if let Some(close) = s[i + 2..].find('}') {
401 let var_name = &s[i + 2..i + 2 + close];
402 if let Ok(val) = std::env::var(var_name) {
403 result.push_str(&val);
404 } else {
405 result.push_str(&s[i..i + 2 + close + 1]);
407 }
408 while let Some(&(j, _)) = chars.peek() {
410 if j <= i + 2 + close {
411 chars.next();
412 } else {
413 break;
414 }
415 }
416 continue;
417 }
418 result.push('$');
420 result.push('{');
421 continue;
422 }
423 }
424 result.push(c);
425 }
426 result
427 }
428
429 fn parse_host_line(trimmed: &str) -> Option<String> {
435 let bytes = trimmed.as_bytes();
436 if bytes.len() > 4 && bytes[..4].eq_ignore_ascii_case(b"host") {
438 let sep = bytes[4];
439 if sep.is_ascii_whitespace() || sep == b'=' {
440 let rest = trimmed[4..].trim_start();
444 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
445 let pattern = strip_inline_comment(rest).to_string();
446 if !pattern.is_empty() {
447 return Some(pattern);
448 }
449 }
450 }
451 None
452 }
453
454 fn is_match_line(trimmed: &str) -> bool {
458 let bytes = trimmed.as_bytes();
459 if bytes.len() > 5 && bytes[..5].eq_ignore_ascii_case(b"match") {
463 let sep = bytes[5];
464 return sep.is_ascii_whitespace() || sep == b'=';
465 }
466 false
467 }
468
469 fn parse_directive(trimmed: &str) -> Option<(String, String)> {
474 let key_end = trimmed.find(|c: char| c.is_whitespace() || c == '=')?;
476 let key = &trimmed[..key_end];
477 if key.is_empty() {
478 return None;
479 }
480
481 let rest = trimmed[key_end..].trim_start();
483 let rest = rest.strip_prefix('=').unwrap_or(rest);
484 let value = rest.trim_start();
485
486 let value = strip_inline_comment(value);
489
490 Some((key.to_string(), value.to_string()))
491 }
492}
493
494pub fn detect_crlf_majority(content: &str) -> bool {
499 let mut crlf_lines = 0usize;
500 let mut lf_lines = 0usize;
501 for line in content.split('\n') {
502 if line.ends_with('\r') {
503 crlf_lines += 1;
504 } else if !line.is_empty() {
505 lf_lines += 1;
506 }
507 }
508 crlf_lines > lf_lines
509}
510
511fn strip_inline_comment(value: &str) -> &str {
514 let bytes = value.as_bytes();
515 let mut in_quote = false;
516 for i in 0..bytes.len() {
517 if bytes[i] == b'"' {
518 in_quote = !in_quote;
519 } else if !in_quote
520 && bytes[i] == b'#'
521 && i > 0
522 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
523 {
524 return value[..i].trim_end();
525 }
526 }
527 value
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 #[allow(unused_imports)]
534 use std::path::PathBuf;
535
536 fn parse_str(content: &str) -> SshConfigFile {
537 SshConfigFile {
538 elements: SshConfigFile::parse_content(content),
539 path: tempfile::tempdir()
540 .expect("tempdir")
541 .keep()
542 .join("test_config"),
543 crlf: crate::ssh_config::parser::detect_crlf_majority(content),
544 bom: false,
545 }
546 }
547
548 #[test]
549 fn test_empty_config() {
550 let config = parse_str("");
551 assert!(config.host_entries().is_empty());
552 }
553
554 #[test]
555 fn test_basic_host() {
556 let config =
557 parse_str("Host myserver\n HostName 192.168.1.10\n User admin\n Port 2222\n");
558 let entries = config.host_entries();
559 assert_eq!(entries.len(), 1);
560 assert_eq!(entries[0].alias, "myserver");
561 assert_eq!(entries[0].hostname, "192.168.1.10");
562 assert_eq!(entries[0].user, "admin");
563 assert_eq!(entries[0].port, 2222);
564 }
565
566 #[test]
567 fn test_multiple_hosts() {
568 let content = "\
569Host alpha
570 HostName alpha.example.com
571 User deploy
572
573Host beta
574 HostName beta.example.com
575 User root
576 Port 22022
577";
578 let config = parse_str(content);
579 let entries = config.host_entries();
580 assert_eq!(entries.len(), 2);
581 assert_eq!(entries[0].alias, "alpha");
582 assert_eq!(entries[1].alias, "beta");
583 assert_eq!(entries[1].port, 22022);
584 }
585
586 #[test]
587 fn test_wildcard_host_filtered() {
588 let content = "\
589Host *
590 ServerAliveInterval 60
591
592Host myserver
593 HostName 10.0.0.1
594";
595 let config = parse_str(content);
596 let entries = config.host_entries();
597 assert_eq!(entries.len(), 1);
598 assert_eq!(entries[0].alias, "myserver");
599 }
600
601 #[test]
602 fn test_comments_preserved() {
603 let content = "\
604# Global comment
605Host myserver
606 # This is a comment
607 HostName 10.0.0.1
608 User admin
609";
610 let config = parse_str(content);
611 assert!(
613 matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment")
614 );
615 if let ConfigElement::HostBlock(block) = &config.elements[1] {
617 assert!(block.directives[0].is_non_directive);
618 assert_eq!(block.directives[0].raw_line, " # This is a comment");
619 } else {
620 panic!("Expected HostBlock");
621 }
622 }
623
624 #[test]
625 fn test_identity_file_and_proxy_jump() {
626 let content = "\
627Host bastion
628 HostName bastion.example.com
629 User admin
630 IdentityFile ~/.ssh/id_ed25519
631 ProxyJump gateway
632";
633 let config = parse_str(content);
634 let entries = config.host_entries();
635 assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
636 assert_eq!(entries[0].proxy_jump, "gateway");
637 }
638
639 #[test]
640 fn test_unknown_directives_preserved() {
641 let content = "\
642Host myserver
643 HostName 10.0.0.1
644 ForwardAgent yes
645 LocalForward 8080 localhost:80
646";
647 let config = parse_str(content);
648 if let ConfigElement::HostBlock(block) = &config.elements[0] {
649 assert_eq!(block.directives.len(), 3);
650 assert_eq!(block.directives[1].key, "ForwardAgent");
651 assert_eq!(block.directives[1].value, "yes");
652 assert_eq!(block.directives[2].key, "LocalForward");
653 } else {
654 panic!("Expected HostBlock");
655 }
656 }
657
658 #[test]
659 fn test_include_directive_parsed() {
660 let content = "\
661Include config.d/*
662
663Host myserver
664 HostName 10.0.0.1
665";
666 let config = parse_str(content);
667 assert!(
669 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*")
670 );
671 assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
673 assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
674 }
675
676 #[test]
677 fn test_include_round_trip() {
678 let content = "\
679Include ~/.ssh/config.d/*
680
681Host myserver
682 HostName 10.0.0.1
683";
684 let config = parse_str(content);
685 assert_eq!(config.serialize(), content);
686 }
687
688 #[test]
689 fn test_ssh_command() {
690 use crate::ssh_config::model::HostEntry;
691 use std::path::PathBuf;
692 let entry = HostEntry {
693 alias: "myserver".to_string(),
694 hostname: "10.0.0.1".to_string(),
695 ..Default::default()
696 };
697 let default_path = dirs::home_dir().unwrap().join(".ssh/config");
698 assert_eq!(entry.ssh_command(&default_path), "ssh -- 'myserver'");
699 let custom_path = PathBuf::from("/tmp/my_config");
700 assert_eq!(
701 entry.ssh_command(&custom_path),
702 "ssh -F '/tmp/my_config' -- 'myserver'"
703 );
704 }
705
706 #[test]
707 fn test_unicode_comment_no_panic() {
708 let content = "# abcde\u{00e9} test\n\nHost myserver\n HostName 10.0.0.1\n";
711 let config = parse_str(content);
712 let entries = config.host_entries();
713 assert_eq!(entries.len(), 1);
714 assert_eq!(entries[0].alias, "myserver");
715 }
716
717 #[test]
718 fn test_unicode_multibyte_line_no_panic() {
719 let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n HostName 10.0.0.1\n";
721 let config = parse_str(content);
722 let entries = config.host_entries();
723 assert_eq!(entries.len(), 1);
724 }
725
726 #[test]
727 fn test_host_with_tab_separator() {
728 let content = "Host\tmyserver\n HostName 10.0.0.1\n";
729 let config = parse_str(content);
730 let entries = config.host_entries();
731 assert_eq!(entries.len(), 1);
732 assert_eq!(entries[0].alias, "myserver");
733 }
734
735 #[test]
736 fn test_include_with_tab_separator() {
737 let content = "Include\tconfig.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
738 let config = parse_str(content);
739 assert!(
740 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
741 );
742 }
743
744 #[test]
745 fn test_include_with_equals_separator() {
746 let content = "Include=config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
747 let config = parse_str(content);
748 assert!(
749 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
750 );
751 }
752
753 #[test]
754 fn test_include_with_space_equals_separator() {
755 let content = "Include =config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
756 let config = parse_str(content);
757 assert!(
758 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
759 );
760 }
761
762 #[test]
763 fn test_include_with_space_equals_space_separator() {
764 let content = "Include = config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
765 let config = parse_str(content);
766 assert!(
767 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
768 );
769 }
770
771 #[test]
772 fn test_hostname_not_confused_with_host() {
773 let content = "Host myserver\n HostName example.com\n";
775 let config = parse_str(content);
776 let entries = config.host_entries();
777 assert_eq!(entries.len(), 1);
778 assert_eq!(entries[0].hostname, "example.com");
779 }
780
781 #[test]
782 fn test_equals_in_value_not_treated_as_separator() {
783 let content = "Host myserver\n IdentityFile ~/.ssh/id=prod\n";
784 let config = parse_str(content);
785 let entries = config.host_entries();
786 assert_eq!(entries.len(), 1);
787 assert_eq!(entries[0].identity_file, "~/.ssh/id=prod");
788 }
789
790 #[test]
791 fn test_equals_syntax_key_value() {
792 let content = "Host myserver\n HostName=10.0.0.1\n User = admin\n";
793 let config = parse_str(content);
794 let entries = config.host_entries();
795 assert_eq!(entries.len(), 1);
796 assert_eq!(entries[0].hostname, "10.0.0.1");
797 assert_eq!(entries[0].user, "admin");
798 }
799
800 #[test]
801 fn test_inline_comment_inside_quotes_preserved() {
802 let content = "Host myserver\n ProxyCommand ssh -W \"%h #test\" gateway\n";
803 let config = parse_str(content);
804 let entries = config.host_entries();
805 assert_eq!(entries.len(), 1);
806 if let ConfigElement::HostBlock(block) = &config.elements[0] {
808 let proxy_cmd = block
809 .directives
810 .iter()
811 .find(|d| d.key == "ProxyCommand")
812 .unwrap();
813 assert_eq!(proxy_cmd.value, "ssh -W \"%h #test\" gateway");
814 } else {
815 panic!("Expected HostBlock");
816 }
817 }
818
819 #[test]
820 fn test_inline_comment_outside_quotes_stripped() {
821 let content = "Host myserver\n HostName 10.0.0.1 # production\n";
822 let config = parse_str(content);
823 let entries = config.host_entries();
824 assert_eq!(entries[0].hostname, "10.0.0.1");
825 }
826
827 #[test]
828 fn test_host_inline_comment_stripped() {
829 let content = "Host alpha # this is a comment\n HostName 10.0.0.1\n";
830 let config = parse_str(content);
831 let entries = config.host_entries();
832 assert_eq!(entries.len(), 1);
833 assert_eq!(entries[0].alias, "alpha");
834 if let ConfigElement::HostBlock(block) = &config.elements[0] {
836 assert_eq!(block.raw_host_line, "Host alpha # this is a comment");
837 assert_eq!(block.host_pattern, "alpha");
838 } else {
839 panic!("Expected HostBlock");
840 }
841 }
842
843 #[test]
844 fn test_match_block_is_global_line() {
845 let content = "\
846Host myserver
847 HostName 10.0.0.1
848
849Match host *.example.com
850 ForwardAgent yes
851";
852 let config = parse_str(content);
853 let host_count = config
855 .elements
856 .iter()
857 .filter(|e| matches!(e, ConfigElement::HostBlock(_)))
858 .count();
859 assert_eq!(host_count, 1);
860 assert!(
862 config.elements.iter().any(
863 |e| matches!(e, ConfigElement::GlobalLine(s) if s == "Match host *.example.com")
864 )
865 );
866 assert!(
868 config
869 .elements
870 .iter()
871 .any(|e| matches!(e, ConfigElement::GlobalLine(s) if s.contains("ForwardAgent")))
872 );
873 }
874
875 #[test]
876 fn test_match_block_survives_host_deletion() {
877 let content = "\
878Host myserver
879 HostName 10.0.0.1
880
881Match host *.example.com
882 ForwardAgent yes
883
884Host other
885 HostName 10.0.0.2
886";
887 let mut config = parse_str(content);
888 config.delete_host("myserver");
889 let output = config.serialize();
890 assert!(output.contains("Match host *.example.com"));
891 assert!(output.contains("ForwardAgent yes"));
892 assert!(output.contains("Host other"));
893 assert!(!output.contains("Host myserver"));
894 }
895
896 #[test]
897 fn test_match_block_round_trip() {
898 let content = "\
899Host myserver
900 HostName 10.0.0.1
901
902Match host *.example.com
903 ForwardAgent yes
904";
905 let config = parse_str(content);
906 assert_eq!(config.serialize(), content);
907 }
908
909 #[test]
910 fn test_match_at_start_of_file() {
911 let content = "\
912Match all
913 ServerAliveInterval 60
914
915Host myserver
916 HostName 10.0.0.1
917";
918 let config = parse_str(content);
919 assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "Match all"));
920 assert!(
921 matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.contains("ServerAliveInterval"))
922 );
923 let entries = config.host_entries();
924 assert_eq!(entries.len(), 1);
925 assert_eq!(entries[0].alias, "myserver");
926 }
927
928 #[test]
929 fn test_host_equals_syntax() {
930 let config = parse_str("Host=foo\n HostName 10.0.0.1\n");
931 let entries = config.host_entries();
932 assert_eq!(entries.len(), 1);
933 assert_eq!(entries[0].alias, "foo");
934 }
935
936 #[test]
937 fn test_host_space_equals_syntax() {
938 let config = parse_str("Host =foo\n HostName 10.0.0.1\n");
939 let entries = config.host_entries();
940 assert_eq!(entries.len(), 1);
941 assert_eq!(entries[0].alias, "foo");
942 }
943
944 #[test]
945 fn test_host_equals_space_syntax() {
946 let config = parse_str("Host= foo\n HostName 10.0.0.1\n");
947 let entries = config.host_entries();
948 assert_eq!(entries.len(), 1);
949 assert_eq!(entries[0].alias, "foo");
950 }
951
952 #[test]
953 fn test_host_space_equals_space_syntax() {
954 let config = parse_str("Host = foo\n HostName 10.0.0.1\n");
955 let entries = config.host_entries();
956 assert_eq!(entries.len(), 1);
957 assert_eq!(entries[0].alias, "foo");
958 }
959
960 #[test]
961 fn test_host_equals_case_insensitive() {
962 let config = parse_str("HOST=foo\n HostName 10.0.0.1\n");
963 let entries = config.host_entries();
964 assert_eq!(entries.len(), 1);
965 assert_eq!(entries[0].alias, "foo");
966 }
967
968 #[test]
969 fn test_hostname_equals_not_parsed_as_host() {
970 let config = parse_str("Host myserver\n HostName=example.com\n");
972 let entries = config.host_entries();
973 assert_eq!(entries.len(), 1);
974 assert_eq!(entries[0].alias, "myserver");
975 assert_eq!(entries[0].hostname, "example.com");
976 }
977
978 #[test]
979 fn test_host_multi_pattern_with_inline_comment() {
980 let content = "Host prod staging # servers\n HostName 10.0.0.1\n";
984 let config = parse_str(content);
985 if let ConfigElement::HostBlock(block) = &config.elements[0] {
986 assert_eq!(block.host_pattern, "prod staging");
987 } else {
988 panic!("Expected HostBlock");
989 }
990 assert_eq!(config.host_entries().len(), 0);
992 }
993
994 #[test]
995 fn test_expand_env_vars_basic() {
996 unsafe { std::env::set_var("_PURPLE_TEST_VAR", "/custom/path") };
998 let result = SshConfigFile::expand_env_vars("${_PURPLE_TEST_VAR}/.ssh/config");
999 assert_eq!(result, "/custom/path/.ssh/config");
1000 unsafe { std::env::remove_var("_PURPLE_TEST_VAR") };
1001 }
1002
1003 #[test]
1004 fn test_expand_env_vars_multiple() {
1005 unsafe { std::env::set_var("_PURPLE_TEST_A", "hello") };
1007 unsafe { std::env::set_var("_PURPLE_TEST_B", "world") };
1008 let result = SshConfigFile::expand_env_vars("${_PURPLE_TEST_A}/${_PURPLE_TEST_B}");
1009 assert_eq!(result, "hello/world");
1010 unsafe { std::env::remove_var("_PURPLE_TEST_A") };
1011 unsafe { std::env::remove_var("_PURPLE_TEST_B") };
1012 }
1013
1014 #[test]
1015 fn test_expand_env_vars_unknown_preserved() {
1016 let result = SshConfigFile::expand_env_vars("${_PURPLE_NONEXISTENT_VAR}/path");
1017 assert_eq!(result, "${_PURPLE_NONEXISTENT_VAR}/path");
1018 }
1019
1020 #[test]
1021 fn test_expand_env_vars_no_vars() {
1022 let result = SshConfigFile::expand_env_vars("~/.ssh/config.d/*");
1023 assert_eq!(result, "~/.ssh/config.d/*");
1024 }
1025
1026 #[test]
1027 fn test_expand_env_vars_unclosed_brace() {
1028 let result = SshConfigFile::expand_env_vars("${UNCLOSED/path");
1029 assert_eq!(result, "${UNCLOSED/path");
1030 }
1031
1032 #[test]
1033 fn test_expand_env_vars_dollar_without_brace() {
1034 let result = SshConfigFile::expand_env_vars("$HOME/.ssh/config");
1035 assert_eq!(result, "$HOME/.ssh/config");
1037 }
1038
1039 #[test]
1040 fn test_max_include_depth_matches_openssh() {
1041 assert_eq!(MAX_INCLUDE_DEPTH, 16);
1042 }
1043
1044 #[test]
1045 fn test_split_include_patterns_single_unquoted() {
1046 let result = SshConfigFile::split_include_patterns("config.d/*");
1047 assert_eq!(result, vec!["config.d/*"]);
1048 }
1049
1050 #[test]
1051 fn test_split_include_patterns_quoted_with_spaces() {
1052 let result = SshConfigFile::split_include_patterns("\"/path/with spaces/config\"");
1053 assert_eq!(result, vec!["/path/with spaces/config"]);
1054 }
1055
1056 #[test]
1057 fn test_split_include_patterns_mixed() {
1058 let result =
1059 SshConfigFile::split_include_patterns("\"/path/with spaces/*\" ~/.ssh/config.d/*");
1060 assert_eq!(result, vec!["/path/with spaces/*", "~/.ssh/config.d/*"]);
1061 }
1062
1063 #[test]
1064 fn test_split_include_patterns_quoted_no_spaces() {
1065 let result = SshConfigFile::split_include_patterns("\"config.d/*\"");
1066 assert_eq!(result, vec!["config.d/*"]);
1067 }
1068
1069 #[test]
1070 fn test_split_include_patterns_multiple_unquoted() {
1071 let result = SshConfigFile::split_include_patterns("~/.ssh/conf.d/* /etc/ssh/config.d/*");
1072 assert_eq!(result, vec!["~/.ssh/conf.d/*", "/etc/ssh/config.d/*"]);
1073 }
1074}