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