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