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> {
19 Self::parse_with_env(path, &crate::runtime::env::Env::from_process())
20 }
21
22 pub fn parse_with_env(path: &Path, env: &crate::runtime::env::Env) -> Result<Self> {
26 Self::parse_with_depth(path, 0, &|n| env.var(n).map(str::to_string))
27 }
28
29 fn parse_with_depth(
30 path: &Path,
31 depth: usize,
32 lookup: &dyn Fn(&str) -> Option<String>,
33 ) -> Result<Self> {
34 let mut visited = std::collections::HashSet::new();
42 if let Ok(canonical) = std::fs::canonicalize(path) {
43 visited.insert(canonical);
44 }
45 Self::parse_with_depth_visited(path, depth, &mut visited, lookup)
46 }
47
48 fn parse_with_depth_visited(
49 path: &Path,
50 depth: usize,
51 visited: &mut std::collections::HashSet<PathBuf>,
52 lookup: &dyn Fn(&str) -> Option<String>,
53 ) -> Result<Self> {
54 let content = if path.exists() {
55 std::fs::read_to_string(path)
56 .with_context(|| format!("Failed to read SSH config at {}", path.display()))?
57 } else {
58 String::new()
59 };
60
61 let (bom, content) = match content.strip_prefix('\u{FEFF}') {
63 Some(stripped) => (true, stripped),
64 None => (false, content.as_str()),
65 };
66
67 let crlf = detect_crlf_majority(content);
68 let config_dir = path.parent().map(|p| p.to_path_buf());
73 let elements = Self::parse_content_with_includes(
74 content,
75 config_dir.as_deref(),
76 depth,
77 Some(path),
78 Some(visited),
79 lookup,
80 );
81
82 let host_count = elements
83 .iter()
84 .filter(|e| matches!(e, super::model::ConfigElement::HostBlock(_)))
85 .count();
86 debug!(
87 "[config] SSH config loaded: {} ({} hosts)",
88 path.display(),
89 host_count
90 );
91
92 Ok(SshConfigFile {
93 elements,
94 path: path.to_path_buf(),
95 crlf,
96 bom,
97 })
98 }
99
100 pub fn from_content(content: &str, synthetic_path: PathBuf) -> Self {
103 let elements = Self::parse_content_with_includes(
104 content,
105 None,
106 MAX_INCLUDE_DEPTH,
107 None,
108 None,
109 &|_: &str| None,
110 );
111 SshConfigFile {
112 elements,
113 path: synthetic_path,
114 crlf: false,
115 bom: false,
116 }
117 }
118
119 #[allow(dead_code)]
122 pub fn parse_content(content: &str) -> Vec<ConfigElement> {
123 Self::parse_content_with_includes(
124 content,
125 None,
126 MAX_INCLUDE_DEPTH,
127 None,
128 None,
129 &|_: &str| None,
130 )
131 }
132
133 fn parse_content_with_includes(
139 content: &str,
140 config_dir: Option<&Path>,
141 depth: usize,
142 config_path: Option<&Path>,
143 mut visited: Option<&mut std::collections::HashSet<PathBuf>>,
144 lookup: &dyn Fn(&str) -> Option<String>,
145 ) -> Vec<ConfigElement> {
146 let mut elements = Vec::new();
147 let mut current_block: Option<HostBlock> = None;
148
149 for (line_idx, raw_line) in content.lines().enumerate() {
150 let line_num = line_idx + 1;
151 let line = raw_line.trim_end_matches('\r');
158 let trimmed = line.trim();
159
160 if let Some(pattern) = Self::parse_include_line(trimmed) {
165 if let Some(block) = current_block.take() {
166 elements.push(ConfigElement::HostBlock(block));
167 }
168 let resolved = if depth < MAX_INCLUDE_DEPTH {
169 Self::resolve_include(
170 pattern,
171 config_dir,
172 depth,
173 visited.as_deref_mut(),
174 lookup,
175 )
176 } else {
177 Vec::new()
178 };
179 elements.push(ConfigElement::Include(IncludeDirective {
180 raw_line: line.to_string(),
181 pattern: pattern.to_string(),
182 resolved_files: resolved,
183 }));
184 continue;
185 }
186
187 if Self::is_match_line(trimmed) {
192 if let Some(block) = current_block.take() {
193 elements.push(ConfigElement::HostBlock(block));
194 }
195 elements.push(ConfigElement::GlobalLine(line.to_string()));
196 continue;
197 }
198
199 let is_indented = line.starts_with(' ') || line.starts_with('\t');
202 if !is_indented && trimmed.starts_with("# purple:group ") {
203 if let Some(block) = current_block.take() {
204 elements.push(ConfigElement::HostBlock(block));
205 }
206 elements.push(ConfigElement::GlobalLine(line.to_string()));
207 continue;
208 }
209
210 if let Some(pattern) = Self::parse_host_line(trimmed) {
212 if let Some(block) = current_block.take() {
214 elements.push(ConfigElement::HostBlock(block));
215 }
216 current_block = Some(HostBlock {
217 host_pattern: pattern,
218 raw_host_line: line.to_string(),
219 directives: Vec::new(),
220 });
221 continue;
222 }
223
224 if let Some(ref mut block) = current_block {
226 if trimmed.is_empty() || trimmed.starts_with('#') {
227 block.directives.push(Directive {
229 key: String::new(),
230 value: String::new(),
231 raw_line: line.to_string(),
232 is_non_directive: true,
233 });
234 } else if let Some((key, value)) = Self::parse_directive(trimmed) {
235 block.directives.push(Directive {
236 key,
237 value,
238 raw_line: line.to_string(),
239 is_non_directive: false,
240 });
241 } else {
242 if let Some(p) = config_path {
244 debug!(
245 "[config] SSH config: unrecognized line {} in {}",
246 line_num,
247 p.display()
248 );
249 }
250 block.directives.push(Directive {
251 key: String::new(),
252 value: String::new(),
253 raw_line: line.to_string(),
254 is_non_directive: true,
255 });
256 }
257 } else {
258 elements.push(ConfigElement::GlobalLine(line.to_string()));
260 }
261 }
262
263 if let Some(block) = current_block {
265 elements.push(ConfigElement::HostBlock(block));
266 }
267
268 elements
269 }
270
271 fn parse_include_line(trimmed: &str) -> Option<&str> {
275 let bytes = trimmed.as_bytes();
276 if bytes.len() > 7 && bytes[..7].eq_ignore_ascii_case(b"include") {
278 let sep = bytes[7];
279 if sep.is_ascii_whitespace() || sep == b'=' {
280 let rest = trimmed[7..].trim_start();
283 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
284 if !rest.is_empty() {
285 return Some(rest);
286 }
287 }
288 }
289 None
290 }
291
292 pub(crate) fn split_include_patterns(pattern: &str) -> Vec<&str> {
295 let mut result = Vec::new();
296 let mut chars = pattern.char_indices().peekable();
297 while let Some(&(i, c)) = chars.peek() {
298 if c.is_whitespace() {
299 chars.next();
300 continue;
301 }
302 if c == '"' {
303 chars.next(); let start = i + 1;
305 let mut end = pattern.len();
306 for (j, ch) in chars.by_ref() {
307 if ch == '"' {
308 end = j;
309 break;
310 }
311 }
312 let token = &pattern[start..end];
313 if !token.is_empty() {
314 result.push(token);
315 }
316 } else {
317 let start = i;
318 let mut end = pattern.len();
319 for (j, ch) in chars.by_ref() {
320 if ch.is_whitespace() {
321 end = j;
322 break;
323 }
324 }
325 result.push(&pattern[start..end]);
326 }
327 }
328 result
329 }
330
331 fn resolve_include(
341 pattern: &str,
342 config_dir: Option<&Path>,
343 depth: usize,
344 mut visited: Option<&mut std::collections::HashSet<PathBuf>>,
345 lookup: &dyn Fn(&str) -> Option<String>,
346 ) -> Vec<IncludedFile> {
347 let mut files = Vec::new();
348 let mut seen = std::collections::HashSet::new();
349
350 for single in Self::split_include_patterns(pattern) {
351 let expanded = Self::expand_env_vars_with(&Self::expand_tilde(single, lookup), lookup);
352
353 let glob_pattern = if expanded.starts_with('/') {
355 expanded
356 } else if let Some(dir) = config_dir {
357 dir.join(&expanded).to_string_lossy().to_string()
358 } else {
359 continue;
360 };
361
362 if let Ok(paths) = glob::glob(&glob_pattern) {
363 let mut matched: Vec<PathBuf> = paths.filter_map(|p| p.ok()).collect();
364 matched.sort();
365 for path in matched {
366 if path.is_file() && seen.insert(path.clone()) {
367 let canonical =
372 std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
373 if let Some(ref mut v) = visited {
374 if !v.insert(canonical) {
375 log::warn!(
376 "[config] Include cycle detected, skipping {} (already visited up the chain)",
377 path.display()
378 );
379 continue;
380 }
381 }
382 match std::fs::read_to_string(&path) {
383 Ok(content) => {
384 let content = content.strip_prefix('\u{FEFF}').unwrap_or(&content);
386 let elements = Self::parse_content_with_includes(
392 content,
393 config_dir,
394 depth + 1,
395 Some(&path),
396 visited.as_deref_mut(),
397 lookup,
398 );
399 files.push(IncludedFile {
400 path: path.clone(),
401 elements,
402 });
403 }
404 Err(e) => {
405 log::warn!(
406 "[config] Could not read Include file {}: {}",
407 path.display(),
408 e
409 );
410 }
411 }
412 }
413 }
414 }
415 }
416 files
417 }
418
419 pub(crate) fn expand_tilde(pattern: &str, lookup: &dyn Fn(&str) -> Option<String>) -> String {
423 if let Some(rest) = pattern.strip_prefix("~/") {
424 if let Some(home) = lookup("HOME") {
425 return format!("{}/{}", home, rest);
426 }
427 }
428 pattern.to_string()
429 }
430
431 #[cfg(test)]
435 pub(crate) fn expand_env_vars(s: &str) -> String {
436 Self::expand_env_vars_with(s, &|n| std::env::var(n).ok())
437 }
438
439 pub(crate) fn expand_env_vars_with(s: &str, lookup: &dyn Fn(&str) -> Option<String>) -> String {
443 let mut result = String::with_capacity(s.len());
444 let mut chars = s.char_indices().peekable();
445 while let Some((i, c)) = chars.next() {
446 if c == '$' {
447 if let Some(&(_, '{')) = chars.peek() {
448 chars.next(); if let Some(close) = s[i + 2..].find('}') {
450 let var_name = &s[i + 2..i + 2 + close];
451 if let Some(val) = lookup(var_name) {
452 result.push_str(&val);
453 } else {
454 result.push_str(&s[i..i + 2 + close + 1]);
456 }
457 while let Some(&(j, _)) = chars.peek() {
459 if j <= i + 2 + close {
460 chars.next();
461 } else {
462 break;
463 }
464 }
465 continue;
466 }
467 result.push('$');
469 result.push('{');
470 continue;
471 }
472 }
473 result.push(c);
474 }
475 result
476 }
477
478 fn parse_host_line(trimmed: &str) -> Option<String> {
484 let bytes = trimmed.as_bytes();
485 if bytes.len() > 4 && bytes[..4].eq_ignore_ascii_case(b"host") {
487 let sep = bytes[4];
488 if sep.is_ascii_whitespace() || sep == b'=' {
489 let rest = trimmed[4..].trim_start();
493 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
494 let pattern = strip_inline_comment(rest).to_string();
495 if !pattern.is_empty() {
496 return Some(pattern);
497 }
498 }
499 }
500 None
501 }
502
503 fn is_match_line(trimmed: &str) -> bool {
507 let bytes = trimmed.as_bytes();
508 if bytes.len() > 5 && bytes[..5].eq_ignore_ascii_case(b"match") {
512 let sep = bytes[5];
513 return sep.is_ascii_whitespace() || sep == b'=';
514 }
515 false
516 }
517
518 fn parse_directive(trimmed: &str) -> Option<(String, String)> {
523 let key_end = trimmed.find(|c: char| c.is_whitespace() || c == '=')?;
525 let key = &trimmed[..key_end];
526 if key.is_empty() {
527 return None;
528 }
529
530 let rest = trimmed[key_end..].trim_start();
532 let rest = rest.strip_prefix('=').unwrap_or(rest);
533 let value = rest.trim_start();
534
535 let value = strip_inline_comment(value);
538 let value = strip_surrounding_quotes(value);
543
544 Some((key.to_string(), value.to_string()))
545 }
546}
547
548fn strip_surrounding_quotes(value: &str) -> std::borrow::Cow<'_, str> {
555 let bytes = value.as_bytes();
556 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
557 return std::borrow::Cow::Borrowed(value);
558 }
559 let interior = &value[1..value.len() - 1];
560 let mut out = String::with_capacity(interior.len());
561 let mut chars = interior.chars();
562 while let Some(c) = chars.next() {
563 match c {
564 '\\' => match chars.next() {
565 Some('\\') => out.push('\\'),
566 Some('"') => out.push('"'),
567 Some(other) => {
570 out.push('\\');
571 out.push(other);
572 }
573 None => out.push('\\'),
574 },
575 '"' => return std::borrow::Cow::Borrowed(value),
578 _ => out.push(c),
579 }
580 }
581 std::borrow::Cow::Owned(out)
582}
583
584pub fn detect_crlf_majority(content: &str) -> bool {
589 let mut crlf_lines = 0usize;
590 let mut lf_lines = 0usize;
591 for line in content.split('\n') {
592 if line.ends_with('\r') {
593 crlf_lines += 1;
594 } else if !line.is_empty() {
595 lf_lines += 1;
596 }
597 }
598 crlf_lines > lf_lines
599}
600
601fn strip_inline_comment(value: &str) -> &str {
604 let bytes = value.as_bytes();
605 let mut in_quote = false;
606 for i in 0..bytes.len() {
607 if bytes[i] == b'"' {
608 in_quote = !in_quote;
609 } else if !in_quote
610 && bytes[i] == b'#'
611 && i > 0
612 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
613 {
614 return value[..i].trim_end();
615 }
616 }
617 value
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 #[allow(unused_imports)]
624 use std::path::PathBuf;
625
626 fn parse_str(content: &str) -> SshConfigFile {
627 SshConfigFile {
628 elements: SshConfigFile::parse_content(content),
629 path: tempfile::tempdir()
630 .expect("tempdir")
631 .keep()
632 .join("test_config"),
633 crlf: crate::ssh_config::parser::detect_crlf_majority(content),
634 bom: false,
635 }
636 }
637
638 #[test]
639 fn test_empty_config() {
640 let config = parse_str("");
641 assert!(config.host_entries().is_empty());
642 }
643
644 #[test]
645 fn test_basic_host() {
646 let config =
647 parse_str("Host myserver\n HostName 192.168.1.10\n User admin\n Port 2222\n");
648 let entries = config.host_entries();
649 assert_eq!(entries.len(), 1);
650 assert_eq!(entries[0].alias, "myserver");
651 assert_eq!(entries[0].hostname, "192.168.1.10");
652 assert_eq!(entries[0].user, "admin");
653 assert_eq!(entries[0].port, 2222);
654 }
655
656 #[test]
657 fn test_multiple_hosts() {
658 let content = "\
659Host alpha
660 HostName alpha.example.com
661 User deploy
662
663Host beta
664 HostName beta.example.com
665 User root
666 Port 22022
667";
668 let config = parse_str(content);
669 let entries = config.host_entries();
670 assert_eq!(entries.len(), 2);
671 assert_eq!(entries[0].alias, "alpha");
672 assert_eq!(entries[1].alias, "beta");
673 assert_eq!(entries[1].port, 22022);
674 }
675
676 #[test]
677 fn test_wildcard_host_filtered() {
678 let content = "\
679Host *
680 ServerAliveInterval 60
681
682Host myserver
683 HostName 10.0.0.1
684";
685 let config = parse_str(content);
686 let entries = config.host_entries();
687 assert_eq!(entries.len(), 1);
688 assert_eq!(entries[0].alias, "myserver");
689 }
690
691 #[test]
692 fn test_comments_preserved() {
693 let content = "\
694# Global comment
695Host myserver
696 # This is a comment
697 HostName 10.0.0.1
698 User admin
699";
700 let config = parse_str(content);
701 assert!(
703 matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global comment")
704 );
705 if let ConfigElement::HostBlock(block) = &config.elements[1] {
707 assert!(block.directives[0].is_non_directive);
708 assert_eq!(block.directives[0].raw_line, " # This is a comment");
709 } else {
710 panic!("Expected HostBlock");
711 }
712 }
713
714 #[test]
715 fn proxy_command_sets_has_proxy_command_unless_none() {
716 let content = "Host a\n ProxyCommand tsh proxy ssh %r@%h:%p\n\n\
717 Host b\n proxycommand none\n\n\
718 Host c\n HostName 10.0.0.1\n\n\
719 Host d\n ProxyCommand none\n ProxyCommand nc %h %p\n";
720 let config = parse_str(content);
721 let entries = config.host_entries();
722 let by_alias = |alias: &str| entries.iter().find(|e| e.alias == alias).unwrap();
723 assert!(by_alias("a").has_proxy_command);
724 assert!(!by_alias("b").has_proxy_command);
725 assert!(!by_alias("c").has_proxy_command);
726 assert!(!by_alias("d").has_proxy_command);
728 }
729
730 #[test]
731 fn test_identity_file_and_proxy_jump() {
732 let content = "\
733Host bastion
734 HostName bastion.example.com
735 User admin
736 IdentityFile ~/.ssh/id_ed25519
737 ProxyJump gateway
738";
739 let config = parse_str(content);
740 let entries = config.host_entries();
741 assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519");
742 assert_eq!(entries[0].proxy_jump, "gateway");
743 }
744
745 #[test]
746 fn test_unknown_directives_preserved() {
747 let content = "\
748Host myserver
749 HostName 10.0.0.1
750 ForwardAgent yes
751 LocalForward 8080 localhost:80
752";
753 let config = parse_str(content);
754 if let ConfigElement::HostBlock(block) = &config.elements[0] {
755 assert_eq!(block.directives.len(), 3);
756 assert_eq!(block.directives[1].key, "ForwardAgent");
757 assert_eq!(block.directives[1].value, "yes");
758 assert_eq!(block.directives[2].key, "LocalForward");
759 } else {
760 panic!("Expected HostBlock");
761 }
762 }
763
764 #[test]
765 fn test_include_directive_parsed() {
766 let content = "\
767Include config.d/*
768
769Host myserver
770 HostName 10.0.0.1
771";
772 let config = parse_str(content);
773 assert!(
775 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include config.d/*")
776 );
777 assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
779 assert!(matches!(&config.elements[2], ConfigElement::HostBlock(_)));
780 }
781
782 #[test]
783 fn test_include_round_trip() {
784 let content = "\
785Include ~/.ssh/config.d/*
786
787Host myserver
788 HostName 10.0.0.1
789";
790 let config = parse_str(content);
791 assert_eq!(config.serialize(), content);
792 }
793
794 #[test]
795 fn test_ssh_command() {
796 use crate::ssh_config::model::HostEntry;
797 use std::path::PathBuf;
798 let entry = HostEntry {
799 alias: "myserver".to_string(),
800 hostname: "10.0.0.1".to_string(),
801 ..Default::default()
802 };
803 let paths = crate::runtime::env::Paths::new("/home/testuser");
804 let default_path = paths.ssh_dir().join("config");
805 assert_eq!(
806 entry.ssh_command(Some(&paths), &default_path),
807 "ssh -- 'myserver'"
808 );
809 let custom_path = PathBuf::from("/tmp/my_config");
810 assert_eq!(
811 entry.ssh_command(Some(&paths), &custom_path),
812 "ssh -F '/tmp/my_config' -- 'myserver'"
813 );
814 assert_eq!(
815 entry.ssh_command_with("kitten ssh", Some(&paths), &default_path),
816 "kitten ssh -- 'myserver'"
817 );
818 assert_eq!(
819 entry.ssh_command_with("kitten ssh", Some(&paths), &custom_path),
820 "kitten ssh -F '/tmp/my_config' -- 'myserver'"
821 );
822 }
823
824 #[test]
825 fn test_unicode_comment_no_panic() {
826 let content = "# abcde\u{00e9} test\n\nHost myserver\n HostName 10.0.0.1\n";
829 let config = parse_str(content);
830 let entries = config.host_entries();
831 assert_eq!(entries.len(), 1);
832 assert_eq!(entries[0].alias, "myserver");
833 }
834
835 #[test]
836 fn test_unicode_multibyte_line_no_panic() {
837 let content = "# \u{3042}\u{3042}\u{3042}xyz\n\nHost myserver\n HostName 10.0.0.1\n";
839 let config = parse_str(content);
840 let entries = config.host_entries();
841 assert_eq!(entries.len(), 1);
842 }
843
844 #[test]
845 fn test_host_with_tab_separator() {
846 let content = "Host\tmyserver\n HostName 10.0.0.1\n";
847 let config = parse_str(content);
848 let entries = config.host_entries();
849 assert_eq!(entries.len(), 1);
850 assert_eq!(entries[0].alias, "myserver");
851 }
852
853 #[test]
854 fn test_include_with_tab_separator() {
855 let content = "Include\tconfig.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
856 let config = parse_str(content);
857 assert!(
858 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
859 );
860 }
861
862 #[test]
863 fn test_include_with_equals_separator() {
864 let content = "Include=config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
865 let config = parse_str(content);
866 assert!(
867 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
868 );
869 }
870
871 #[test]
872 fn test_include_with_space_equals_separator() {
873 let content = "Include =config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
874 let config = parse_str(content);
875 assert!(
876 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
877 );
878 }
879
880 #[test]
881 fn test_include_with_space_equals_space_separator() {
882 let content = "Include = config.d/*\n\nHost myserver\n HostName 10.0.0.1\n";
883 let config = parse_str(content);
884 assert!(
885 matches!(&config.elements[0], ConfigElement::Include(inc) if inc.pattern == "config.d/*")
886 );
887 }
888
889 #[test]
890 fn test_hostname_not_confused_with_host() {
891 let content = "Host myserver\n HostName example.com\n";
893 let config = parse_str(content);
894 let entries = config.host_entries();
895 assert_eq!(entries.len(), 1);
896 assert_eq!(entries[0].hostname, "example.com");
897 }
898
899 #[test]
900 fn test_equals_in_value_not_treated_as_separator() {
901 let content = "Host myserver\n IdentityFile ~/.ssh/id=prod\n";
902 let config = parse_str(content);
903 let entries = config.host_entries();
904 assert_eq!(entries.len(), 1);
905 assert_eq!(entries[0].identity_file, "~/.ssh/id=prod");
906 }
907
908 #[test]
909 fn test_equals_syntax_key_value() {
910 let content = "Host myserver\n HostName=10.0.0.1\n User = admin\n";
911 let config = parse_str(content);
912 let entries = config.host_entries();
913 assert_eq!(entries.len(), 1);
914 assert_eq!(entries[0].hostname, "10.0.0.1");
915 assert_eq!(entries[0].user, "admin");
916 }
917
918 #[test]
919 fn test_inline_comment_inside_quotes_preserved() {
920 let content = "Host myserver\n ProxyCommand ssh -W \"%h #test\" gateway\n";
921 let config = parse_str(content);
922 let entries = config.host_entries();
923 assert_eq!(entries.len(), 1);
924 if let ConfigElement::HostBlock(block) = &config.elements[0] {
926 let proxy_cmd = block
927 .directives
928 .iter()
929 .find(|d| d.key == "ProxyCommand")
930 .unwrap();
931 assert_eq!(proxy_cmd.value, "ssh -W \"%h #test\" gateway");
932 } else {
933 panic!("Expected HostBlock");
934 }
935 }
936
937 #[test]
938 fn test_inline_comment_outside_quotes_stripped() {
939 let content = "Host myserver\n HostName 10.0.0.1 # production\n";
940 let config = parse_str(content);
941 let entries = config.host_entries();
942 assert_eq!(entries[0].hostname, "10.0.0.1");
943 }
944
945 #[test]
946 fn test_surrounding_quotes_stripped_from_single_token() {
947 let config = parse_str("Host h\n IdentityFile \"~/my key/id\"\n");
951 if let ConfigElement::HostBlock(block) = &config.elements[0] {
952 let d = block
953 .directives
954 .iter()
955 .find(|d| d.key == "IdentityFile")
956 .expect("IdentityFile directive");
957 assert_eq!(d.value, "~/my key/id");
958 } else {
959 panic!("Expected HostBlock");
960 }
961 }
962
963 #[test]
964 fn test_internal_quotes_not_stripped() {
965 let config = parse_str("Host h\n ProxyCommand ssh -W \"%h:%p\" gw\n");
968 if let ConfigElement::HostBlock(block) = &config.elements[0] {
969 let d = block
970 .directives
971 .iter()
972 .find(|d| d.key == "ProxyCommand")
973 .expect("ProxyCommand directive");
974 assert_eq!(d.value, "ssh -W \"%h:%p\" gw");
975 } else {
976 panic!("Expected HostBlock");
977 }
978 }
979
980 #[test]
981 fn test_host_inline_comment_stripped() {
982 let content = "Host alpha # this is a comment\n HostName 10.0.0.1\n";
983 let config = parse_str(content);
984 let entries = config.host_entries();
985 assert_eq!(entries.len(), 1);
986 assert_eq!(entries[0].alias, "alpha");
987 if let ConfigElement::HostBlock(block) = &config.elements[0] {
989 assert_eq!(block.raw_host_line, "Host alpha # this is a comment");
990 assert_eq!(block.host_pattern, "alpha");
991 } else {
992 panic!("Expected HostBlock");
993 }
994 }
995
996 #[test]
997 fn test_match_block_is_global_line() {
998 let content = "\
999Host myserver
1000 HostName 10.0.0.1
1001
1002Match host *.example.com
1003 ForwardAgent yes
1004";
1005 let config = parse_str(content);
1006 let host_count = config
1008 .elements
1009 .iter()
1010 .filter(|e| matches!(e, ConfigElement::HostBlock(_)))
1011 .count();
1012 assert_eq!(host_count, 1);
1013 assert!(
1015 config.elements.iter().any(
1016 |e| matches!(e, ConfigElement::GlobalLine(s) if s == "Match host *.example.com")
1017 )
1018 );
1019 assert!(
1021 config
1022 .elements
1023 .iter()
1024 .any(|e| matches!(e, ConfigElement::GlobalLine(s) if s.contains("ForwardAgent")))
1025 );
1026 }
1027
1028 #[test]
1029 fn test_match_block_survives_host_deletion() {
1030 let content = "\
1031Host myserver
1032 HostName 10.0.0.1
1033
1034Match host *.example.com
1035 ForwardAgent yes
1036
1037Host other
1038 HostName 10.0.0.2
1039";
1040 let mut config = parse_str(content);
1041 config.delete_host("myserver");
1042 let output = config.serialize();
1043 assert!(output.contains("Match host *.example.com"));
1044 assert!(output.contains("ForwardAgent yes"));
1045 assert!(output.contains("Host other"));
1046 assert!(!output.contains("Host myserver"));
1047 }
1048
1049 #[test]
1050 fn test_match_block_round_trip() {
1051 let content = "\
1052Host myserver
1053 HostName 10.0.0.1
1054
1055Match host *.example.com
1056 ForwardAgent yes
1057";
1058 let config = parse_str(content);
1059 assert_eq!(config.serialize(), content);
1060 }
1061
1062 #[test]
1063 fn test_match_at_start_of_file() {
1064 let content = "\
1065Match all
1066 ServerAliveInterval 60
1067
1068Host myserver
1069 HostName 10.0.0.1
1070";
1071 let config = parse_str(content);
1072 assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "Match all"));
1073 assert!(
1074 matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.contains("ServerAliveInterval"))
1075 );
1076 let entries = config.host_entries();
1077 assert_eq!(entries.len(), 1);
1078 assert_eq!(entries[0].alias, "myserver");
1079 }
1080
1081 #[test]
1082 fn test_host_equals_syntax() {
1083 let config = parse_str("Host=foo\n HostName 10.0.0.1\n");
1084 let entries = config.host_entries();
1085 assert_eq!(entries.len(), 1);
1086 assert_eq!(entries[0].alias, "foo");
1087 }
1088
1089 #[test]
1090 fn test_host_space_equals_syntax() {
1091 let config = parse_str("Host =foo\n HostName 10.0.0.1\n");
1092 let entries = config.host_entries();
1093 assert_eq!(entries.len(), 1);
1094 assert_eq!(entries[0].alias, "foo");
1095 }
1096
1097 #[test]
1098 fn test_host_equals_space_syntax() {
1099 let config = parse_str("Host= foo\n HostName 10.0.0.1\n");
1100 let entries = config.host_entries();
1101 assert_eq!(entries.len(), 1);
1102 assert_eq!(entries[0].alias, "foo");
1103 }
1104
1105 #[test]
1106 fn test_host_space_equals_space_syntax() {
1107 let config = parse_str("Host = foo\n HostName 10.0.0.1\n");
1108 let entries = config.host_entries();
1109 assert_eq!(entries.len(), 1);
1110 assert_eq!(entries[0].alias, "foo");
1111 }
1112
1113 #[test]
1114 fn test_host_equals_case_insensitive() {
1115 let config = parse_str("HOST=foo\n HostName 10.0.0.1\n");
1116 let entries = config.host_entries();
1117 assert_eq!(entries.len(), 1);
1118 assert_eq!(entries[0].alias, "foo");
1119 }
1120
1121 #[test]
1122 fn test_hostname_equals_not_parsed_as_host() {
1123 let config = parse_str("Host myserver\n HostName=example.com\n");
1125 let entries = config.host_entries();
1126 assert_eq!(entries.len(), 1);
1127 assert_eq!(entries[0].alias, "myserver");
1128 assert_eq!(entries[0].hostname, "example.com");
1129 }
1130
1131 #[test]
1132 fn test_host_multi_pattern_with_inline_comment() {
1133 let content = "Host prod staging # servers\n HostName 10.0.0.1\n";
1137 let config = parse_str(content);
1138 if let ConfigElement::HostBlock(block) = &config.elements[0] {
1139 assert_eq!(block.host_pattern, "prod staging");
1140 } else {
1141 panic!("Expected HostBlock");
1142 }
1143 assert_eq!(config.host_entries().len(), 0);
1145 }
1146
1147 #[test]
1148 fn test_expand_env_vars_basic() {
1149 let result =
1150 SshConfigFile::expand_env_vars_with("${_PURPLE_TEST_VAR}/.ssh/config", &|name| {
1151 match name {
1152 "_PURPLE_TEST_VAR" => Some("/custom/path".to_string()),
1153 _ => None,
1154 }
1155 });
1156 assert_eq!(result, "/custom/path/.ssh/config");
1157 }
1158
1159 #[test]
1160 fn test_expand_env_vars_multiple() {
1161 let result =
1162 SshConfigFile::expand_env_vars_with("${_PURPLE_TEST_A}/${_PURPLE_TEST_B}", &|name| {
1163 match name {
1164 "_PURPLE_TEST_A" => Some("hello".to_string()),
1165 "_PURPLE_TEST_B" => Some("world".to_string()),
1166 _ => None,
1167 }
1168 });
1169 assert_eq!(result, "hello/world");
1170 }
1171
1172 #[test]
1173 fn test_expand_env_vars_unknown_preserved() {
1174 let result = SshConfigFile::expand_env_vars("${_PURPLE_NONEXISTENT_VAR}/path");
1175 assert_eq!(result, "${_PURPLE_NONEXISTENT_VAR}/path");
1176 }
1177
1178 #[test]
1179 fn test_expand_env_vars_no_vars() {
1180 let result = SshConfigFile::expand_env_vars("~/.ssh/config.d/*");
1181 assert_eq!(result, "~/.ssh/config.d/*");
1182 }
1183
1184 #[test]
1185 fn test_expand_env_vars_unclosed_brace() {
1186 let result = SshConfigFile::expand_env_vars("${UNCLOSED/path");
1187 assert_eq!(result, "${UNCLOSED/path");
1188 }
1189
1190 #[test]
1191 fn test_expand_env_vars_dollar_without_brace() {
1192 let result = SshConfigFile::expand_env_vars("$HOME/.ssh/config");
1193 assert_eq!(result, "$HOME/.ssh/config");
1195 }
1196
1197 #[test]
1198 fn test_max_include_depth_matches_openssh() {
1199 assert_eq!(MAX_INCLUDE_DEPTH, 16);
1200 }
1201
1202 #[test]
1203 fn test_split_include_patterns_single_unquoted() {
1204 let result = SshConfigFile::split_include_patterns("config.d/*");
1205 assert_eq!(result, vec!["config.d/*"]);
1206 }
1207
1208 #[test]
1209 fn test_split_include_patterns_quoted_with_spaces() {
1210 let result = SshConfigFile::split_include_patterns("\"/path/with spaces/config\"");
1211 assert_eq!(result, vec!["/path/with spaces/config"]);
1212 }
1213
1214 #[test]
1215 fn test_split_include_patterns_mixed() {
1216 let result =
1217 SshConfigFile::split_include_patterns("\"/path/with spaces/*\" ~/.ssh/config.d/*");
1218 assert_eq!(result, vec!["/path/with spaces/*", "~/.ssh/config.d/*"]);
1219 }
1220
1221 #[test]
1222 fn test_split_include_patterns_quoted_no_spaces() {
1223 let result = SshConfigFile::split_include_patterns("\"config.d/*\"");
1224 assert_eq!(result, vec!["config.d/*"]);
1225 }
1226
1227 #[test]
1228 fn test_split_include_patterns_multiple_unquoted() {
1229 let result = SshConfigFile::split_include_patterns("~/.ssh/conf.d/* /etc/ssh/config.d/*");
1230 assert_eq!(result, vec!["~/.ssh/conf.d/*", "/etc/ssh/config.d/*"]);
1231 }
1232}