1use std::path::PathBuf;
2
3#[derive(Debug, Clone)]
6pub struct SshConfigFile {
7 pub elements: Vec<ConfigElement>,
8 pub path: PathBuf,
9 pub crlf: bool,
11}
12
13#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub struct IncludeDirective {
17 pub raw_line: String,
18 pub pattern: String,
19 pub resolved_files: Vec<IncludedFile>,
20}
21
22#[derive(Debug, Clone)]
24pub struct IncludedFile {
25 pub path: PathBuf,
26 pub elements: Vec<ConfigElement>,
27}
28
29#[derive(Debug, Clone)]
31pub enum ConfigElement {
32 HostBlock(HostBlock),
34 GlobalLine(String),
36 Include(IncludeDirective),
38}
39
40#[derive(Debug, Clone)]
42pub struct HostBlock {
43 pub host_pattern: String,
45 pub raw_host_line: String,
47 pub directives: Vec<Directive>,
49}
50
51#[derive(Debug, Clone)]
53pub struct Directive {
54 pub key: String,
56 pub value: String,
58 pub raw_line: String,
60 pub is_non_directive: bool,
62}
63
64#[derive(Debug, Clone)]
66pub struct HostEntry {
67 pub alias: String,
68 pub hostname: String,
69 pub user: String,
70 pub port: u16,
71 pub identity_file: String,
72 pub proxy_jump: String,
73 pub source_file: Option<PathBuf>,
75 pub tags: Vec<String>,
77 pub provider: Option<String>,
79 pub tunnel_count: u16,
81}
82
83impl Default for HostEntry {
84 fn default() -> Self {
85 Self {
86 alias: String::new(),
87 hostname: String::new(),
88 user: String::new(),
89 port: 22,
90 identity_file: String::new(),
91 proxy_jump: String::new(),
92 source_file: None,
93 tags: Vec::new(),
94 provider: None,
95 tunnel_count: 0,
96 }
97 }
98}
99
100impl HostEntry {
101 pub fn ssh_command(&self) -> String {
104 let escaped = self.alias.replace('\'', "'\\''");
105 format!("ssh -- '{}'", escaped)
106 }
107}
108
109pub fn is_host_pattern(pattern: &str) -> bool {
113 pattern.contains('*')
114 || pattern.contains('?')
115 || pattern.contains('[')
116 || pattern.starts_with('!')
117 || pattern.contains(' ')
118 || pattern.contains('\t')
119}
120
121impl HostBlock {
122 fn content_end(&self) -> usize {
124 let mut pos = self.directives.len();
125 while pos > 0 {
126 if self.directives[pos - 1].is_non_directive
127 && self.directives[pos - 1].raw_line.trim().is_empty()
128 {
129 pos -= 1;
130 } else {
131 break;
132 }
133 }
134 pos
135 }
136
137 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
139 let end = self.content_end();
140 self.directives.drain(end..).collect()
141 }
142
143 fn ensure_trailing_blank(&mut self) {
145 self.pop_trailing_blanks();
146 self.directives.push(Directive {
147 key: String::new(),
148 value: String::new(),
149 raw_line: String::new(),
150 is_non_directive: true,
151 });
152 }
153
154 fn detect_indent(&self) -> String {
156 for d in &self.directives {
157 if !d.is_non_directive && !d.raw_line.is_empty() {
158 let trimmed = d.raw_line.trim_start();
159 let indent_len = d.raw_line.len() - trimmed.len();
160 if indent_len > 0 {
161 return d.raw_line[..indent_len].to_string();
162 }
163 }
164 }
165 " ".to_string()
166 }
167
168 pub fn tags(&self) -> Vec<String> {
170 for d in &self.directives {
171 if d.is_non_directive {
172 let trimmed = d.raw_line.trim();
173 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
174 return rest
175 .split(',')
176 .map(|t| t.trim().to_string())
177 .filter(|t| !t.is_empty())
178 .collect();
179 }
180 }
181 }
182 Vec::new()
183 }
184
185 pub fn provider(&self) -> Option<(String, String)> {
188 for d in &self.directives {
189 if d.is_non_directive {
190 let trimmed = d.raw_line.trim();
191 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
192 if let Some((name, id)) = rest.split_once(':') {
193 return Some((name.trim().to_string(), id.trim().to_string()));
194 }
195 }
196 }
197 }
198 None
199 }
200
201 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
203 let indent = self.detect_indent();
204 self.directives.retain(|d| {
205 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider"))
206 });
207 let pos = self.content_end();
208 self.directives.insert(
209 pos,
210 Directive {
211 key: String::new(),
212 value: String::new(),
213 raw_line: format!("{}# purple:provider {}:{}", indent, provider_name, server_id),
214 is_non_directive: true,
215 },
216 );
217 }
218
219 pub fn set_tags(&mut self, tags: &[String]) {
221 let indent = self.detect_indent();
222 self.directives.retain(|d| {
223 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
224 });
225 if !tags.is_empty() {
226 let pos = self.content_end();
227 self.directives.insert(
228 pos,
229 Directive {
230 key: String::new(),
231 value: String::new(),
232 raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
233 is_non_directive: true,
234 },
235 );
236 }
237 }
238
239 pub fn to_host_entry(&self) -> HostEntry {
241 let mut entry = HostEntry {
242 alias: self.host_pattern.clone(),
243 port: 22,
244 ..Default::default()
245 };
246 for d in &self.directives {
247 if d.is_non_directive {
248 continue;
249 }
250 if d.key.eq_ignore_ascii_case("hostname") {
251 entry.hostname = d.value.clone();
252 } else if d.key.eq_ignore_ascii_case("user") {
253 entry.user = d.value.clone();
254 } else if d.key.eq_ignore_ascii_case("port") {
255 entry.port = d.value.parse().unwrap_or(22);
256 } else if d.key.eq_ignore_ascii_case("identityfile") {
257 if entry.identity_file.is_empty() {
258 entry.identity_file = d.value.clone();
259 }
260 } else if d.key.eq_ignore_ascii_case("proxyjump") {
261 entry.proxy_jump = d.value.clone();
262 }
263 }
264 entry.tags = self.tags();
265 entry.provider = self.provider().map(|(name, _)| name);
266 entry.tunnel_count = self.tunnel_count();
267 entry
268 }
269
270 pub fn tunnel_count(&self) -> u16 {
272 let count = self
273 .directives
274 .iter()
275 .filter(|d| {
276 !d.is_non_directive
277 && (d.key.eq_ignore_ascii_case("localforward")
278 || d.key.eq_ignore_ascii_case("remoteforward")
279 || d.key.eq_ignore_ascii_case("dynamicforward"))
280 })
281 .count();
282 count.min(u16::MAX as usize) as u16
283 }
284
285 #[allow(dead_code)]
287 pub fn has_tunnels(&self) -> bool {
288 self.directives.iter().any(|d| {
289 !d.is_non_directive
290 && (d.key.eq_ignore_ascii_case("localforward")
291 || d.key.eq_ignore_ascii_case("remoteforward")
292 || d.key.eq_ignore_ascii_case("dynamicforward"))
293 })
294 }
295
296 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
298 self.directives
299 .iter()
300 .filter(|d| !d.is_non_directive)
301 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
302 .collect()
303 }
304}
305
306impl SshConfigFile {
307 pub fn host_entries(&self) -> Vec<HostEntry> {
309 let mut entries = Vec::new();
310 Self::collect_host_entries(&self.elements, &mut entries);
311 entries
312 }
313
314 pub fn include_paths(&self) -> Vec<PathBuf> {
316 let mut paths = Vec::new();
317 Self::collect_include_paths(&self.elements, &mut paths);
318 paths
319 }
320
321 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
322 for e in elements {
323 if let ConfigElement::Include(include) = e {
324 for file in &include.resolved_files {
325 paths.push(file.path.clone());
326 Self::collect_include_paths(&file.elements, paths);
327 }
328 }
329 }
330 }
331
332 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
335 let config_dir = self.path.parent();
336 let mut seen = std::collections::HashSet::new();
337 let mut dirs = Vec::new();
338 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
339 dirs
340 }
341
342 fn collect_include_glob_dirs(
343 elements: &[ConfigElement],
344 config_dir: Option<&std::path::Path>,
345 seen: &mut std::collections::HashSet<PathBuf>,
346 dirs: &mut Vec<PathBuf>,
347 ) {
348 for e in elements {
349 if let ConfigElement::Include(include) = e {
350 for single in include.pattern.split_whitespace() {
353 let expanded = Self::expand_tilde(single);
354 let resolved = if expanded.starts_with('/') {
355 PathBuf::from(&expanded)
356 } else if let Some(dir) = config_dir {
357 dir.join(&expanded)
358 } else {
359 continue;
360 };
361 if let Some(parent) = resolved.parent() {
362 let parent = parent.to_path_buf();
363 if seen.insert(parent.clone()) {
364 dirs.push(parent);
365 }
366 }
367 }
368 for file in &include.resolved_files {
370 Self::collect_include_glob_dirs(
371 &file.elements,
372 file.path.parent(),
373 seen,
374 dirs,
375 );
376 }
377 }
378 }
379 }
380
381
382 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
384 for e in elements {
385 match e {
386 ConfigElement::HostBlock(block) => {
387 if is_host_pattern(&block.host_pattern) {
388 continue;
389 }
390 entries.push(block.to_host_entry());
391 }
392 ConfigElement::Include(include) => {
393 for file in &include.resolved_files {
394 let start = entries.len();
395 Self::collect_host_entries(&file.elements, entries);
396 for entry in &mut entries[start..] {
397 if entry.source_file.is_none() {
398 entry.source_file = Some(file.path.clone());
399 }
400 }
401 }
402 }
403 ConfigElement::GlobalLine(_) => {}
404 }
405 }
406 }
407
408 pub fn has_host(&self, alias: &str) -> bool {
411 Self::has_host_in_elements(&self.elements, alias)
412 }
413
414 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
415 for e in elements {
416 match e {
417 ConfigElement::HostBlock(block) => {
418 if block.host_pattern.split_whitespace().any(|p| p == alias) {
419 return true;
420 }
421 }
422 ConfigElement::Include(include) => {
423 for file in &include.resolved_files {
424 if Self::has_host_in_elements(&file.elements, alias) {
425 return true;
426 }
427 }
428 }
429 ConfigElement::GlobalLine(_) => {}
430 }
431 }
432 false
433 }
434
435 pub fn is_included_host(&self, alias: &str) -> bool {
438 for e in &self.elements {
440 match e {
441 ConfigElement::HostBlock(block) => {
442 if block.host_pattern.split_whitespace().any(|p| p == alias) {
443 return false;
444 }
445 }
446 ConfigElement::Include(include) => {
447 for file in &include.resolved_files {
448 if Self::has_host_in_elements(&file.elements, alias) {
449 return true;
450 }
451 }
452 }
453 ConfigElement::GlobalLine(_) => {}
454 }
455 }
456 false
457 }
458
459 pub fn add_host(&mut self, entry: &HostEntry) {
461 let block = Self::entry_to_block(entry);
462 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
464 self.elements
465 .push(ConfigElement::GlobalLine(String::new()));
466 }
467 self.elements.push(ConfigElement::HostBlock(block));
468 }
469
470 pub fn last_element_has_trailing_blank(&self) -> bool {
472 match self.elements.last() {
473 Some(ConfigElement::HostBlock(block)) => block
474 .directives
475 .last()
476 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
477 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
478 _ => false,
479 }
480 }
481
482 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
485 for element in &mut self.elements {
486 if let ConfigElement::HostBlock(block) = element {
487 if block.host_pattern == old_alias {
488 if entry.alias != block.host_pattern {
490 block.host_pattern = entry.alias.clone();
491 block.raw_host_line = format!("Host {}", entry.alias);
492 }
493
494 Self::upsert_directive(block, "HostName", &entry.hostname);
496 Self::upsert_directive(block, "User", &entry.user);
497 if entry.port != 22 {
498 Self::upsert_directive(block, "Port", &entry.port.to_string());
499 } else {
500 block
502 .directives
503 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
504 }
505 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
506 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
507 return;
508 }
509 }
510 }
511 }
512
513 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
515 if value.is_empty() {
516 block
517 .directives
518 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
519 return;
520 }
521 let indent = block.detect_indent();
522 for d in &mut block.directives {
523 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
524 if d.value != value {
526 d.value = value.to_string();
527 let trimmed = d.raw_line.trim_start();
533 let after_key = &trimmed[d.key.len()..];
534 let sep = if after_key.trim_start().starts_with('=') {
535 let eq_pos = after_key.find('=').unwrap();
536 let after_eq = &after_key[eq_pos + 1..];
537 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
538 after_key[..eq_pos + 1 + trailing_ws].to_string()
539 } else {
540 " ".to_string()
541 };
542 d.raw_line = format!("{}{}{}{}", indent, d.key, sep, value);
543 }
544 return;
545 }
546 }
547 let pos = block.content_end();
549 block.directives.insert(
550 pos,
551 Directive {
552 key: key.to_string(),
553 value: value.to_string(),
554 raw_line: format!("{}{} {}", indent, key, value),
555 is_non_directive: false,
556 },
557 );
558 }
559
560 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
562 for element in &mut self.elements {
563 if let ConfigElement::HostBlock(block) = element {
564 if block.host_pattern == alias {
565 block.set_provider(provider_name, server_id);
566 return;
567 }
568 }
569 }
570 }
571
572 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
576 let mut results = Vec::new();
577 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
578 results
579 }
580
581 fn collect_provider_hosts(
582 elements: &[ConfigElement],
583 provider_name: &str,
584 results: &mut Vec<(String, String)>,
585 ) {
586 for element in elements {
587 match element {
588 ConfigElement::HostBlock(block) => {
589 if let Some((name, id)) = block.provider() {
590 if name == provider_name {
591 results.push((block.host_pattern.clone(), id));
592 }
593 }
594 }
595 ConfigElement::Include(include) => {
596 for file in &include.resolved_files {
597 Self::collect_provider_hosts(&file.elements, provider_name, results);
598 }
599 }
600 ConfigElement::GlobalLine(_) => {}
601 }
602 }
603 }
604
605 fn values_match(a: &str, b: &str) -> bool {
608 a.split_whitespace().eq(b.split_whitespace())
609 }
610
611 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
615 for element in &mut self.elements {
616 if let ConfigElement::HostBlock(block) = element {
617 if block.host_pattern.split_whitespace().any(|p| p == alias) {
618 let indent = block.detect_indent();
619 let pos = block.content_end();
620 block.directives.insert(
621 pos,
622 Directive {
623 key: directive_key.to_string(),
624 value: value.to_string(),
625 raw_line: format!("{}{} {}", indent, directive_key, value),
626 is_non_directive: false,
627 },
628 );
629 return;
630 }
631 }
632 }
633 }
634
635 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
640 for element in &mut self.elements {
641 if let ConfigElement::HostBlock(block) = element {
642 if block.host_pattern.split_whitespace().any(|p| p == alias) {
643 if let Some(pos) = block.directives.iter().position(|d| {
644 !d.is_non_directive
645 && d.key.eq_ignore_ascii_case(directive_key)
646 && Self::values_match(&d.value, value)
647 }) {
648 block.directives.remove(pos);
649 return true;
650 }
651 return false;
652 }
653 }
654 }
655 false
656 }
657
658 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
661 for element in &self.elements {
662 if let ConfigElement::HostBlock(block) = element {
663 if block.host_pattern.split_whitespace().any(|p| p == alias) {
664 return block.directives.iter().any(|d| {
665 !d.is_non_directive
666 && d.key.eq_ignore_ascii_case(directive_key)
667 && Self::values_match(&d.value, value)
668 });
669 }
670 }
671 }
672 false
673 }
674
675 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
679 Self::find_tunnel_directives_in(&self.elements, alias)
680 }
681
682 fn find_tunnel_directives_in(
683 elements: &[ConfigElement],
684 alias: &str,
685 ) -> Vec<crate::tunnel::TunnelRule> {
686 for element in elements {
687 match element {
688 ConfigElement::HostBlock(block) => {
689 if block.host_pattern.split_whitespace().any(|p| p == alias) {
690 return block.tunnel_directives();
691 }
692 }
693 ConfigElement::Include(include) => {
694 for file in &include.resolved_files {
695 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
696 if !rules.is_empty() {
697 return rules;
698 }
699 }
700 }
701 ConfigElement::GlobalLine(_) => {}
702 }
703 }
704 Vec::new()
705 }
706
707 pub fn deduplicate_alias(&self, base: &str) -> String {
709 self.deduplicate_alias_excluding(base, None)
710 }
711
712 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
715 let is_taken = |alias: &str| {
716 if exclude == Some(alias) {
717 return false;
718 }
719 self.has_host(alias)
720 };
721 if !is_taken(base) {
722 return base.to_string();
723 }
724 for n in 2..=9999 {
725 let candidate = format!("{}-{}", base, n);
726 if !is_taken(&candidate) {
727 return candidate;
728 }
729 }
730 format!("{}-{}", base, std::process::id())
732 }
733
734 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
736 for element in &mut self.elements {
737 if let ConfigElement::HostBlock(block) = element {
738 if block.host_pattern == alias {
739 block.set_tags(tags);
740 return;
741 }
742 }
743 }
744 }
745
746 #[allow(dead_code)]
748 pub fn delete_host(&mut self, alias: &str) {
749 self.elements.retain(|e| match e {
750 ConfigElement::HostBlock(block) => block.host_pattern != alias,
751 _ => true,
752 });
753 self.elements.dedup_by(|a, b| {
755 matches!(
756 (&*a, &*b),
757 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
758 if x.trim().is_empty() && y.trim().is_empty()
759 )
760 });
761 }
762
763 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
766 let pos = self.elements.iter().position(|e| {
767 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
768 })?;
769 let element = self.elements.remove(pos);
770 Some((element, pos))
771 }
772
773 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
775 let pos = position.min(self.elements.len());
776 self.elements.insert(pos, element);
777 }
778
779 #[allow(dead_code)]
781 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
782 let pos_a = self.elements.iter().position(|e| {
783 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
784 });
785 let pos_b = self.elements.iter().position(|e| {
786 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
787 });
788 if let (Some(a), Some(b)) = (pos_a, pos_b) {
789 if a == b {
790 return false;
791 }
792 let (first, second) = (a.min(b), a.max(b));
793
794 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
796 block.pop_trailing_blanks();
797 }
798 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
799 block.pop_trailing_blanks();
800 }
801
802 self.elements.swap(first, second);
804
805 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
807 block.ensure_trailing_blank();
808 }
809
810 if second < self.elements.len() - 1 {
812 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
813 block.ensure_trailing_blank();
814 }
815 }
816
817 return true;
818 }
819 false
820 }
821
822 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
824 let mut directives = Vec::new();
825
826 if !entry.hostname.is_empty() {
827 directives.push(Directive {
828 key: "HostName".to_string(),
829 value: entry.hostname.clone(),
830 raw_line: format!(" HostName {}", entry.hostname),
831 is_non_directive: false,
832 });
833 }
834 if !entry.user.is_empty() {
835 directives.push(Directive {
836 key: "User".to_string(),
837 value: entry.user.clone(),
838 raw_line: format!(" User {}", entry.user),
839 is_non_directive: false,
840 });
841 }
842 if entry.port != 22 {
843 directives.push(Directive {
844 key: "Port".to_string(),
845 value: entry.port.to_string(),
846 raw_line: format!(" Port {}", entry.port),
847 is_non_directive: false,
848 });
849 }
850 if !entry.identity_file.is_empty() {
851 directives.push(Directive {
852 key: "IdentityFile".to_string(),
853 value: entry.identity_file.clone(),
854 raw_line: format!(" IdentityFile {}", entry.identity_file),
855 is_non_directive: false,
856 });
857 }
858 if !entry.proxy_jump.is_empty() {
859 directives.push(Directive {
860 key: "ProxyJump".to_string(),
861 value: entry.proxy_jump.clone(),
862 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
863 is_non_directive: false,
864 });
865 }
866
867 HostBlock {
868 host_pattern: entry.alias.clone(),
869 raw_host_line: format!("Host {}", entry.alias),
870 directives,
871 }
872 }
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878
879 fn parse_str(content: &str) -> SshConfigFile {
880 SshConfigFile {
881 elements: SshConfigFile::parse_content(content),
882 path: PathBuf::from("/tmp/test_config"),
883 crlf: false,
884 }
885 }
886
887 #[test]
888 fn tunnel_directives_extracts_forwards() {
889 let config = parse_str(
890 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
891 );
892 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
893 let rules = block.tunnel_directives();
894 assert_eq!(rules.len(), 3);
895 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
896 assert_eq!(rules[0].bind_port, 8080);
897 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
898 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
899 } else {
900 panic!("Expected HostBlock");
901 }
902 }
903
904 #[test]
905 fn tunnel_count_counts_forwards() {
906 let config = parse_str(
907 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n",
908 );
909 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
910 assert_eq!(block.tunnel_count(), 2);
911 } else {
912 panic!("Expected HostBlock");
913 }
914 }
915
916 #[test]
917 fn tunnel_count_zero_for_no_forwards() {
918 let config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
919 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
920 assert_eq!(block.tunnel_count(), 0);
921 assert!(!block.has_tunnels());
922 } else {
923 panic!("Expected HostBlock");
924 }
925 }
926
927 #[test]
928 fn has_tunnels_true_with_forward() {
929 let config = parse_str("Host myserver\n HostName 10.0.0.1\n DynamicForward 1080\n");
930 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
931 assert!(block.has_tunnels());
932 } else {
933 panic!("Expected HostBlock");
934 }
935 }
936
937 #[test]
938 fn add_forward_inserts_directive() {
939 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
940 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
941 let output = config.serialize();
942 assert!(output.contains("LocalForward 8080 localhost:80"));
943 assert!(output.contains("HostName 10.0.0.1"));
945 assert!(output.contains("User admin"));
946 }
947
948 #[test]
949 fn add_forward_preserves_indentation() {
950 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
951 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
952 let output = config.serialize();
953 assert!(output.contains("\tLocalForward 8080 localhost:80"));
954 }
955
956 #[test]
957 fn add_multiple_forwards_same_type() {
958 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
959 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
960 config.add_forward("myserver", "LocalForward", "9090 localhost:90");
961 let output = config.serialize();
962 assert!(output.contains("LocalForward 8080 localhost:80"));
963 assert!(output.contains("LocalForward 9090 localhost:90"));
964 }
965
966 #[test]
967 fn remove_forward_removes_exact_match() {
968 let mut config = parse_str(
969 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
970 );
971 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
972 let output = config.serialize();
973 assert!(!output.contains("8080 localhost:80"));
974 assert!(output.contains("9090 localhost:90"));
975 }
976
977 #[test]
978 fn remove_forward_leaves_other_directives() {
979 let mut config = parse_str(
980 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n User admin\n",
981 );
982 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
983 let output = config.serialize();
984 assert!(!output.contains("LocalForward"));
985 assert!(output.contains("HostName 10.0.0.1"));
986 assert!(output.contains("User admin"));
987 }
988
989 #[test]
990 fn remove_forward_no_match_is_noop() {
991 let original = "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n";
992 let mut config = parse_str(original);
993 config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
994 assert_eq!(config.serialize(), original);
995 }
996
997 #[test]
998 fn host_entry_tunnel_count_populated() {
999 let config = parse_str(
1000 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n DynamicForward 1080\n",
1001 );
1002 let entries = config.host_entries();
1003 assert_eq!(entries.len(), 1);
1004 assert_eq!(entries[0].tunnel_count, 2);
1005 }
1006
1007 #[test]
1008 fn remove_forward_returns_true_on_match() {
1009 let mut config = parse_str(
1010 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1011 );
1012 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1013 }
1014
1015 #[test]
1016 fn remove_forward_returns_false_on_no_match() {
1017 let mut config = parse_str(
1018 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1019 );
1020 assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
1021 }
1022
1023 #[test]
1024 fn remove_forward_returns_false_for_unknown_host() {
1025 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1026 assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
1027 }
1028
1029 #[test]
1030 fn has_forward_finds_match() {
1031 let config = parse_str(
1032 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1033 );
1034 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1035 }
1036
1037 #[test]
1038 fn has_forward_no_match() {
1039 let config = parse_str(
1040 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1041 );
1042 assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
1043 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1044 }
1045
1046 #[test]
1047 fn has_forward_case_insensitive_key() {
1048 let config = parse_str(
1049 "Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n",
1050 );
1051 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1052 }
1053
1054 #[test]
1055 fn add_forward_to_empty_block() {
1056 let mut config = parse_str("Host myserver\n");
1057 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1058 let output = config.serialize();
1059 assert!(output.contains("LocalForward 8080 localhost:80"));
1060 }
1061
1062 #[test]
1063 fn remove_forward_case_insensitive_key_match() {
1064 let mut config = parse_str(
1065 "Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n",
1066 );
1067 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1068 assert!(!config.serialize().contains("localforward"));
1069 }
1070
1071 #[test]
1072 fn tunnel_count_case_insensitive() {
1073 let config = parse_str(
1074 "Host myserver\n localforward 8080 localhost:80\n REMOTEFORWARD 9090 localhost:90\n dynamicforward 1080\n",
1075 );
1076 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1077 assert_eq!(block.tunnel_count(), 3);
1078 } else {
1079 panic!("Expected HostBlock");
1080 }
1081 }
1082
1083 #[test]
1084 fn tunnel_directives_extracts_all_types() {
1085 let config = parse_str(
1086 "Host myserver\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1087 );
1088 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1089 let rules = block.tunnel_directives();
1090 assert_eq!(rules.len(), 3);
1091 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1092 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1093 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1094 } else {
1095 panic!("Expected HostBlock");
1096 }
1097 }
1098
1099 #[test]
1100 fn tunnel_directives_skips_malformed() {
1101 let config = parse_str(
1102 "Host myserver\n LocalForward not_valid\n DynamicForward 1080\n",
1103 );
1104 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1105 let rules = block.tunnel_directives();
1106 assert_eq!(rules.len(), 1);
1107 assert_eq!(rules[0].bind_port, 1080);
1108 } else {
1109 panic!("Expected HostBlock");
1110 }
1111 }
1112
1113 #[test]
1114 fn find_tunnel_directives_multi_pattern_host() {
1115 let config = parse_str(
1116 "Host prod staging\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1117 );
1118 let rules = config.find_tunnel_directives("prod");
1119 assert_eq!(rules.len(), 1);
1120 assert_eq!(rules[0].bind_port, 8080);
1121 let rules2 = config.find_tunnel_directives("staging");
1122 assert_eq!(rules2.len(), 1);
1123 }
1124
1125 #[test]
1126 fn find_tunnel_directives_no_match() {
1127 let config = parse_str(
1128 "Host myserver\n LocalForward 8080 localhost:80\n",
1129 );
1130 let rules = config.find_tunnel_directives("nohost");
1131 assert!(rules.is_empty());
1132 }
1133
1134 #[test]
1135 fn has_forward_exact_match() {
1136 let config = parse_str(
1137 "Host myserver\n LocalForward 8080 localhost:80\n",
1138 );
1139 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1140 assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
1141 assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
1142 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1143 }
1144
1145 #[test]
1146 fn has_forward_whitespace_normalized() {
1147 let config = parse_str(
1148 "Host myserver\n LocalForward 8080 localhost:80\n",
1149 );
1150 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1152 }
1153
1154 #[test]
1155 fn has_forward_multi_pattern_host() {
1156 let config = parse_str(
1157 "Host prod staging\n LocalForward 8080 localhost:80\n",
1158 );
1159 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1160 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1161 }
1162
1163 #[test]
1164 fn add_forward_multi_pattern_host() {
1165 let mut config = parse_str(
1166 "Host prod staging\n HostName 10.0.0.1\n",
1167 );
1168 config.add_forward("prod", "LocalForward", "8080 localhost:80");
1169 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1170 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1171 }
1172
1173 #[test]
1174 fn remove_forward_multi_pattern_host() {
1175 let mut config = parse_str(
1176 "Host prod staging\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1177 );
1178 assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
1179 assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1180 assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
1182 }
1183
1184 #[test]
1185 fn edit_tunnel_detects_duplicate_after_remove() {
1186 let mut config = parse_str(
1188 "Host myserver\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1189 );
1190 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1192 assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
1194 }
1195
1196 #[test]
1197 fn has_forward_tab_whitespace_normalized() {
1198 let config = parse_str(
1199 "Host myserver\n LocalForward 8080\tlocalhost:80\n",
1200 );
1201 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1203 }
1204
1205 #[test]
1206 fn remove_forward_tab_whitespace_normalized() {
1207 let mut config = parse_str(
1208 "Host myserver\n LocalForward 8080\tlocalhost:80\n",
1209 );
1210 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1212 assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1213 }
1214
1215 #[test]
1216 fn upsert_preserves_space_separator_when_value_contains_equals() {
1217 let mut config = parse_str(
1218 "Host myserver\n IdentityFile ~/.ssh/id=prod\n",
1219 );
1220 let entry = HostEntry {
1221 alias: "myserver".to_string(),
1222 hostname: "10.0.0.1".to_string(),
1223 identity_file: "~/.ssh/id=staging".to_string(),
1224 port: 22,
1225 ..Default::default()
1226 };
1227 config.update_host("myserver", &entry);
1228 let output = config.serialize();
1229 assert!(output.contains(" IdentityFile ~/.ssh/id=staging"), "got: {}", output);
1231 assert!(!output.contains("IdentityFile="), "got: {}", output);
1232 }
1233
1234 #[test]
1235 fn upsert_preserves_equals_separator() {
1236 let mut config = parse_str(
1237 "Host myserver\n IdentityFile=~/.ssh/id_rsa\n",
1238 );
1239 let entry = HostEntry {
1240 alias: "myserver".to_string(),
1241 hostname: "10.0.0.1".to_string(),
1242 identity_file: "~/.ssh/id_ed25519".to_string(),
1243 port: 22,
1244 ..Default::default()
1245 };
1246 config.update_host("myserver", &entry);
1247 let output = config.serialize();
1248 assert!(output.contains("IdentityFile=~/.ssh/id_ed25519"), "got: {}", output);
1249 }
1250
1251 #[test]
1252 fn upsert_preserves_spaced_equals_separator() {
1253 let mut config = parse_str(
1254 "Host myserver\n IdentityFile = ~/.ssh/id_rsa\n",
1255 );
1256 let entry = HostEntry {
1257 alias: "myserver".to_string(),
1258 hostname: "10.0.0.1".to_string(),
1259 identity_file: "~/.ssh/id_ed25519".to_string(),
1260 port: 22,
1261 ..Default::default()
1262 };
1263 config.update_host("myserver", &entry);
1264 let output = config.serialize();
1265 assert!(output.contains("IdentityFile = ~/.ssh/id_ed25519"), "got: {}", output);
1266 }
1267
1268 #[test]
1269 fn is_included_host_false_for_main_config() {
1270 let config = parse_str(
1271 "Host myserver\n HostName 10.0.0.1\n",
1272 );
1273 assert!(!config.is_included_host("myserver"));
1274 }
1275
1276 #[test]
1277 fn is_included_host_false_for_nonexistent() {
1278 let config = parse_str(
1279 "Host myserver\n HostName 10.0.0.1\n",
1280 );
1281 assert!(!config.is_included_host("nohost"));
1282 }
1283
1284 #[test]
1285 fn is_included_host_multi_pattern_main_config() {
1286 let config = parse_str(
1287 "Host prod staging\n HostName 10.0.0.1\n",
1288 );
1289 assert!(!config.is_included_host("prod"));
1290 assert!(!config.is_included_host("staging"));
1291 }
1292}