1use std::path::PathBuf;
2
3fn provider_group_display_name(name: &str) -> &str {
6 match name {
7 "digitalocean" => "DigitalOcean",
8 "vultr" => "Vultr",
9 "linode" => "Linode",
10 "hetzner" => "Hetzner",
11 "upcloud" => "UpCloud",
12 "proxmox" => "Proxmox VE",
13 "aws" => "AWS EC2",
14 "scaleway" => "Scaleway",
15 "gcp" => "GCP",
16 "azure" => "Azure",
17 "tailscale" => "Tailscale",
18 other => other,
19 }
20}
21
22#[derive(Debug, Clone)]
25pub struct SshConfigFile {
26 pub elements: Vec<ConfigElement>,
27 pub path: PathBuf,
28 pub crlf: bool,
30 pub bom: bool,
32}
33
34#[derive(Debug, Clone)]
36#[allow(dead_code)]
37pub struct IncludeDirective {
38 pub raw_line: String,
39 pub pattern: String,
40 pub resolved_files: Vec<IncludedFile>,
41}
42
43#[derive(Debug, Clone)]
45pub struct IncludedFile {
46 pub path: PathBuf,
47 pub elements: Vec<ConfigElement>,
48}
49
50#[derive(Debug, Clone)]
52pub enum ConfigElement {
53 HostBlock(HostBlock),
55 GlobalLine(String),
57 Include(IncludeDirective),
59}
60
61#[derive(Debug, Clone)]
63pub struct HostBlock {
64 pub host_pattern: String,
66 pub raw_host_line: String,
68 pub directives: Vec<Directive>,
70}
71
72#[derive(Debug, Clone)]
74pub struct Directive {
75 pub key: String,
77 pub value: String,
79 pub raw_line: String,
81 pub is_non_directive: bool,
83}
84
85#[derive(Debug, Clone)]
87pub struct HostEntry {
88 pub alias: String,
89 pub hostname: String,
90 pub user: String,
91 pub port: u16,
92 pub identity_file: String,
93 pub proxy_jump: String,
94 pub source_file: Option<PathBuf>,
96 pub tags: Vec<String>,
98 pub provider: Option<String>,
100 pub tunnel_count: u16,
102 pub askpass: Option<String>,
104 pub provider_meta: Vec<(String, String)>,
106}
107
108impl Default for HostEntry {
109 fn default() -> Self {
110 Self {
111 alias: String::new(),
112 hostname: String::new(),
113 user: String::new(),
114 port: 22,
115 identity_file: String::new(),
116 proxy_jump: String::new(),
117 source_file: None,
118 tags: Vec::new(),
119 provider: None,
120 tunnel_count: 0,
121 askpass: None,
122 provider_meta: Vec::new(),
123 }
124 }
125}
126
127impl HostEntry {
128 pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
133 let escaped = self.alias.replace('\'', "'\\''");
134 let default = dirs::home_dir()
135 .map(|h| h.join(".ssh/config"))
136 .unwrap_or_default();
137 if config_path == default {
138 format!("ssh -- '{}'", escaped)
139 } else {
140 let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
141 format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
142 }
143 }
144}
145
146pub fn is_host_pattern(pattern: &str) -> bool {
150 pattern.contains('*')
151 || pattern.contains('?')
152 || pattern.contains('[')
153 || pattern.starts_with('!')
154 || pattern.contains(' ')
155 || pattern.contains('\t')
156}
157
158impl HostBlock {
159 fn content_end(&self) -> usize {
161 let mut pos = self.directives.len();
162 while pos > 0 {
163 if self.directives[pos - 1].is_non_directive
164 && self.directives[pos - 1].raw_line.trim().is_empty()
165 {
166 pos -= 1;
167 } else {
168 break;
169 }
170 }
171 pos
172 }
173
174 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
176 let end = self.content_end();
177 self.directives.drain(end..).collect()
178 }
179
180 fn ensure_trailing_blank(&mut self) {
182 self.pop_trailing_blanks();
183 self.directives.push(Directive {
184 key: String::new(),
185 value: String::new(),
186 raw_line: String::new(),
187 is_non_directive: true,
188 });
189 }
190
191 fn detect_indent(&self) -> String {
193 for d in &self.directives {
194 if !d.is_non_directive && !d.raw_line.is_empty() {
195 let trimmed = d.raw_line.trim_start();
196 let indent_len = d.raw_line.len() - trimmed.len();
197 if indent_len > 0 {
198 return d.raw_line[..indent_len].to_string();
199 }
200 }
201 }
202 " ".to_string()
203 }
204
205 pub fn tags(&self) -> Vec<String> {
207 for d in &self.directives {
208 if d.is_non_directive {
209 let trimmed = d.raw_line.trim();
210 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
211 return rest
212 .split(',')
213 .map(|t| t.trim().to_string())
214 .filter(|t| !t.is_empty())
215 .collect();
216 }
217 }
218 }
219 Vec::new()
220 }
221
222 pub fn provider(&self) -> Option<(String, String)> {
225 for d in &self.directives {
226 if d.is_non_directive {
227 let trimmed = d.raw_line.trim();
228 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
229 if let Some((name, id)) = rest.split_once(':') {
230 return Some((name.trim().to_string(), id.trim().to_string()));
231 }
232 }
233 }
234 }
235 None
236 }
237
238 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
240 let indent = self.detect_indent();
241 self.directives.retain(|d| {
242 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider"))
243 });
244 let pos = self.content_end();
245 self.directives.insert(
246 pos,
247 Directive {
248 key: String::new(),
249 value: String::new(),
250 raw_line: format!("{}# purple:provider {}:{}", indent, provider_name, server_id),
251 is_non_directive: true,
252 },
253 );
254 }
255
256 pub fn askpass(&self) -> Option<String> {
258 for d in &self.directives {
259 if d.is_non_directive {
260 let trimmed = d.raw_line.trim();
261 if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
262 let val = rest.trim();
263 if !val.is_empty() {
264 return Some(val.to_string());
265 }
266 }
267 }
268 }
269 None
270 }
271
272 pub fn set_askpass(&mut self, source: &str) {
275 let indent = self.detect_indent();
276 self.directives.retain(|d| {
277 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:askpass"))
278 });
279 if !source.is_empty() {
280 let pos = self.content_end();
281 self.directives.insert(
282 pos,
283 Directive {
284 key: String::new(),
285 value: String::new(),
286 raw_line: format!("{}# purple:askpass {}", indent, source),
287 is_non_directive: true,
288 },
289 );
290 }
291 }
292
293 pub fn meta(&self) -> Vec<(String, String)> {
296 for d in &self.directives {
297 if d.is_non_directive {
298 let trimmed = d.raw_line.trim();
299 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
300 return rest
301 .split(',')
302 .filter_map(|pair| {
303 let (k, v) = pair.split_once('=')?;
304 let k = k.trim();
305 let v = v.trim();
306 if k.is_empty() {
307 None
308 } else {
309 Some((k.to_string(), v.to_string()))
310 }
311 })
312 .collect();
313 }
314 }
315 }
316 Vec::new()
317 }
318
319 pub fn set_meta(&mut self, meta: &[(String, String)]) {
322 let indent = self.detect_indent();
323 self.directives.retain(|d| {
324 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:meta"))
325 });
326 if !meta.is_empty() {
327 let encoded: Vec<String> = meta
328 .iter()
329 .map(|(k, v)| {
330 let clean_k = k.replace([',', '='], "");
331 let clean_v = v.replace(',', "");
332 format!("{}={}", clean_k, clean_v)
333 })
334 .collect();
335 let pos = self.content_end();
336 self.directives.insert(
337 pos,
338 Directive {
339 key: String::new(),
340 value: String::new(),
341 raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
342 is_non_directive: true,
343 },
344 );
345 }
346 }
347
348 pub fn set_tags(&mut self, tags: &[String]) {
350 let indent = self.detect_indent();
351 self.directives.retain(|d| {
352 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
353 });
354 if !tags.is_empty() {
355 let pos = self.content_end();
356 self.directives.insert(
357 pos,
358 Directive {
359 key: String::new(),
360 value: String::new(),
361 raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
362 is_non_directive: true,
363 },
364 );
365 }
366 }
367
368 pub fn to_host_entry(&self) -> HostEntry {
370 let mut entry = HostEntry {
371 alias: self.host_pattern.clone(),
372 port: 22,
373 ..Default::default()
374 };
375 for d in &self.directives {
376 if d.is_non_directive {
377 continue;
378 }
379 if d.key.eq_ignore_ascii_case("hostname") {
380 entry.hostname = d.value.clone();
381 } else if d.key.eq_ignore_ascii_case("user") {
382 entry.user = d.value.clone();
383 } else if d.key.eq_ignore_ascii_case("port") {
384 entry.port = d.value.parse().unwrap_or(22);
385 } else if d.key.eq_ignore_ascii_case("identityfile") {
386 if entry.identity_file.is_empty() {
387 entry.identity_file = d.value.clone();
388 }
389 } else if d.key.eq_ignore_ascii_case("proxyjump") {
390 entry.proxy_jump = d.value.clone();
391 }
392 }
393 entry.tags = self.tags();
394 entry.provider = self.provider().map(|(name, _)| name);
395 entry.tunnel_count = self.tunnel_count();
396 entry.askpass = self.askpass();
397 entry.provider_meta = self.meta();
398 entry
399 }
400
401 pub fn tunnel_count(&self) -> u16 {
403 let count = self
404 .directives
405 .iter()
406 .filter(|d| {
407 !d.is_non_directive
408 && (d.key.eq_ignore_ascii_case("localforward")
409 || d.key.eq_ignore_ascii_case("remoteforward")
410 || d.key.eq_ignore_ascii_case("dynamicforward"))
411 })
412 .count();
413 count.min(u16::MAX as usize) as u16
414 }
415
416 #[allow(dead_code)]
418 pub fn has_tunnels(&self) -> bool {
419 self.directives.iter().any(|d| {
420 !d.is_non_directive
421 && (d.key.eq_ignore_ascii_case("localforward")
422 || d.key.eq_ignore_ascii_case("remoteforward")
423 || d.key.eq_ignore_ascii_case("dynamicforward"))
424 })
425 }
426
427 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
429 self.directives
430 .iter()
431 .filter(|d| !d.is_non_directive)
432 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
433 .collect()
434 }
435}
436
437impl SshConfigFile {
438 pub fn host_entries(&self) -> Vec<HostEntry> {
440 let mut entries = Vec::new();
441 Self::collect_host_entries(&self.elements, &mut entries);
442 entries
443 }
444
445 pub fn include_paths(&self) -> Vec<PathBuf> {
447 let mut paths = Vec::new();
448 Self::collect_include_paths(&self.elements, &mut paths);
449 paths
450 }
451
452 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
453 for e in elements {
454 if let ConfigElement::Include(include) = e {
455 for file in &include.resolved_files {
456 paths.push(file.path.clone());
457 Self::collect_include_paths(&file.elements, paths);
458 }
459 }
460 }
461 }
462
463 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
466 let config_dir = self.path.parent();
467 let mut seen = std::collections::HashSet::new();
468 let mut dirs = Vec::new();
469 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
470 dirs
471 }
472
473 fn collect_include_glob_dirs(
474 elements: &[ConfigElement],
475 config_dir: Option<&std::path::Path>,
476 seen: &mut std::collections::HashSet<PathBuf>,
477 dirs: &mut Vec<PathBuf>,
478 ) {
479 for e in elements {
480 if let ConfigElement::Include(include) = e {
481 for single in Self::split_include_patterns(&include.pattern) {
483 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
484 let resolved = if expanded.starts_with('/') {
485 PathBuf::from(&expanded)
486 } else if let Some(dir) = config_dir {
487 dir.join(&expanded)
488 } else {
489 continue;
490 };
491 if let Some(parent) = resolved.parent() {
492 let parent = parent.to_path_buf();
493 if seen.insert(parent.clone()) {
494 dirs.push(parent);
495 }
496 }
497 }
498 for file in &include.resolved_files {
500 Self::collect_include_glob_dirs(
501 &file.elements,
502 file.path.parent(),
503 seen,
504 dirs,
505 );
506 }
507 }
508 }
509 }
510
511 pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
514 let active_providers: std::collections::HashSet<String> = self
516 .elements
517 .iter()
518 .filter_map(|e| {
519 if let ConfigElement::HostBlock(block) = e {
520 block
521 .provider()
522 .map(|(name, _)| provider_group_display_name(&name).to_string())
523 } else {
524 None
525 }
526 })
527 .collect();
528
529 let mut removed = 0;
530 self.elements.retain(|e| {
531 if let ConfigElement::GlobalLine(line) = e {
532 if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
533 if !active_providers.contains(rest.trim()) {
534 removed += 1;
535 return false;
536 }
537 }
538 }
539 true
540 });
541 removed
542 }
543
544 pub fn repair_absorbed_group_comments(&mut self) -> usize {
548 let mut repaired = 0;
549 let mut idx = 0;
550 while idx < self.elements.len() {
551 let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
552 block.directives.iter().any(|d| {
553 d.is_non_directive
554 && d.raw_line.trim().starts_with("# purple:group ")
555 })
556 } else {
557 false
558 };
559
560 if !needs_repair {
561 idx += 1;
562 continue;
563 }
564
565 let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
567 block
568 } else {
569 unreachable!()
570 };
571
572 let group_idx = block
573 .directives
574 .iter()
575 .position(|d| {
576 d.is_non_directive
577 && d.raw_line.trim().starts_with("# purple:group ")
578 })
579 .unwrap();
580
581 let mut keep_end = group_idx;
583 while keep_end > 0
584 && block.directives[keep_end - 1].is_non_directive
585 && block.directives[keep_end - 1].raw_line.trim().is_empty()
586 {
587 keep_end -= 1;
588 }
589
590 let extracted: Vec<ConfigElement> = block
592 .directives
593 .drain(keep_end..)
594 .map(|d| ConfigElement::GlobalLine(d.raw_line))
595 .collect();
596
597 let insert_at = idx + 1;
599 for (i, elem) in extracted.into_iter().enumerate() {
600 self.elements.insert(insert_at + i, elem);
601 }
602
603 repaired += 1;
604 idx = insert_at;
606 while idx < self.elements.len() {
608 if let ConfigElement::HostBlock(_) = &self.elements[idx] {
609 break;
610 }
611 idx += 1;
612 }
613 }
614 repaired
615 }
616
617 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
619 for e in elements {
620 match e {
621 ConfigElement::HostBlock(block) => {
622 if is_host_pattern(&block.host_pattern) {
623 continue;
624 }
625 entries.push(block.to_host_entry());
626 }
627 ConfigElement::Include(include) => {
628 for file in &include.resolved_files {
629 let start = entries.len();
630 Self::collect_host_entries(&file.elements, entries);
631 for entry in &mut entries[start..] {
632 if entry.source_file.is_none() {
633 entry.source_file = Some(file.path.clone());
634 }
635 }
636 }
637 }
638 ConfigElement::GlobalLine(_) => {}
639 }
640 }
641 }
642
643 pub fn has_host(&self, alias: &str) -> bool {
646 Self::has_host_in_elements(&self.elements, alias)
647 }
648
649 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
650 for e in elements {
651 match e {
652 ConfigElement::HostBlock(block) => {
653 if block.host_pattern.split_whitespace().any(|p| p == alias) {
654 return true;
655 }
656 }
657 ConfigElement::Include(include) => {
658 for file in &include.resolved_files {
659 if Self::has_host_in_elements(&file.elements, alias) {
660 return true;
661 }
662 }
663 }
664 ConfigElement::GlobalLine(_) => {}
665 }
666 }
667 false
668 }
669
670 pub fn is_included_host(&self, alias: &str) -> bool {
673 for e in &self.elements {
675 match e {
676 ConfigElement::HostBlock(block) => {
677 if block.host_pattern.split_whitespace().any(|p| p == alias) {
678 return false;
679 }
680 }
681 ConfigElement::Include(include) => {
682 for file in &include.resolved_files {
683 if Self::has_host_in_elements(&file.elements, alias) {
684 return true;
685 }
686 }
687 }
688 ConfigElement::GlobalLine(_) => {}
689 }
690 }
691 false
692 }
693
694 pub fn add_host(&mut self, entry: &HostEntry) {
699 let block = Self::entry_to_block(entry);
700 let insert_pos = self.find_trailing_pattern_start();
701
702 if let Some(pos) = insert_pos {
703 let needs_blank_before = pos > 0
705 && !matches!(
706 self.elements.get(pos - 1),
707 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
708 );
709 let mut idx = pos;
710 if needs_blank_before {
711 self.elements
712 .insert(idx, ConfigElement::GlobalLine(String::new()));
713 idx += 1;
714 }
715 self.elements.insert(idx, ConfigElement::HostBlock(block));
716 let after = idx + 1;
718 if after < self.elements.len()
719 && !matches!(
720 self.elements.get(after),
721 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
722 )
723 {
724 self.elements
725 .insert(after, ConfigElement::GlobalLine(String::new()));
726 }
727 } else {
728 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
730 self.elements
731 .push(ConfigElement::GlobalLine(String::new()));
732 }
733 self.elements.push(ConfigElement::HostBlock(block));
734 }
735 }
736
737 fn find_trailing_pattern_start(&self) -> Option<usize> {
742 let mut first_pattern_pos = None;
743 for i in (0..self.elements.len()).rev() {
744 match &self.elements[i] {
745 ConfigElement::HostBlock(block) => {
746 if is_host_pattern(&block.host_pattern) {
747 first_pattern_pos = Some(i);
748 } else {
749 break;
751 }
752 }
753 ConfigElement::GlobalLine(_) => {
754 if first_pattern_pos.is_some() {
756 first_pattern_pos = Some(i);
757 }
758 }
759 ConfigElement::Include(_) => break,
760 }
761 }
762 first_pattern_pos.filter(|&pos| pos > 0)
764 }
765
766 pub fn last_element_has_trailing_blank(&self) -> bool {
768 match self.elements.last() {
769 Some(ConfigElement::HostBlock(block)) => block
770 .directives
771 .last()
772 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
773 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
774 _ => false,
775 }
776 }
777
778 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
781 for element in &mut self.elements {
782 if let ConfigElement::HostBlock(block) = element {
783 if block.host_pattern == old_alias {
784 if entry.alias != block.host_pattern {
786 block.host_pattern = entry.alias.clone();
787 block.raw_host_line = format!("Host {}", entry.alias);
788 }
789
790 Self::upsert_directive(block, "HostName", &entry.hostname);
792 Self::upsert_directive(block, "User", &entry.user);
793 if entry.port != 22 {
794 Self::upsert_directive(block, "Port", &entry.port.to_string());
795 } else {
796 block
798 .directives
799 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
800 }
801 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
802 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
803 return;
804 }
805 }
806 }
807 }
808
809 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
811 if value.is_empty() {
812 block
813 .directives
814 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
815 return;
816 }
817 let indent = block.detect_indent();
818 for d in &mut block.directives {
819 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
820 if d.value != value {
822 d.value = value.to_string();
823 let trimmed = d.raw_line.trim_start();
829 let after_key = &trimmed[d.key.len()..];
830 let sep = if after_key.trim_start().starts_with('=') {
831 let eq_pos = after_key.find('=').unwrap();
832 let after_eq = &after_key[eq_pos + 1..];
833 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
834 after_key[..eq_pos + 1 + trailing_ws].to_string()
835 } else {
836 " ".to_string()
837 };
838 let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
840 d.raw_line =
841 format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
842 }
843 return;
844 }
845 }
846 let pos = block.content_end();
848 block.directives.insert(
849 pos,
850 Directive {
851 key: key.to_string(),
852 value: value.to_string(),
853 raw_line: format!("{}{} {}", indent, key, value),
854 is_non_directive: false,
855 },
856 );
857 }
858
859 fn extract_inline_comment(raw_line: &str, key: &str) -> String {
863 let trimmed = raw_line.trim_start();
864 if trimmed.len() <= key.len() {
865 return String::new();
866 }
867 let after_key = &trimmed[key.len()..];
869 let rest = after_key.trim_start();
870 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
871 let bytes = rest.as_bytes();
873 let mut in_quote = false;
874 for i in 0..bytes.len() {
875 if bytes[i] == b'"' {
876 in_quote = !in_quote;
877 } else if !in_quote
878 && bytes[i] == b'#'
879 && i > 0
880 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
881 {
882 let clean_end = rest[..i].trim_end().len();
884 return rest[clean_end..].to_string();
885 }
886 }
887 String::new()
888 }
889
890 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
892 for element in &mut self.elements {
893 if let ConfigElement::HostBlock(block) = element {
894 if block.host_pattern == alias {
895 block.set_provider(provider_name, server_id);
896 return;
897 }
898 }
899 }
900 }
901
902 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
906 let mut results = Vec::new();
907 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
908 results
909 }
910
911 fn collect_provider_hosts(
912 elements: &[ConfigElement],
913 provider_name: &str,
914 results: &mut Vec<(String, String)>,
915 ) {
916 for element in elements {
917 match element {
918 ConfigElement::HostBlock(block) => {
919 if let Some((name, id)) = block.provider() {
920 if name == provider_name {
921 results.push((block.host_pattern.clone(), id));
922 }
923 }
924 }
925 ConfigElement::Include(include) => {
926 for file in &include.resolved_files {
927 Self::collect_provider_hosts(&file.elements, provider_name, results);
928 }
929 }
930 ConfigElement::GlobalLine(_) => {}
931 }
932 }
933 }
934
935 fn values_match(a: &str, b: &str) -> bool {
938 a.split_whitespace().eq(b.split_whitespace())
939 }
940
941 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
945 for element in &mut self.elements {
946 if let ConfigElement::HostBlock(block) = element {
947 if block.host_pattern.split_whitespace().any(|p| p == alias) {
948 let indent = block.detect_indent();
949 let pos = block.content_end();
950 block.directives.insert(
951 pos,
952 Directive {
953 key: directive_key.to_string(),
954 value: value.to_string(),
955 raw_line: format!("{}{} {}", indent, directive_key, value),
956 is_non_directive: false,
957 },
958 );
959 return;
960 }
961 }
962 }
963 }
964
965 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
970 for element in &mut self.elements {
971 if let ConfigElement::HostBlock(block) = element {
972 if block.host_pattern.split_whitespace().any(|p| p == alias) {
973 if let Some(pos) = block.directives.iter().position(|d| {
974 !d.is_non_directive
975 && d.key.eq_ignore_ascii_case(directive_key)
976 && Self::values_match(&d.value, value)
977 }) {
978 block.directives.remove(pos);
979 return true;
980 }
981 return false;
982 }
983 }
984 }
985 false
986 }
987
988 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
991 for element in &self.elements {
992 if let ConfigElement::HostBlock(block) = element {
993 if block.host_pattern.split_whitespace().any(|p| p == alias) {
994 return block.directives.iter().any(|d| {
995 !d.is_non_directive
996 && d.key.eq_ignore_ascii_case(directive_key)
997 && Self::values_match(&d.value, value)
998 });
999 }
1000 }
1001 }
1002 false
1003 }
1004
1005 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1009 Self::find_tunnel_directives_in(&self.elements, alias)
1010 }
1011
1012 fn find_tunnel_directives_in(
1013 elements: &[ConfigElement],
1014 alias: &str,
1015 ) -> Vec<crate::tunnel::TunnelRule> {
1016 for element in elements {
1017 match element {
1018 ConfigElement::HostBlock(block) => {
1019 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1020 return block.tunnel_directives();
1021 }
1022 }
1023 ConfigElement::Include(include) => {
1024 for file in &include.resolved_files {
1025 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1026 if !rules.is_empty() {
1027 return rules;
1028 }
1029 }
1030 }
1031 ConfigElement::GlobalLine(_) => {}
1032 }
1033 }
1034 Vec::new()
1035 }
1036
1037 pub fn deduplicate_alias(&self, base: &str) -> String {
1039 self.deduplicate_alias_excluding(base, None)
1040 }
1041
1042 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1045 let is_taken = |alias: &str| {
1046 if exclude == Some(alias) {
1047 return false;
1048 }
1049 self.has_host(alias)
1050 };
1051 if !is_taken(base) {
1052 return base.to_string();
1053 }
1054 for n in 2..=9999 {
1055 let candidate = format!("{}-{}", base, n);
1056 if !is_taken(&candidate) {
1057 return candidate;
1058 }
1059 }
1060 format!("{}-{}", base, std::process::id())
1062 }
1063
1064 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1066 for element in &mut self.elements {
1067 if let ConfigElement::HostBlock(block) = element {
1068 if block.host_pattern == alias {
1069 block.set_tags(tags);
1070 return;
1071 }
1072 }
1073 }
1074 }
1075
1076 pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1078 for element in &mut self.elements {
1079 if let ConfigElement::HostBlock(block) = element {
1080 if block.host_pattern == alias {
1081 block.set_askpass(source);
1082 return;
1083 }
1084 }
1085 }
1086 }
1087
1088 pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1090 for element in &mut self.elements {
1091 if let ConfigElement::HostBlock(block) = element {
1092 if block.host_pattern == alias {
1093 block.set_meta(meta);
1094 return;
1095 }
1096 }
1097 }
1098 }
1099
1100 #[allow(dead_code)]
1102 pub fn delete_host(&mut self, alias: &str) {
1103 let provider_name = self.elements.iter().find_map(|e| {
1106 if let ConfigElement::HostBlock(b) = e {
1107 if b.host_pattern == alias {
1108 return b.provider().map(|(name, _)| name);
1109 }
1110 }
1111 None
1112 });
1113
1114 self.elements.retain(|e| match e {
1115 ConfigElement::HostBlock(block) => block.host_pattern != alias,
1116 _ => true,
1117 });
1118
1119 if let Some(name) = provider_name {
1121 self.remove_orphaned_group_header(&name);
1122 }
1123
1124 self.elements.dedup_by(|a, b| {
1126 matches!(
1127 (&*a, &*b),
1128 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1129 if x.trim().is_empty() && y.trim().is_empty()
1130 )
1131 });
1132 }
1133
1134 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1139 let pos = self.elements.iter().position(|e| {
1140 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
1141 })?;
1142 let element = self.elements.remove(pos);
1143 Some((element, pos))
1144 }
1145
1146 #[allow(dead_code)]
1148 fn find_group_header_position(&self, provider_name: &str) -> Option<usize> {
1149 let display = provider_group_display_name(provider_name);
1150 let header = format!("# purple:group {}", display);
1151 self.elements
1152 .iter()
1153 .position(|e| matches!(e, ConfigElement::GlobalLine(line) if *line == header))
1154 }
1155
1156 fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1159 if self.find_hosts_by_provider(provider_name).is_empty() {
1160 let display = provider_group_display_name(provider_name);
1161 let header = format!("# purple:group {}", display);
1162 self.elements
1163 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1164 }
1165 }
1166
1167 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1169 let pos = position.min(self.elements.len());
1170 self.elements.insert(pos, element);
1171 }
1172
1173 pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1177 let mut last_pos = None;
1178 for (i, element) in self.elements.iter().enumerate() {
1179 if let ConfigElement::HostBlock(block) = element {
1180 if let Some((name, _)) = block.provider() {
1181 if name == provider_name {
1182 last_pos = Some(i);
1183 }
1184 }
1185 }
1186 }
1187 last_pos.map(|p| p + 1)
1189 }
1190
1191 #[allow(dead_code)]
1193 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1194 let pos_a = self.elements.iter().position(|e| {
1195 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
1196 });
1197 let pos_b = self.elements.iter().position(|e| {
1198 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
1199 });
1200 if let (Some(a), Some(b)) = (pos_a, pos_b) {
1201 if a == b {
1202 return false;
1203 }
1204 let (first, second) = (a.min(b), a.max(b));
1205
1206 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1208 block.pop_trailing_blanks();
1209 }
1210 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1211 block.pop_trailing_blanks();
1212 }
1213
1214 self.elements.swap(first, second);
1216
1217 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1219 block.ensure_trailing_blank();
1220 }
1221
1222 if second < self.elements.len() - 1 {
1224 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1225 block.ensure_trailing_blank();
1226 }
1227 }
1228
1229 return true;
1230 }
1231 false
1232 }
1233
1234 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1236 let mut directives = Vec::new();
1237
1238 if !entry.hostname.is_empty() {
1239 directives.push(Directive {
1240 key: "HostName".to_string(),
1241 value: entry.hostname.clone(),
1242 raw_line: format!(" HostName {}", entry.hostname),
1243 is_non_directive: false,
1244 });
1245 }
1246 if !entry.user.is_empty() {
1247 directives.push(Directive {
1248 key: "User".to_string(),
1249 value: entry.user.clone(),
1250 raw_line: format!(" User {}", entry.user),
1251 is_non_directive: false,
1252 });
1253 }
1254 if entry.port != 22 {
1255 directives.push(Directive {
1256 key: "Port".to_string(),
1257 value: entry.port.to_string(),
1258 raw_line: format!(" Port {}", entry.port),
1259 is_non_directive: false,
1260 });
1261 }
1262 if !entry.identity_file.is_empty() {
1263 directives.push(Directive {
1264 key: "IdentityFile".to_string(),
1265 value: entry.identity_file.clone(),
1266 raw_line: format!(" IdentityFile {}", entry.identity_file),
1267 is_non_directive: false,
1268 });
1269 }
1270 if !entry.proxy_jump.is_empty() {
1271 directives.push(Directive {
1272 key: "ProxyJump".to_string(),
1273 value: entry.proxy_jump.clone(),
1274 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
1275 is_non_directive: false,
1276 });
1277 }
1278
1279 HostBlock {
1280 host_pattern: entry.alias.clone(),
1281 raw_host_line: format!("Host {}", entry.alias),
1282 directives,
1283 }
1284 }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289 use super::*;
1290
1291 fn parse_str(content: &str) -> SshConfigFile {
1292 SshConfigFile {
1293 elements: SshConfigFile::parse_content(content),
1294 path: PathBuf::from("/tmp/test_config"),
1295 crlf: false,
1296 bom: false,
1297 }
1298 }
1299
1300 #[test]
1301 fn tunnel_directives_extracts_forwards() {
1302 let config = parse_str(
1303 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1304 );
1305 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1306 let rules = block.tunnel_directives();
1307 assert_eq!(rules.len(), 3);
1308 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1309 assert_eq!(rules[0].bind_port, 8080);
1310 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1311 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1312 } else {
1313 panic!("Expected HostBlock");
1314 }
1315 }
1316
1317 #[test]
1318 fn tunnel_count_counts_forwards() {
1319 let config = parse_str(
1320 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n",
1321 );
1322 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1323 assert_eq!(block.tunnel_count(), 2);
1324 } else {
1325 panic!("Expected HostBlock");
1326 }
1327 }
1328
1329 #[test]
1330 fn tunnel_count_zero_for_no_forwards() {
1331 let config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1332 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1333 assert_eq!(block.tunnel_count(), 0);
1334 assert!(!block.has_tunnels());
1335 } else {
1336 panic!("Expected HostBlock");
1337 }
1338 }
1339
1340 #[test]
1341 fn has_tunnels_true_with_forward() {
1342 let config = parse_str("Host myserver\n HostName 10.0.0.1\n DynamicForward 1080\n");
1343 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1344 assert!(block.has_tunnels());
1345 } else {
1346 panic!("Expected HostBlock");
1347 }
1348 }
1349
1350 #[test]
1351 fn add_forward_inserts_directive() {
1352 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1353 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1354 let output = config.serialize();
1355 assert!(output.contains("LocalForward 8080 localhost:80"));
1356 assert!(output.contains("HostName 10.0.0.1"));
1358 assert!(output.contains("User admin"));
1359 }
1360
1361 #[test]
1362 fn add_forward_preserves_indentation() {
1363 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1364 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1365 let output = config.serialize();
1366 assert!(output.contains("\tLocalForward 8080 localhost:80"));
1367 }
1368
1369 #[test]
1370 fn add_multiple_forwards_same_type() {
1371 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1372 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1373 config.add_forward("myserver", "LocalForward", "9090 localhost:90");
1374 let output = config.serialize();
1375 assert!(output.contains("LocalForward 8080 localhost:80"));
1376 assert!(output.contains("LocalForward 9090 localhost:90"));
1377 }
1378
1379 #[test]
1380 fn remove_forward_removes_exact_match() {
1381 let mut config = parse_str(
1382 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1383 );
1384 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1385 let output = config.serialize();
1386 assert!(!output.contains("8080 localhost:80"));
1387 assert!(output.contains("9090 localhost:90"));
1388 }
1389
1390 #[test]
1391 fn remove_forward_leaves_other_directives() {
1392 let mut config = parse_str(
1393 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n User admin\n",
1394 );
1395 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1396 let output = config.serialize();
1397 assert!(!output.contains("LocalForward"));
1398 assert!(output.contains("HostName 10.0.0.1"));
1399 assert!(output.contains("User admin"));
1400 }
1401
1402 #[test]
1403 fn remove_forward_no_match_is_noop() {
1404 let original = "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n";
1405 let mut config = parse_str(original);
1406 config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
1407 assert_eq!(config.serialize(), original);
1408 }
1409
1410 #[test]
1411 fn host_entry_tunnel_count_populated() {
1412 let config = parse_str(
1413 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n DynamicForward 1080\n",
1414 );
1415 let entries = config.host_entries();
1416 assert_eq!(entries.len(), 1);
1417 assert_eq!(entries[0].tunnel_count, 2);
1418 }
1419
1420 #[test]
1421 fn remove_forward_returns_true_on_match() {
1422 let mut config = parse_str(
1423 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1424 );
1425 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1426 }
1427
1428 #[test]
1429 fn remove_forward_returns_false_on_no_match() {
1430 let mut config = parse_str(
1431 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1432 );
1433 assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
1434 }
1435
1436 #[test]
1437 fn remove_forward_returns_false_for_unknown_host() {
1438 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1439 assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
1440 }
1441
1442 #[test]
1443 fn has_forward_finds_match() {
1444 let config = parse_str(
1445 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1446 );
1447 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1448 }
1449
1450 #[test]
1451 fn has_forward_no_match() {
1452 let config = parse_str(
1453 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1454 );
1455 assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
1456 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1457 }
1458
1459 #[test]
1460 fn has_forward_case_insensitive_key() {
1461 let config = parse_str(
1462 "Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n",
1463 );
1464 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1465 }
1466
1467 #[test]
1468 fn add_forward_to_empty_block() {
1469 let mut config = parse_str("Host myserver\n");
1470 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1471 let output = config.serialize();
1472 assert!(output.contains("LocalForward 8080 localhost:80"));
1473 }
1474
1475 #[test]
1476 fn remove_forward_case_insensitive_key_match() {
1477 let mut config = parse_str(
1478 "Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n",
1479 );
1480 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1481 assert!(!config.serialize().contains("localforward"));
1482 }
1483
1484 #[test]
1485 fn tunnel_count_case_insensitive() {
1486 let config = parse_str(
1487 "Host myserver\n localforward 8080 localhost:80\n REMOTEFORWARD 9090 localhost:90\n dynamicforward 1080\n",
1488 );
1489 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1490 assert_eq!(block.tunnel_count(), 3);
1491 } else {
1492 panic!("Expected HostBlock");
1493 }
1494 }
1495
1496 #[test]
1497 fn tunnel_directives_extracts_all_types() {
1498 let config = parse_str(
1499 "Host myserver\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1500 );
1501 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1502 let rules = block.tunnel_directives();
1503 assert_eq!(rules.len(), 3);
1504 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1505 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1506 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1507 } else {
1508 panic!("Expected HostBlock");
1509 }
1510 }
1511
1512 #[test]
1513 fn tunnel_directives_skips_malformed() {
1514 let config = parse_str(
1515 "Host myserver\n LocalForward not_valid\n DynamicForward 1080\n",
1516 );
1517 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1518 let rules = block.tunnel_directives();
1519 assert_eq!(rules.len(), 1);
1520 assert_eq!(rules[0].bind_port, 1080);
1521 } else {
1522 panic!("Expected HostBlock");
1523 }
1524 }
1525
1526 #[test]
1527 fn find_tunnel_directives_multi_pattern_host() {
1528 let config = parse_str(
1529 "Host prod staging\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n",
1530 );
1531 let rules = config.find_tunnel_directives("prod");
1532 assert_eq!(rules.len(), 1);
1533 assert_eq!(rules[0].bind_port, 8080);
1534 let rules2 = config.find_tunnel_directives("staging");
1535 assert_eq!(rules2.len(), 1);
1536 }
1537
1538 #[test]
1539 fn find_tunnel_directives_no_match() {
1540 let config = parse_str(
1541 "Host myserver\n LocalForward 8080 localhost:80\n",
1542 );
1543 let rules = config.find_tunnel_directives("nohost");
1544 assert!(rules.is_empty());
1545 }
1546
1547 #[test]
1548 fn has_forward_exact_match() {
1549 let config = parse_str(
1550 "Host myserver\n LocalForward 8080 localhost:80\n",
1551 );
1552 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1553 assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
1554 assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
1555 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1556 }
1557
1558 #[test]
1559 fn has_forward_whitespace_normalized() {
1560 let config = parse_str(
1561 "Host myserver\n LocalForward 8080 localhost:80\n",
1562 );
1563 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1565 }
1566
1567 #[test]
1568 fn has_forward_multi_pattern_host() {
1569 let config = parse_str(
1570 "Host prod staging\n LocalForward 8080 localhost:80\n",
1571 );
1572 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1573 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1574 }
1575
1576 #[test]
1577 fn add_forward_multi_pattern_host() {
1578 let mut config = parse_str(
1579 "Host prod staging\n HostName 10.0.0.1\n",
1580 );
1581 config.add_forward("prod", "LocalForward", "8080 localhost:80");
1582 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1583 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1584 }
1585
1586 #[test]
1587 fn remove_forward_multi_pattern_host() {
1588 let mut config = parse_str(
1589 "Host prod staging\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1590 );
1591 assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
1592 assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1593 assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
1595 }
1596
1597 #[test]
1598 fn edit_tunnel_detects_duplicate_after_remove() {
1599 let mut config = parse_str(
1601 "Host myserver\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1602 );
1603 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1605 assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
1607 }
1608
1609 #[test]
1610 fn has_forward_tab_whitespace_normalized() {
1611 let config = parse_str(
1612 "Host myserver\n LocalForward 8080\tlocalhost:80\n",
1613 );
1614 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1616 }
1617
1618 #[test]
1619 fn remove_forward_tab_whitespace_normalized() {
1620 let mut config = parse_str(
1621 "Host myserver\n LocalForward 8080\tlocalhost:80\n",
1622 );
1623 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1625 assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1626 }
1627
1628 #[test]
1629 fn upsert_preserves_space_separator_when_value_contains_equals() {
1630 let mut config = parse_str(
1631 "Host myserver\n IdentityFile ~/.ssh/id=prod\n",
1632 );
1633 let entry = HostEntry {
1634 alias: "myserver".to_string(),
1635 hostname: "10.0.0.1".to_string(),
1636 identity_file: "~/.ssh/id=staging".to_string(),
1637 port: 22,
1638 ..Default::default()
1639 };
1640 config.update_host("myserver", &entry);
1641 let output = config.serialize();
1642 assert!(output.contains(" IdentityFile ~/.ssh/id=staging"), "got: {}", output);
1644 assert!(!output.contains("IdentityFile="), "got: {}", output);
1645 }
1646
1647 #[test]
1648 fn upsert_preserves_equals_separator() {
1649 let mut config = parse_str(
1650 "Host myserver\n IdentityFile=~/.ssh/id_rsa\n",
1651 );
1652 let entry = HostEntry {
1653 alias: "myserver".to_string(),
1654 hostname: "10.0.0.1".to_string(),
1655 identity_file: "~/.ssh/id_ed25519".to_string(),
1656 port: 22,
1657 ..Default::default()
1658 };
1659 config.update_host("myserver", &entry);
1660 let output = config.serialize();
1661 assert!(output.contains("IdentityFile=~/.ssh/id_ed25519"), "got: {}", output);
1662 }
1663
1664 #[test]
1665 fn upsert_preserves_spaced_equals_separator() {
1666 let mut config = parse_str(
1667 "Host myserver\n IdentityFile = ~/.ssh/id_rsa\n",
1668 );
1669 let entry = HostEntry {
1670 alias: "myserver".to_string(),
1671 hostname: "10.0.0.1".to_string(),
1672 identity_file: "~/.ssh/id_ed25519".to_string(),
1673 port: 22,
1674 ..Default::default()
1675 };
1676 config.update_host("myserver", &entry);
1677 let output = config.serialize();
1678 assert!(output.contains("IdentityFile = ~/.ssh/id_ed25519"), "got: {}", output);
1679 }
1680
1681 #[test]
1682 fn is_included_host_false_for_main_config() {
1683 let config = parse_str(
1684 "Host myserver\n HostName 10.0.0.1\n",
1685 );
1686 assert!(!config.is_included_host("myserver"));
1687 }
1688
1689 #[test]
1690 fn is_included_host_false_for_nonexistent() {
1691 let config = parse_str(
1692 "Host myserver\n HostName 10.0.0.1\n",
1693 );
1694 assert!(!config.is_included_host("nohost"));
1695 }
1696
1697 #[test]
1698 fn is_included_host_multi_pattern_main_config() {
1699 let config = parse_str(
1700 "Host prod staging\n HostName 10.0.0.1\n",
1701 );
1702 assert!(!config.is_included_host("prod"));
1703 assert!(!config.is_included_host("staging"));
1704 }
1705
1706 fn first_block(config: &SshConfigFile) -> &HostBlock {
1711 match config.elements.first().unwrap() {
1712 ConfigElement::HostBlock(b) => b,
1713 _ => panic!("Expected HostBlock"),
1714 }
1715 }
1716
1717 fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
1718 let mut count = 0;
1719 for el in &config.elements {
1720 if let ConfigElement::HostBlock(b) = el {
1721 if count == idx {
1722 return b;
1723 }
1724 count += 1;
1725 }
1726 }
1727 panic!("No HostBlock at index {}", idx);
1728 }
1729
1730 #[test]
1731 fn askpass_returns_none_when_absent() {
1732 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1733 assert_eq!(first_block(&config).askpass(), None);
1734 }
1735
1736 #[test]
1737 fn askpass_returns_keychain() {
1738 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1739 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1740 }
1741
1742 #[test]
1743 fn askpass_returns_op_uri() {
1744 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass op://Vault/Item/field\n");
1745 assert_eq!(first_block(&config).askpass(), Some("op://Vault/Item/field".to_string()));
1746 }
1747
1748 #[test]
1749 fn askpass_returns_vault_with_field() {
1750 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:secret/ssh#password\n");
1751 assert_eq!(first_block(&config).askpass(), Some("vault:secret/ssh#password".to_string()));
1752 }
1753
1754 #[test]
1755 fn askpass_returns_bw_source() {
1756 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:my-item\n");
1757 assert_eq!(first_block(&config).askpass(), Some("bw:my-item".to_string()));
1758 }
1759
1760 #[test]
1761 fn askpass_returns_pass_source() {
1762 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass pass:ssh/prod\n");
1763 assert_eq!(first_block(&config).askpass(), Some("pass:ssh/prod".to_string()));
1764 }
1765
1766 #[test]
1767 fn askpass_returns_custom_command() {
1768 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass get-pass %a %h\n");
1769 assert_eq!(first_block(&config).askpass(), Some("get-pass %a %h".to_string()));
1770 }
1771
1772 #[test]
1773 fn askpass_ignores_empty_value() {
1774 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass \n");
1775 assert_eq!(first_block(&config).askpass(), None);
1776 }
1777
1778 #[test]
1779 fn askpass_ignores_non_askpass_purple_comments() {
1780 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod\n");
1781 assert_eq!(first_block(&config).askpass(), None);
1782 }
1783
1784 #[test]
1785 fn set_askpass_adds_comment() {
1786 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1787 config.set_host_askpass("myserver", "keychain");
1788 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1789 }
1790
1791 #[test]
1792 fn set_askpass_replaces_existing() {
1793 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1794 config.set_host_askpass("myserver", "op://V/I/p");
1795 assert_eq!(first_block(&config).askpass(), Some("op://V/I/p".to_string()));
1796 }
1797
1798 #[test]
1799 fn set_askpass_empty_removes_comment() {
1800 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1801 config.set_host_askpass("myserver", "");
1802 assert_eq!(first_block(&config).askpass(), None);
1803 }
1804
1805 #[test]
1806 fn set_askpass_preserves_other_directives() {
1807 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n # purple:tags prod\n");
1808 config.set_host_askpass("myserver", "vault:secret/ssh");
1809 assert_eq!(first_block(&config).askpass(), Some("vault:secret/ssh".to_string()));
1810 let entry = first_block(&config).to_host_entry();
1811 assert_eq!(entry.user, "admin");
1812 assert!(entry.tags.contains(&"prod".to_string()));
1813 }
1814
1815 #[test]
1816 fn set_askpass_preserves_indent() {
1817 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1818 config.set_host_askpass("myserver", "keychain");
1819 let raw = first_block(&config).directives.iter()
1820 .find(|d| d.raw_line.contains("purple:askpass"))
1821 .unwrap();
1822 assert!(raw.raw_line.starts_with(" "), "Expected 4-space indent, got: {:?}", raw.raw_line);
1823 }
1824
1825 #[test]
1826 fn set_askpass_on_nonexistent_host() {
1827 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1828 config.set_host_askpass("nohost", "keychain");
1829 assert_eq!(first_block(&config).askpass(), None);
1830 }
1831
1832 #[test]
1833 fn to_entry_includes_askpass() {
1834 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:item\n");
1835 let entries = config.host_entries();
1836 assert_eq!(entries.len(), 1);
1837 assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
1838 }
1839
1840 #[test]
1841 fn to_entry_askpass_none_when_absent() {
1842 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1843 let entries = config.host_entries();
1844 assert_eq!(entries.len(), 1);
1845 assert_eq!(entries[0].askpass, None);
1846 }
1847
1848 #[test]
1849 fn set_askpass_vault_with_hash_field() {
1850 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1851 config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
1852 assert_eq!(first_block(&config).askpass(), Some("vault:secret/data/team#api_key".to_string()));
1853 }
1854
1855 #[test]
1856 fn set_askpass_custom_command_with_percent() {
1857 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1858 config.set_host_askpass("myserver", "get-pass %a %h");
1859 assert_eq!(first_block(&config).askpass(), Some("get-pass %a %h".to_string()));
1860 }
1861
1862 #[test]
1863 fn multiple_hosts_independent_askpass() {
1864 let mut config = parse_str("Host alpha\n HostName a.com\n\nHost beta\n HostName b.com\n");
1865 config.set_host_askpass("alpha", "keychain");
1866 config.set_host_askpass("beta", "vault:secret/ssh");
1867 assert_eq!(block_by_index(&config, 0).askpass(), Some("keychain".to_string()));
1868 assert_eq!(block_by_index(&config, 1).askpass(), Some("vault:secret/ssh".to_string()));
1869 }
1870
1871 #[test]
1872 fn set_askpass_then_clear_then_set_again() {
1873 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1874 config.set_host_askpass("myserver", "keychain");
1875 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1876 config.set_host_askpass("myserver", "");
1877 assert_eq!(first_block(&config).askpass(), None);
1878 config.set_host_askpass("myserver", "op://V/I/p");
1879 assert_eq!(first_block(&config).askpass(), Some("op://V/I/p".to_string()));
1880 }
1881
1882 #[test]
1883 fn askpass_tab_indent_preserved() {
1884 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1885 config.set_host_askpass("myserver", "pass:ssh/prod");
1886 let raw = first_block(&config).directives.iter()
1887 .find(|d| d.raw_line.contains("purple:askpass"))
1888 .unwrap();
1889 assert!(raw.raw_line.starts_with("\t"), "Expected tab indent, got: {:?}", raw.raw_line);
1890 }
1891
1892 #[test]
1893 fn askpass_coexists_with_provider_comment() {
1894 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider do:123\n # purple:askpass keychain\n");
1895 let block = first_block(&config);
1896 assert_eq!(block.askpass(), Some("keychain".to_string()));
1897 assert!(block.provider().is_some());
1898 }
1899
1900 #[test]
1901 fn set_askpass_does_not_remove_tags() {
1902 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod,staging\n");
1903 config.set_host_askpass("myserver", "keychain");
1904 let entry = first_block(&config).to_host_entry();
1905 assert_eq!(entry.askpass, Some("keychain".to_string()));
1906 assert!(entry.tags.contains(&"prod".to_string()));
1907 assert!(entry.tags.contains(&"staging".to_string()));
1908 }
1909
1910 #[test]
1911 fn askpass_idempotent_set_same_value() {
1912 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1913 config.set_host_askpass("myserver", "keychain");
1914 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1915 let serialized = config.serialize();
1916 assert_eq!(serialized.matches("purple:askpass").count(), 1, "Should have exactly one askpass comment");
1917 }
1918
1919 #[test]
1920 fn askpass_with_value_containing_equals() {
1921 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1922 config.set_host_askpass("myserver", "cmd --opt=val %h");
1923 assert_eq!(first_block(&config).askpass(), Some("cmd --opt=val %h".to_string()));
1924 }
1925
1926 #[test]
1927 fn askpass_with_value_containing_hash() {
1928 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:a/b#c\n");
1929 assert_eq!(first_block(&config).askpass(), Some("vault:a/b#c".to_string()));
1930 }
1931
1932 #[test]
1933 fn askpass_with_long_op_uri() {
1934 let uri = "op://My Personal Vault/SSH Production Server/password";
1935 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1936 config.set_host_askpass("myserver", uri);
1937 assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
1938 }
1939
1940 #[test]
1941 fn askpass_does_not_interfere_with_host_matching() {
1942 let config = parse_str("Host myserver\n HostName 10.0.0.1\n User root\n # purple:askpass keychain\n");
1944 let entry = first_block(&config).to_host_entry();
1945 assert_eq!(entry.user, "root");
1946 assert_eq!(entry.hostname, "10.0.0.1");
1947 assert_eq!(entry.askpass, Some("keychain".to_string()));
1948 }
1949
1950 #[test]
1951 fn set_askpass_on_host_with_many_directives() {
1952 let config_str = "\
1953Host myserver
1954 HostName 10.0.0.1
1955 User admin
1956 Port 2222
1957 IdentityFile ~/.ssh/id_ed25519
1958 ProxyJump bastion
1959 # purple:tags prod,us-east
1960";
1961 let mut config = parse_str(config_str);
1962 config.set_host_askpass("myserver", "pass:ssh/prod");
1963 let entry = first_block(&config).to_host_entry();
1964 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
1965 assert_eq!(entry.user, "admin");
1966 assert_eq!(entry.port, 2222);
1967 assert!(entry.tags.contains(&"prod".to_string()));
1968 }
1969
1970 #[test]
1971 fn askpass_with_crlf_line_endings() {
1972 let config = parse_str("Host myserver\r\n HostName 10.0.0.1\r\n # purple:askpass keychain\r\n");
1973 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1974 }
1975
1976 #[test]
1977 fn askpass_only_on_first_matching_host() {
1978 let config = parse_str("Host dup\n HostName a.com\n # purple:askpass keychain\n\nHost dup\n HostName b.com\n # purple:askpass vault:x\n");
1980 let entries = config.host_entries();
1981 assert_eq!(entries[0].askpass, Some("keychain".to_string()));
1983 }
1984
1985 #[test]
1986 fn set_askpass_preserves_other_non_directive_comments() {
1987 let config_str = "Host myserver\n HostName 10.0.0.1\n # This is a user comment\n # purple:askpass old\n # Another comment\n";
1988 let mut config = parse_str(config_str);
1989 config.set_host_askpass("myserver", "new-source");
1990 let serialized = config.serialize();
1991 assert!(serialized.contains("# This is a user comment"));
1992 assert!(serialized.contains("# Another comment"));
1993 assert!(serialized.contains("# purple:askpass new-source"));
1994 assert!(!serialized.contains("# purple:askpass old"));
1995 }
1996
1997 #[test]
1998 fn askpass_mixed_with_tunnel_directives() {
1999 let config_str = "\
2000Host myserver
2001 HostName 10.0.0.1
2002 LocalForward 8080 localhost:80
2003 # purple:askpass bw:item
2004 RemoteForward 9090 localhost:9090
2005";
2006 let config = parse_str(config_str);
2007 let entry = first_block(&config).to_host_entry();
2008 assert_eq!(entry.askpass, Some("bw:item".to_string()));
2009 assert_eq!(entry.tunnel_count, 2);
2010 }
2011
2012 #[test]
2017 fn set_askpass_idempotent_same_value() {
2018 let config_str = "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n";
2019 let mut config = parse_str(config_str);
2020 config.set_host_askpass("myserver", "keychain");
2021 let output = config.serialize();
2022 assert_eq!(output.matches("purple:askpass").count(), 1);
2024 assert!(output.contains("# purple:askpass keychain"));
2025 }
2026
2027 #[test]
2028 fn set_askpass_with_equals_in_value() {
2029 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2030 config.set_host_askpass("myserver", "cmd --opt=val");
2031 let entries = config.host_entries();
2032 assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
2033 }
2034
2035 #[test]
2036 fn set_askpass_with_hash_in_value() {
2037 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2038 config.set_host_askpass("myserver", "vault:secret/data#field");
2039 let entries = config.host_entries();
2040 assert_eq!(entries[0].askpass, Some("vault:secret/data#field".to_string()));
2041 }
2042
2043 #[test]
2044 fn set_askpass_long_op_uri() {
2045 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2046 let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
2047 config.set_host_askpass("myserver", long_uri);
2048 assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
2049 }
2050
2051 #[test]
2052 fn askpass_host_with_multi_pattern_is_skipped() {
2053 let config_str = "Host prod staging\n HostName 10.0.0.1\n";
2056 let mut config = parse_str(config_str);
2057 config.set_host_askpass("prod", "keychain");
2058 assert!(config.host_entries().is_empty());
2060 }
2061
2062 #[test]
2063 fn askpass_survives_directive_reorder() {
2064 let config_str = "\
2066Host myserver
2067 # purple:askpass op://V/I/p
2068 HostName 10.0.0.1
2069 User root
2070";
2071 let config = parse_str(config_str);
2072 let entry = first_block(&config).to_host_entry();
2073 assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
2074 assert_eq!(entry.hostname, "10.0.0.1");
2075 }
2076
2077 #[test]
2078 fn askpass_among_many_purple_comments() {
2079 let config_str = "\
2080Host myserver
2081 HostName 10.0.0.1
2082 # purple:tags prod,us-east
2083 # purple:provider do:12345
2084 # purple:askpass pass:ssh/prod
2085";
2086 let config = parse_str(config_str);
2087 let entry = first_block(&config).to_host_entry();
2088 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2089 assert!(entry.tags.contains(&"prod".to_string()));
2090 }
2091
2092 #[test]
2093 fn meta_empty_when_no_comment() {
2094 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2095 let config = parse_str(config_str);
2096 let meta = first_block(&config).meta();
2097 assert!(meta.is_empty());
2098 }
2099
2100 #[test]
2101 fn meta_parses_key_value_pairs() {
2102 let config_str = "\
2103Host myhost
2104 HostName 1.2.3.4
2105 # purple:meta region=nyc3,plan=s-1vcpu-1gb
2106";
2107 let config = parse_str(config_str);
2108 let meta = first_block(&config).meta();
2109 assert_eq!(meta.len(), 2);
2110 assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
2111 assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
2112 }
2113
2114 #[test]
2115 fn meta_round_trip() {
2116 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2117 let mut config = parse_str(config_str);
2118 let meta = vec![
2119 ("region".to_string(), "fra1".to_string()),
2120 ("plan".to_string(), "cx11".to_string()),
2121 ];
2122 config.set_host_meta("myhost", &meta);
2123 let output = config.serialize();
2124 assert!(output.contains("# purple:meta region=fra1,plan=cx11"));
2125
2126 let config2 = parse_str(&output);
2127 let parsed = first_block(&config2).meta();
2128 assert_eq!(parsed, meta);
2129 }
2130
2131 #[test]
2132 fn meta_replaces_existing() {
2133 let config_str = "\
2134Host myhost
2135 HostName 1.2.3.4
2136 # purple:meta region=old
2137";
2138 let mut config = parse_str(config_str);
2139 config.set_host_meta(
2140 "myhost",
2141 &[("region".to_string(), "new".to_string())],
2142 );
2143 let output = config.serialize();
2144 assert!(!output.contains("region=old"));
2145 assert!(output.contains("region=new"));
2146 }
2147
2148 #[test]
2149 fn meta_removed_when_empty() {
2150 let config_str = "\
2151Host myhost
2152 HostName 1.2.3.4
2153 # purple:meta region=nyc3
2154";
2155 let mut config = parse_str(config_str);
2156 config.set_host_meta("myhost", &[]);
2157 let output = config.serialize();
2158 assert!(!output.contains("purple:meta"));
2159 }
2160
2161 #[test]
2162 fn meta_sanitizes_commas_in_values() {
2163 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2164 let mut config = parse_str(config_str);
2165 let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
2166 config.set_host_meta("myhost", &meta);
2167 let output = config.serialize();
2168 assert!(output.contains("plan=s-1vcpu1gb"));
2170
2171 let config2 = parse_str(&output);
2172 let parsed = first_block(&config2).meta();
2173 assert_eq!(parsed[0].1, "s-1vcpu1gb");
2174 }
2175
2176 #[test]
2177 fn meta_in_host_entry() {
2178 let config_str = "\
2179Host myhost
2180 HostName 1.2.3.4
2181 # purple:meta region=nyc3,plan=s-1vcpu-1gb
2182";
2183 let config = parse_str(config_str);
2184 let entry = first_block(&config).to_host_entry();
2185 assert_eq!(entry.provider_meta.len(), 2);
2186 assert_eq!(entry.provider_meta[0].0, "region");
2187 assert_eq!(entry.provider_meta[1].0, "plan");
2188 }
2189
2190 #[test]
2191 fn repair_absorbed_group_comment() {
2192 let mut config = SshConfigFile {
2194 elements: vec![ConfigElement::HostBlock(HostBlock {
2195 host_pattern: "myserver".to_string(),
2196 raw_host_line: "Host myserver".to_string(),
2197 directives: vec![
2198 Directive {
2199 key: "HostName".to_string(),
2200 value: "10.0.0.1".to_string(),
2201 raw_line: " HostName 10.0.0.1".to_string(),
2202 is_non_directive: false,
2203 },
2204 Directive {
2205 key: String::new(),
2206 value: String::new(),
2207 raw_line: "# purple:group Production".to_string(),
2208 is_non_directive: true,
2209 },
2210 ],
2211 })],
2212 path: PathBuf::from("/tmp/test_config"),
2213 crlf: false,
2214 bom: false,
2215 };
2216 let count = config.repair_absorbed_group_comments();
2217 assert_eq!(count, 1);
2218 assert_eq!(config.elements.len(), 2);
2219 if let ConfigElement::HostBlock(block) = &config.elements[0] {
2221 assert_eq!(block.directives.len(), 1);
2222 assert_eq!(block.directives[0].key, "HostName");
2223 } else {
2224 panic!("Expected HostBlock");
2225 }
2226 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2228 assert_eq!(line, "# purple:group Production");
2229 } else {
2230 panic!("Expected GlobalLine for group comment");
2231 }
2232 }
2233
2234 #[test]
2235 fn repair_strips_trailing_blanks_before_group() {
2236 let mut config = SshConfigFile {
2237 elements: vec![ConfigElement::HostBlock(HostBlock {
2238 host_pattern: "myserver".to_string(),
2239 raw_host_line: "Host myserver".to_string(),
2240 directives: vec![
2241 Directive {
2242 key: "HostName".to_string(),
2243 value: "10.0.0.1".to_string(),
2244 raw_line: " HostName 10.0.0.1".to_string(),
2245 is_non_directive: false,
2246 },
2247 Directive {
2248 key: String::new(),
2249 value: String::new(),
2250 raw_line: "".to_string(),
2251 is_non_directive: true,
2252 },
2253 Directive {
2254 key: String::new(),
2255 value: String::new(),
2256 raw_line: "# purple:group Staging".to_string(),
2257 is_non_directive: true,
2258 },
2259 ],
2260 })],
2261 path: PathBuf::from("/tmp/test_config"),
2262 crlf: false,
2263 bom: false,
2264 };
2265 let count = config.repair_absorbed_group_comments();
2266 assert_eq!(count, 1);
2267 if let ConfigElement::HostBlock(block) = &config.elements[0] {
2269 assert_eq!(block.directives.len(), 1);
2270 } else {
2271 panic!("Expected HostBlock");
2272 }
2273 assert_eq!(config.elements.len(), 3);
2275 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2276 assert!(line.trim().is_empty());
2277 } else {
2278 panic!("Expected blank GlobalLine");
2279 }
2280 if let ConfigElement::GlobalLine(line) = &config.elements[2] {
2281 assert!(line.starts_with("# purple:group"));
2282 } else {
2283 panic!("Expected group GlobalLine");
2284 }
2285 }
2286
2287 #[test]
2288 fn repair_clean_config_returns_zero() {
2289 let mut config = parse_str(
2290 "# purple:group Production\nHost myserver\n HostName 10.0.0.1\n",
2291 );
2292 let count = config.repair_absorbed_group_comments();
2293 assert_eq!(count, 0);
2294 }
2295
2296 #[test]
2297 fn repair_roundtrip_serializes_correctly() {
2298 let mut config = SshConfigFile {
2300 elements: vec![
2301 ConfigElement::HostBlock(HostBlock {
2302 host_pattern: "server1".to_string(),
2303 raw_host_line: "Host server1".to_string(),
2304 directives: vec![
2305 Directive {
2306 key: "HostName".to_string(),
2307 value: "10.0.0.1".to_string(),
2308 raw_line: " HostName 10.0.0.1".to_string(),
2309 is_non_directive: false,
2310 },
2311 Directive {
2312 key: String::new(),
2313 value: String::new(),
2314 raw_line: "".to_string(),
2315 is_non_directive: true,
2316 },
2317 Directive {
2318 key: String::new(),
2319 value: String::new(),
2320 raw_line: "# purple:group Staging".to_string(),
2321 is_non_directive: true,
2322 },
2323 ],
2324 }),
2325 ConfigElement::HostBlock(HostBlock {
2326 host_pattern: "server2".to_string(),
2327 raw_host_line: "Host server2".to_string(),
2328 directives: vec![Directive {
2329 key: "HostName".to_string(),
2330 value: "10.0.0.2".to_string(),
2331 raw_line: " HostName 10.0.0.2".to_string(),
2332 is_non_directive: false,
2333 }],
2334 }),
2335 ],
2336 path: PathBuf::from("/tmp/test_config"),
2337 crlf: false,
2338 bom: false,
2339 };
2340 let count = config.repair_absorbed_group_comments();
2341 assert_eq!(count, 1);
2342 let output = config.serialize();
2343 let expected = "\
2345Host server1
2346 HostName 10.0.0.1
2347
2348# purple:group Staging
2349Host server2
2350 HostName 10.0.0.2
2351";
2352 assert_eq!(output, expected);
2353 }
2354
2355 #[test]
2360 fn delete_last_provider_host_removes_group_header() {
2361 let config_str = "\
2362# purple:group DigitalOcean
2363Host do-web
2364 HostName 1.2.3.4
2365 # purple:provider digitalocean:123
2366";
2367 let mut config = parse_str(config_str);
2368 config.delete_host("do-web");
2369 let has_header = config.elements.iter().any(|e| {
2370 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group"))
2371 });
2372 assert!(!has_header, "Group header should be removed when last provider host is deleted");
2373 }
2374
2375 #[test]
2376 fn delete_one_of_multiple_provider_hosts_preserves_group_header() {
2377 let config_str = "\
2378# purple:group DigitalOcean
2379Host do-web
2380 HostName 1.2.3.4
2381 # purple:provider digitalocean:123
2382
2383Host do-db
2384 HostName 5.6.7.8
2385 # purple:provider digitalocean:456
2386";
2387 let mut config = parse_str(config_str);
2388 config.delete_host("do-web");
2389 let has_header = config.elements.iter().any(|e| {
2390 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2391 });
2392 assert!(has_header, "Group header should be preserved when other provider hosts remain");
2393 assert_eq!(config.host_entries().len(), 1);
2394 }
2395
2396 #[test]
2397 fn delete_non_provider_host_leaves_group_headers() {
2398 let config_str = "\
2399Host personal
2400 HostName 10.0.0.1
2401
2402# purple:group DigitalOcean
2403Host do-web
2404 HostName 1.2.3.4
2405 # purple:provider digitalocean:123
2406";
2407 let mut config = parse_str(config_str);
2408 config.delete_host("personal");
2409 let has_header = config.elements.iter().any(|e| {
2410 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2411 });
2412 assert!(has_header, "Group header should not be affected by deleting a non-provider host");
2413 assert_eq!(config.host_entries().len(), 1);
2414 }
2415
2416 #[test]
2417 fn delete_host_undoable_keeps_group_header_for_undo() {
2418 let config_str = "\
2422# purple:group Vultr
2423Host vultr-web
2424 HostName 2.3.4.5
2425 # purple:provider vultr:789
2426";
2427 let mut config = parse_str(config_str);
2428 let result = config.delete_host_undoable("vultr-web");
2429 assert!(result.is_some());
2430 let has_header = config.elements.iter().any(|e| {
2431 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group"))
2432 });
2433 assert!(has_header, "Group header should be kept for undo");
2434 }
2435
2436 #[test]
2437 fn delete_host_undoable_preserves_header_when_others_remain() {
2438 let config_str = "\
2439# purple:group AWS EC2
2440Host aws-web
2441 HostName 3.4.5.6
2442 # purple:provider aws:i-111
2443
2444Host aws-db
2445 HostName 7.8.9.0
2446 # purple:provider aws:i-222
2447";
2448 let mut config = parse_str(config_str);
2449 let result = config.delete_host_undoable("aws-web");
2450 assert!(result.is_some());
2451 let has_header = config.elements.iter().any(|e| {
2452 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group AWS EC2"))
2453 });
2454 assert!(has_header, "Group header preserved when other provider hosts remain (undoable)");
2455 }
2456
2457 #[test]
2458 fn delete_host_undoable_returns_original_position_for_undo() {
2459 let config_str = "\
2462# purple:group Vultr
2463Host vultr-web
2464 HostName 2.3.4.5
2465 # purple:provider vultr:789
2466
2467Host manual
2468 HostName 10.0.0.1
2469";
2470 let mut config = parse_str(config_str);
2471 let (element, pos) = config.delete_host_undoable("vultr-web").unwrap();
2472 assert_eq!(pos, 1, "Position should be the original host index");
2474 config.insert_host_at(element, pos);
2476 let output = config.serialize();
2478 assert!(output.contains("# purple:group Vultr"), "Group header should be present");
2479 assert!(output.contains("Host vultr-web"), "Host should be restored");
2480 assert!(output.contains("Host manual"), "Manual host should survive");
2481 assert_eq!(config_str, output);
2482 }
2483
2484 #[test]
2489 fn add_host_inserts_before_trailing_wildcard() {
2490 let config_str = "\
2491Host existing
2492 HostName 10.0.0.1
2493
2494Host *
2495 ServerAliveInterval 60
2496";
2497 let mut config = parse_str(config_str);
2498 let entry = HostEntry {
2499 alias: "newhost".to_string(),
2500 hostname: "10.0.0.2".to_string(),
2501 port: 22,
2502 ..Default::default()
2503 };
2504 config.add_host(&entry);
2505 let output = config.serialize();
2506 let new_pos = output.find("Host newhost").unwrap();
2507 let wildcard_pos = output.find("Host *").unwrap();
2508 assert!(
2509 new_pos < wildcard_pos,
2510 "New host should appear before Host *: {}",
2511 output
2512 );
2513 let existing_pos = output.find("Host existing").unwrap();
2514 assert!(existing_pos < new_pos);
2515 }
2516
2517 #[test]
2518 fn add_host_appends_when_no_wildcards() {
2519 let config_str = "\
2520Host existing
2521 HostName 10.0.0.1
2522";
2523 let mut config = parse_str(config_str);
2524 let entry = HostEntry {
2525 alias: "newhost".to_string(),
2526 hostname: "10.0.0.2".to_string(),
2527 port: 22,
2528 ..Default::default()
2529 };
2530 config.add_host(&entry);
2531 let output = config.serialize();
2532 let existing_pos = output.find("Host existing").unwrap();
2533 let new_pos = output.find("Host newhost").unwrap();
2534 assert!(existing_pos < new_pos, "New host should be appended at end");
2535 }
2536
2537 #[test]
2538 fn add_host_appends_when_wildcard_at_beginning() {
2539 let config_str = "\
2541Host *
2542 ServerAliveInterval 60
2543
2544Host existing
2545 HostName 10.0.0.1
2546";
2547 let mut config = parse_str(config_str);
2548 let entry = HostEntry {
2549 alias: "newhost".to_string(),
2550 hostname: "10.0.0.2".to_string(),
2551 port: 22,
2552 ..Default::default()
2553 };
2554 config.add_host(&entry);
2555 let output = config.serialize();
2556 let existing_pos = output.find("Host existing").unwrap();
2557 let new_pos = output.find("Host newhost").unwrap();
2558 assert!(
2559 existing_pos < new_pos,
2560 "New host should be appended at end when wildcard is at top: {}",
2561 output
2562 );
2563 }
2564
2565 #[test]
2566 fn add_host_inserts_before_trailing_pattern_host() {
2567 let config_str = "\
2568Host existing
2569 HostName 10.0.0.1
2570
2571Host *.example.com
2572 ProxyJump bastion
2573";
2574 let mut config = parse_str(config_str);
2575 let entry = HostEntry {
2576 alias: "newhost".to_string(),
2577 hostname: "10.0.0.2".to_string(),
2578 port: 22,
2579 ..Default::default()
2580 };
2581 config.add_host(&entry);
2582 let output = config.serialize();
2583 let new_pos = output.find("Host newhost").unwrap();
2584 let pattern_pos = output.find("Host *.example.com").unwrap();
2585 assert!(
2586 new_pos < pattern_pos,
2587 "New host should appear before pattern host: {}",
2588 output
2589 );
2590 }
2591
2592 #[test]
2593 fn add_host_no_triple_blank_lines() {
2594 let config_str = "\
2595Host existing
2596 HostName 10.0.0.1
2597
2598Host *
2599 ServerAliveInterval 60
2600";
2601 let mut config = parse_str(config_str);
2602 let entry = HostEntry {
2603 alias: "newhost".to_string(),
2604 hostname: "10.0.0.2".to_string(),
2605 port: 22,
2606 ..Default::default()
2607 };
2608 config.add_host(&entry);
2609 let output = config.serialize();
2610 assert!(
2611 !output.contains("\n\n\n"),
2612 "Should not have triple blank lines: {}",
2613 output
2614 );
2615 }
2616
2617 #[test]
2618 fn provider_group_display_name_matches_providers_mod() {
2619 let providers = [
2624 "digitalocean", "vultr", "linode", "hetzner", "upcloud",
2625 "proxmox", "aws", "scaleway", "gcp", "azure", "tailscale",
2626 ];
2627 for name in &providers {
2628 assert_eq!(
2629 provider_group_display_name(name),
2630 crate::providers::provider_display_name(name),
2631 "Display name mismatch for provider '{}': model.rs has '{}' but providers/mod.rs has '{}'",
2632 name,
2633 provider_group_display_name(name),
2634 crate::providers::provider_display_name(name),
2635 );
2636 }
2637 }
2638
2639}