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_tags: Vec<String>,
100 pub has_provider_tags: bool,
102 pub provider: Option<String>,
104 pub tunnel_count: u16,
106 pub askpass: Option<String>,
108 pub provider_meta: Vec<(String, String)>,
110}
111
112impl Default for HostEntry {
113 fn default() -> Self {
114 Self {
115 alias: String::new(),
116 hostname: String::new(),
117 user: String::new(),
118 port: 22,
119 identity_file: String::new(),
120 proxy_jump: String::new(),
121 source_file: None,
122 tags: Vec::new(),
123 provider_tags: Vec::new(),
124 has_provider_tags: false,
125 provider: None,
126 tunnel_count: 0,
127 askpass: None,
128 provider_meta: Vec::new(),
129 }
130 }
131}
132
133impl HostEntry {
134 pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
139 let escaped = self.alias.replace('\'', "'\\''");
140 let default = dirs::home_dir()
141 .map(|h| h.join(".ssh/config"))
142 .unwrap_or_default();
143 if config_path == default {
144 format!("ssh -- '{}'", escaped)
145 } else {
146 let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
147 format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
148 }
149 }
150}
151
152pub fn is_host_pattern(pattern: &str) -> bool {
156 pattern.contains('*')
157 || pattern.contains('?')
158 || pattern.contains('[')
159 || pattern.starts_with('!')
160 || pattern.contains(' ')
161 || pattern.contains('\t')
162}
163
164impl HostBlock {
165 fn content_end(&self) -> usize {
167 let mut pos = self.directives.len();
168 while pos > 0 {
169 if self.directives[pos - 1].is_non_directive
170 && self.directives[pos - 1].raw_line.trim().is_empty()
171 {
172 pos -= 1;
173 } else {
174 break;
175 }
176 }
177 pos
178 }
179
180 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
182 let end = self.content_end();
183 self.directives.drain(end..).collect()
184 }
185
186 fn ensure_trailing_blank(&mut self) {
188 self.pop_trailing_blanks();
189 self.directives.push(Directive {
190 key: String::new(),
191 value: String::new(),
192 raw_line: String::new(),
193 is_non_directive: true,
194 });
195 }
196
197 fn detect_indent(&self) -> String {
199 for d in &self.directives {
200 if !d.is_non_directive && !d.raw_line.is_empty() {
201 let trimmed = d.raw_line.trim_start();
202 let indent_len = d.raw_line.len() - trimmed.len();
203 if indent_len > 0 {
204 return d.raw_line[..indent_len].to_string();
205 }
206 }
207 }
208 " ".to_string()
209 }
210
211 pub fn tags(&self) -> Vec<String> {
213 for d in &self.directives {
214 if d.is_non_directive {
215 let trimmed = d.raw_line.trim();
216 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
217 return rest
218 .split(',')
219 .map(|t| t.trim().to_string())
220 .filter(|t| !t.is_empty())
221 .collect();
222 }
223 }
224 }
225 Vec::new()
226 }
227
228 pub fn provider_tags(&self) -> Vec<String> {
230 for d in &self.directives {
231 if d.is_non_directive {
232 let trimmed = d.raw_line.trim();
233 if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
234 return rest
235 .split(',')
236 .map(|t| t.trim().to_string())
237 .filter(|t| !t.is_empty())
238 .collect();
239 }
240 }
241 }
242 Vec::new()
243 }
244
245 pub fn has_provider_tags_comment(&self) -> bool {
248 self.directives.iter().any(|d| {
249 d.is_non_directive && {
250 let t = d.raw_line.trim();
251 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
252 }
253 })
254 }
255
256 pub fn provider(&self) -> Option<(String, String)> {
259 for d in &self.directives {
260 if d.is_non_directive {
261 let trimmed = d.raw_line.trim();
262 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
263 if let Some((name, id)) = rest.split_once(':') {
264 return Some((name.trim().to_string(), id.trim().to_string()));
265 }
266 }
267 }
268 }
269 None
270 }
271
272 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
274 let indent = self.detect_indent();
275 self.directives.retain(|d| {
276 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
277 });
278 let pos = self.content_end();
279 self.directives.insert(
280 pos,
281 Directive {
282 key: String::new(),
283 value: String::new(),
284 raw_line: format!(
285 "{}# purple:provider {}:{}",
286 indent, provider_name, server_id
287 ),
288 is_non_directive: true,
289 },
290 );
291 }
292
293 pub fn askpass(&self) -> Option<String> {
295 for d in &self.directives {
296 if d.is_non_directive {
297 let trimmed = d.raw_line.trim();
298 if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
299 let val = rest.trim();
300 if !val.is_empty() {
301 return Some(val.to_string());
302 }
303 }
304 }
305 }
306 None
307 }
308
309 pub fn set_askpass(&mut self, source: &str) {
312 let indent = self.detect_indent();
313 self.directives.retain(|d| {
314 !(d.is_non_directive && {
315 let t = d.raw_line.trim();
316 t == "# purple:askpass" || t.starts_with("# purple:askpass ")
317 })
318 });
319 if !source.is_empty() {
320 let pos = self.content_end();
321 self.directives.insert(
322 pos,
323 Directive {
324 key: String::new(),
325 value: String::new(),
326 raw_line: format!("{}# purple:askpass {}", indent, source),
327 is_non_directive: true,
328 },
329 );
330 }
331 }
332
333 pub fn meta(&self) -> Vec<(String, String)> {
336 for d in &self.directives {
337 if d.is_non_directive {
338 let trimmed = d.raw_line.trim();
339 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
340 return rest
341 .split(',')
342 .filter_map(|pair| {
343 let (k, v) = pair.split_once('=')?;
344 let k = k.trim();
345 let v = v.trim();
346 if k.is_empty() {
347 None
348 } else {
349 Some((k.to_string(), v.to_string()))
350 }
351 })
352 .collect();
353 }
354 }
355 }
356 Vec::new()
357 }
358
359 pub fn set_meta(&mut self, meta: &[(String, String)]) {
362 let indent = self.detect_indent();
363 self.directives.retain(|d| {
364 !(d.is_non_directive && {
365 let t = d.raw_line.trim();
366 t == "# purple:meta" || t.starts_with("# purple:meta ")
367 })
368 });
369 if !meta.is_empty() {
370 let encoded: Vec<String> = meta
371 .iter()
372 .map(|(k, v)| {
373 let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
374 let clean_v = Self::sanitize_tag(&v.replace(',', ""));
375 format!("{}={}", clean_k, clean_v)
376 })
377 .collect();
378 let pos = self.content_end();
379 self.directives.insert(
380 pos,
381 Directive {
382 key: String::new(),
383 value: String::new(),
384 raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
385 is_non_directive: true,
386 },
387 );
388 }
389 }
390
391 fn sanitize_tag(tag: &str) -> String {
394 tag.chars()
395 .filter(|c| {
396 !c.is_control()
397 && *c != ','
398 && !('\u{200B}'..='\u{200F}').contains(c) && !('\u{202A}'..='\u{202E}').contains(c) && !('\u{2066}'..='\u{2069}').contains(c) && *c != '\u{FEFF}' })
403 .take(128)
404 .collect()
405 }
406
407 pub fn set_tags(&mut self, tags: &[String]) {
409 let indent = self.detect_indent();
410 self.directives.retain(|d| {
411 !(d.is_non_directive && {
412 let t = d.raw_line.trim();
413 t == "# purple:tags" || t.starts_with("# purple:tags ")
414 })
415 });
416 let sanitized: Vec<String> = tags
417 .iter()
418 .map(|t| Self::sanitize_tag(t))
419 .filter(|t| !t.is_empty())
420 .collect();
421 if !sanitized.is_empty() {
422 let pos = self.content_end();
423 self.directives.insert(
424 pos,
425 Directive {
426 key: String::new(),
427 value: String::new(),
428 raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
429 is_non_directive: true,
430 },
431 );
432 }
433 }
434
435 pub fn set_provider_tags(&mut self, tags: &[String]) {
438 let indent = self.detect_indent();
439 self.directives.retain(|d| {
440 !(d.is_non_directive && {
441 let t = d.raw_line.trim();
442 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
443 })
444 });
445 let sanitized: Vec<String> = tags
446 .iter()
447 .map(|t| Self::sanitize_tag(t))
448 .filter(|t| !t.is_empty())
449 .collect();
450 let raw = if sanitized.is_empty() {
451 format!("{}# purple:provider_tags", indent)
452 } else {
453 format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
454 };
455 let pos = self.content_end();
456 self.directives.insert(
457 pos,
458 Directive {
459 key: String::new(),
460 value: String::new(),
461 raw_line: raw,
462 is_non_directive: true,
463 },
464 );
465 }
466
467 pub fn to_host_entry(&self) -> HostEntry {
469 let mut entry = HostEntry {
470 alias: self.host_pattern.clone(),
471 port: 22,
472 ..Default::default()
473 };
474 for d in &self.directives {
475 if d.is_non_directive {
476 continue;
477 }
478 if d.key.eq_ignore_ascii_case("hostname") {
479 entry.hostname = d.value.clone();
480 } else if d.key.eq_ignore_ascii_case("user") {
481 entry.user = d.value.clone();
482 } else if d.key.eq_ignore_ascii_case("port") {
483 entry.port = d.value.parse().unwrap_or(22);
484 } else if d.key.eq_ignore_ascii_case("identityfile") {
485 if entry.identity_file.is_empty() {
486 entry.identity_file = d.value.clone();
487 }
488 } else if d.key.eq_ignore_ascii_case("proxyjump") {
489 entry.proxy_jump = d.value.clone();
490 }
491 }
492 entry.tags = self.tags();
493 entry.provider_tags = self.provider_tags();
494 entry.has_provider_tags = self.has_provider_tags_comment();
495 entry.provider = self.provider().map(|(name, _)| name);
496 entry.tunnel_count = self.tunnel_count();
497 entry.askpass = self.askpass();
498 entry.provider_meta = self.meta();
499 entry
500 }
501
502 pub fn tunnel_count(&self) -> u16 {
504 let count = self
505 .directives
506 .iter()
507 .filter(|d| {
508 !d.is_non_directive
509 && (d.key.eq_ignore_ascii_case("localforward")
510 || d.key.eq_ignore_ascii_case("remoteforward")
511 || d.key.eq_ignore_ascii_case("dynamicforward"))
512 })
513 .count();
514 count.min(u16::MAX as usize) as u16
515 }
516
517 #[allow(dead_code)]
519 pub fn has_tunnels(&self) -> bool {
520 self.directives.iter().any(|d| {
521 !d.is_non_directive
522 && (d.key.eq_ignore_ascii_case("localforward")
523 || d.key.eq_ignore_ascii_case("remoteforward")
524 || d.key.eq_ignore_ascii_case("dynamicforward"))
525 })
526 }
527
528 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
530 self.directives
531 .iter()
532 .filter(|d| !d.is_non_directive)
533 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
534 .collect()
535 }
536}
537
538impl SshConfigFile {
539 pub fn host_entries(&self) -> Vec<HostEntry> {
541 let mut entries = Vec::new();
542 Self::collect_host_entries(&self.elements, &mut entries);
543 entries
544 }
545
546 pub fn include_paths(&self) -> Vec<PathBuf> {
548 let mut paths = Vec::new();
549 Self::collect_include_paths(&self.elements, &mut paths);
550 paths
551 }
552
553 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
554 for e in elements {
555 if let ConfigElement::Include(include) = e {
556 for file in &include.resolved_files {
557 paths.push(file.path.clone());
558 Self::collect_include_paths(&file.elements, paths);
559 }
560 }
561 }
562 }
563
564 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
567 let config_dir = self.path.parent();
568 let mut seen = std::collections::HashSet::new();
569 let mut dirs = Vec::new();
570 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
571 dirs
572 }
573
574 fn collect_include_glob_dirs(
575 elements: &[ConfigElement],
576 config_dir: Option<&std::path::Path>,
577 seen: &mut std::collections::HashSet<PathBuf>,
578 dirs: &mut Vec<PathBuf>,
579 ) {
580 for e in elements {
581 if let ConfigElement::Include(include) = e {
582 for single in Self::split_include_patterns(&include.pattern) {
584 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
585 let resolved = if expanded.starts_with('/') {
586 PathBuf::from(&expanded)
587 } else if let Some(dir) = config_dir {
588 dir.join(&expanded)
589 } else {
590 continue;
591 };
592 if let Some(parent) = resolved.parent() {
593 let parent = parent.to_path_buf();
594 if seen.insert(parent.clone()) {
595 dirs.push(parent);
596 }
597 }
598 }
599 for file in &include.resolved_files {
601 Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
602 }
603 }
604 }
605 }
606
607 pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
610 let active_providers: std::collections::HashSet<String> = self
612 .elements
613 .iter()
614 .filter_map(|e| {
615 if let ConfigElement::HostBlock(block) = e {
616 block
617 .provider()
618 .map(|(name, _)| provider_group_display_name(&name).to_string())
619 } else {
620 None
621 }
622 })
623 .collect();
624
625 let mut removed = 0;
626 self.elements.retain(|e| {
627 if let ConfigElement::GlobalLine(line) = e {
628 if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
629 if !active_providers.contains(rest.trim()) {
630 removed += 1;
631 return false;
632 }
633 }
634 }
635 true
636 });
637 removed
638 }
639
640 pub fn repair_absorbed_group_comments(&mut self) -> usize {
644 let mut repaired = 0;
645 let mut idx = 0;
646 while idx < self.elements.len() {
647 let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
648 block
649 .directives
650 .iter()
651 .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
652 } else {
653 false
654 };
655
656 if !needs_repair {
657 idx += 1;
658 continue;
659 }
660
661 let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
663 block
664 } else {
665 unreachable!()
666 };
667
668 let group_idx = block
669 .directives
670 .iter()
671 .position(|d| {
672 d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
673 })
674 .unwrap();
675
676 let mut keep_end = group_idx;
678 while keep_end > 0
679 && block.directives[keep_end - 1].is_non_directive
680 && block.directives[keep_end - 1].raw_line.trim().is_empty()
681 {
682 keep_end -= 1;
683 }
684
685 let extracted: Vec<ConfigElement> = block
687 .directives
688 .drain(keep_end..)
689 .map(|d| ConfigElement::GlobalLine(d.raw_line))
690 .collect();
691
692 let insert_at = idx + 1;
694 for (i, elem) in extracted.into_iter().enumerate() {
695 self.elements.insert(insert_at + i, elem);
696 }
697
698 repaired += 1;
699 idx = insert_at;
701 while idx < self.elements.len() {
703 if let ConfigElement::HostBlock(_) = &self.elements[idx] {
704 break;
705 }
706 idx += 1;
707 }
708 }
709 repaired
710 }
711
712 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
714 for e in elements {
715 match e {
716 ConfigElement::HostBlock(block) => {
717 if is_host_pattern(&block.host_pattern) {
718 continue;
719 }
720 entries.push(block.to_host_entry());
721 }
722 ConfigElement::Include(include) => {
723 for file in &include.resolved_files {
724 let start = entries.len();
725 Self::collect_host_entries(&file.elements, entries);
726 for entry in &mut entries[start..] {
727 if entry.source_file.is_none() {
728 entry.source_file = Some(file.path.clone());
729 }
730 }
731 }
732 }
733 ConfigElement::GlobalLine(_) => {}
734 }
735 }
736 }
737
738 pub fn has_host(&self, alias: &str) -> bool {
741 Self::has_host_in_elements(&self.elements, alias)
742 }
743
744 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
745 for e in elements {
746 match e {
747 ConfigElement::HostBlock(block) => {
748 if block.host_pattern.split_whitespace().any(|p| p == alias) {
749 return true;
750 }
751 }
752 ConfigElement::Include(include) => {
753 for file in &include.resolved_files {
754 if Self::has_host_in_elements(&file.elements, alias) {
755 return true;
756 }
757 }
758 }
759 ConfigElement::GlobalLine(_) => {}
760 }
761 }
762 false
763 }
764
765 pub fn is_included_host(&self, alias: &str) -> bool {
768 for e in &self.elements {
770 match e {
771 ConfigElement::HostBlock(block) => {
772 if block.host_pattern.split_whitespace().any(|p| p == alias) {
773 return false;
774 }
775 }
776 ConfigElement::Include(include) => {
777 for file in &include.resolved_files {
778 if Self::has_host_in_elements(&file.elements, alias) {
779 return true;
780 }
781 }
782 }
783 ConfigElement::GlobalLine(_) => {}
784 }
785 }
786 false
787 }
788
789 pub fn add_host(&mut self, entry: &HostEntry) {
794 let block = Self::entry_to_block(entry);
795 let insert_pos = self.find_trailing_pattern_start();
796
797 if let Some(pos) = insert_pos {
798 let needs_blank_before = pos > 0
800 && !matches!(
801 self.elements.get(pos - 1),
802 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
803 );
804 let mut idx = pos;
805 if needs_blank_before {
806 self.elements
807 .insert(idx, ConfigElement::GlobalLine(String::new()));
808 idx += 1;
809 }
810 self.elements.insert(idx, ConfigElement::HostBlock(block));
811 let after = idx + 1;
813 if after < self.elements.len()
814 && !matches!(
815 self.elements.get(after),
816 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
817 )
818 {
819 self.elements
820 .insert(after, ConfigElement::GlobalLine(String::new()));
821 }
822 } else {
823 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
825 self.elements.push(ConfigElement::GlobalLine(String::new()));
826 }
827 self.elements.push(ConfigElement::HostBlock(block));
828 }
829 }
830
831 fn find_trailing_pattern_start(&self) -> Option<usize> {
836 let mut first_pattern_pos = None;
837 for i in (0..self.elements.len()).rev() {
838 match &self.elements[i] {
839 ConfigElement::HostBlock(block) => {
840 if is_host_pattern(&block.host_pattern) {
841 first_pattern_pos = Some(i);
842 } else {
843 break;
845 }
846 }
847 ConfigElement::GlobalLine(_) => {
848 if first_pattern_pos.is_some() {
850 first_pattern_pos = Some(i);
851 }
852 }
853 ConfigElement::Include(_) => break,
854 }
855 }
856 first_pattern_pos.filter(|&pos| pos > 0)
858 }
859
860 pub fn last_element_has_trailing_blank(&self) -> bool {
862 match self.elements.last() {
863 Some(ConfigElement::HostBlock(block)) => block
864 .directives
865 .last()
866 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
867 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
868 _ => false,
869 }
870 }
871
872 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
875 for element in &mut self.elements {
876 if let ConfigElement::HostBlock(block) = element {
877 if block.host_pattern == old_alias {
878 if entry.alias != block.host_pattern {
880 block.host_pattern = entry.alias.clone();
881 block.raw_host_line = format!("Host {}", entry.alias);
882 }
883
884 Self::upsert_directive(block, "HostName", &entry.hostname);
886 Self::upsert_directive(block, "User", &entry.user);
887 if entry.port != 22 {
888 Self::upsert_directive(block, "Port", &entry.port.to_string());
889 } else {
890 block
892 .directives
893 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
894 }
895 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
896 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
897 return;
898 }
899 }
900 }
901 }
902
903 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
905 if value.is_empty() {
906 block
907 .directives
908 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
909 return;
910 }
911 let indent = block.detect_indent();
912 for d in &mut block.directives {
913 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
914 if d.value != value {
916 d.value = value.to_string();
917 let trimmed = d.raw_line.trim_start();
923 let after_key = &trimmed[d.key.len()..];
924 let sep = if after_key.trim_start().starts_with('=') {
925 let eq_pos = after_key.find('=').unwrap();
926 let after_eq = &after_key[eq_pos + 1..];
927 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
928 after_key[..eq_pos + 1 + trailing_ws].to_string()
929 } else {
930 " ".to_string()
931 };
932 let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
934 d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
935 }
936 return;
937 }
938 }
939 let pos = block.content_end();
941 block.directives.insert(
942 pos,
943 Directive {
944 key: key.to_string(),
945 value: value.to_string(),
946 raw_line: format!("{}{} {}", indent, key, value),
947 is_non_directive: false,
948 },
949 );
950 }
951
952 fn extract_inline_comment(raw_line: &str, key: &str) -> String {
956 let trimmed = raw_line.trim_start();
957 if trimmed.len() <= key.len() {
958 return String::new();
959 }
960 let after_key = &trimmed[key.len()..];
962 let rest = after_key.trim_start();
963 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
964 let bytes = rest.as_bytes();
966 let mut in_quote = false;
967 for i in 0..bytes.len() {
968 if bytes[i] == b'"' {
969 in_quote = !in_quote;
970 } else if !in_quote
971 && bytes[i] == b'#'
972 && i > 0
973 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
974 {
975 let clean_end = rest[..i].trim_end().len();
977 return rest[clean_end..].to_string();
978 }
979 }
980 String::new()
981 }
982
983 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
985 for element in &mut self.elements {
986 if let ConfigElement::HostBlock(block) = element {
987 if block.host_pattern == alias {
988 block.set_provider(provider_name, server_id);
989 return;
990 }
991 }
992 }
993 }
994
995 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
999 let mut results = Vec::new();
1000 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1001 results
1002 }
1003
1004 fn collect_provider_hosts(
1005 elements: &[ConfigElement],
1006 provider_name: &str,
1007 results: &mut Vec<(String, String)>,
1008 ) {
1009 for element in elements {
1010 match element {
1011 ConfigElement::HostBlock(block) => {
1012 if let Some((name, id)) = block.provider() {
1013 if name == provider_name {
1014 results.push((block.host_pattern.clone(), id));
1015 }
1016 }
1017 }
1018 ConfigElement::Include(include) => {
1019 for file in &include.resolved_files {
1020 Self::collect_provider_hosts(&file.elements, provider_name, results);
1021 }
1022 }
1023 ConfigElement::GlobalLine(_) => {}
1024 }
1025 }
1026 }
1027
1028 fn values_match(a: &str, b: &str) -> bool {
1031 a.split_whitespace().eq(b.split_whitespace())
1032 }
1033
1034 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1038 for element in &mut self.elements {
1039 if let ConfigElement::HostBlock(block) = element {
1040 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1041 let indent = block.detect_indent();
1042 let pos = block.content_end();
1043 block.directives.insert(
1044 pos,
1045 Directive {
1046 key: directive_key.to_string(),
1047 value: value.to_string(),
1048 raw_line: format!("{}{} {}", indent, directive_key, value),
1049 is_non_directive: false,
1050 },
1051 );
1052 return;
1053 }
1054 }
1055 }
1056 }
1057
1058 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1063 for element in &mut self.elements {
1064 if let ConfigElement::HostBlock(block) = element {
1065 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1066 if let Some(pos) = block.directives.iter().position(|d| {
1067 !d.is_non_directive
1068 && d.key.eq_ignore_ascii_case(directive_key)
1069 && Self::values_match(&d.value, value)
1070 }) {
1071 block.directives.remove(pos);
1072 return true;
1073 }
1074 return false;
1075 }
1076 }
1077 }
1078 false
1079 }
1080
1081 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1084 for element in &self.elements {
1085 if let ConfigElement::HostBlock(block) = element {
1086 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1087 return block.directives.iter().any(|d| {
1088 !d.is_non_directive
1089 && d.key.eq_ignore_ascii_case(directive_key)
1090 && Self::values_match(&d.value, value)
1091 });
1092 }
1093 }
1094 }
1095 false
1096 }
1097
1098 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1102 Self::find_tunnel_directives_in(&self.elements, alias)
1103 }
1104
1105 fn find_tunnel_directives_in(
1106 elements: &[ConfigElement],
1107 alias: &str,
1108 ) -> Vec<crate::tunnel::TunnelRule> {
1109 for element in elements {
1110 match element {
1111 ConfigElement::HostBlock(block) => {
1112 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1113 return block.tunnel_directives();
1114 }
1115 }
1116 ConfigElement::Include(include) => {
1117 for file in &include.resolved_files {
1118 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1119 if !rules.is_empty() {
1120 return rules;
1121 }
1122 }
1123 }
1124 ConfigElement::GlobalLine(_) => {}
1125 }
1126 }
1127 Vec::new()
1128 }
1129
1130 pub fn deduplicate_alias(&self, base: &str) -> String {
1132 self.deduplicate_alias_excluding(base, None)
1133 }
1134
1135 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1138 let is_taken = |alias: &str| {
1139 if exclude == Some(alias) {
1140 return false;
1141 }
1142 self.has_host(alias)
1143 };
1144 if !is_taken(base) {
1145 return base.to_string();
1146 }
1147 for n in 2..=9999 {
1148 let candidate = format!("{}-{}", base, n);
1149 if !is_taken(&candidate) {
1150 return candidate;
1151 }
1152 }
1153 format!("{}-{}", base, std::process::id())
1155 }
1156
1157 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1159 for element in &mut self.elements {
1160 if let ConfigElement::HostBlock(block) = element {
1161 if block.host_pattern == alias {
1162 block.set_tags(tags);
1163 return;
1164 }
1165 }
1166 }
1167 }
1168
1169 pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1171 for element in &mut self.elements {
1172 if let ConfigElement::HostBlock(block) = element {
1173 if block.host_pattern == alias {
1174 block.set_provider_tags(tags);
1175 return;
1176 }
1177 }
1178 }
1179 }
1180
1181 pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1183 for element in &mut self.elements {
1184 if let ConfigElement::HostBlock(block) = element {
1185 if block.host_pattern == alias {
1186 block.set_askpass(source);
1187 return;
1188 }
1189 }
1190 }
1191 }
1192
1193 pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1195 for element in &mut self.elements {
1196 if let ConfigElement::HostBlock(block) = element {
1197 if block.host_pattern == alias {
1198 block.set_meta(meta);
1199 return;
1200 }
1201 }
1202 }
1203 }
1204
1205 #[allow(dead_code)]
1207 pub fn delete_host(&mut self, alias: &str) {
1208 let provider_name = self.elements.iter().find_map(|e| {
1211 if let ConfigElement::HostBlock(b) = e {
1212 if b.host_pattern == alias {
1213 return b.provider().map(|(name, _)| name);
1214 }
1215 }
1216 None
1217 });
1218
1219 self.elements.retain(|e| match e {
1220 ConfigElement::HostBlock(block) => block.host_pattern != alias,
1221 _ => true,
1222 });
1223
1224 if let Some(name) = provider_name {
1226 self.remove_orphaned_group_header(&name);
1227 }
1228
1229 self.elements.dedup_by(|a, b| {
1231 matches!(
1232 (&*a, &*b),
1233 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1234 if x.trim().is_empty() && y.trim().is_empty()
1235 )
1236 });
1237 }
1238
1239 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1244 let pos = self
1245 .elements
1246 .iter()
1247 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
1248 let element = self.elements.remove(pos);
1249 Some((element, pos))
1250 }
1251
1252 #[allow(dead_code)]
1254 fn find_group_header_position(&self, provider_name: &str) -> Option<usize> {
1255 let display = provider_group_display_name(provider_name);
1256 let header = format!("# purple:group {}", display);
1257 self.elements
1258 .iter()
1259 .position(|e| matches!(e, ConfigElement::GlobalLine(line) if *line == header))
1260 }
1261
1262 fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1265 if self.find_hosts_by_provider(provider_name).is_empty() {
1266 let display = provider_group_display_name(provider_name);
1267 let header = format!("# purple:group {}", display);
1268 self.elements
1269 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1270 }
1271 }
1272
1273 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1275 let pos = position.min(self.elements.len());
1276 self.elements.insert(pos, element);
1277 }
1278
1279 pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1283 let mut last_pos = None;
1284 for (i, element) in self.elements.iter().enumerate() {
1285 if let ConfigElement::HostBlock(block) = element {
1286 if let Some((name, _)) = block.provider() {
1287 if name == provider_name {
1288 last_pos = Some(i);
1289 }
1290 }
1291 }
1292 }
1293 last_pos.map(|p| p + 1)
1295 }
1296
1297 #[allow(dead_code)]
1299 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1300 let pos_a = self
1301 .elements
1302 .iter()
1303 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1304 let pos_b = self
1305 .elements
1306 .iter()
1307 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1308 if let (Some(a), Some(b)) = (pos_a, pos_b) {
1309 if a == b {
1310 return false;
1311 }
1312 let (first, second) = (a.min(b), a.max(b));
1313
1314 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1316 block.pop_trailing_blanks();
1317 }
1318 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1319 block.pop_trailing_blanks();
1320 }
1321
1322 self.elements.swap(first, second);
1324
1325 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1327 block.ensure_trailing_blank();
1328 }
1329
1330 if second < self.elements.len() - 1 {
1332 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1333 block.ensure_trailing_blank();
1334 }
1335 }
1336
1337 return true;
1338 }
1339 false
1340 }
1341
1342 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1344 let mut directives = Vec::new();
1345
1346 if !entry.hostname.is_empty() {
1347 directives.push(Directive {
1348 key: "HostName".to_string(),
1349 value: entry.hostname.clone(),
1350 raw_line: format!(" HostName {}", entry.hostname),
1351 is_non_directive: false,
1352 });
1353 }
1354 if !entry.user.is_empty() {
1355 directives.push(Directive {
1356 key: "User".to_string(),
1357 value: entry.user.clone(),
1358 raw_line: format!(" User {}", entry.user),
1359 is_non_directive: false,
1360 });
1361 }
1362 if entry.port != 22 {
1363 directives.push(Directive {
1364 key: "Port".to_string(),
1365 value: entry.port.to_string(),
1366 raw_line: format!(" Port {}", entry.port),
1367 is_non_directive: false,
1368 });
1369 }
1370 if !entry.identity_file.is_empty() {
1371 directives.push(Directive {
1372 key: "IdentityFile".to_string(),
1373 value: entry.identity_file.clone(),
1374 raw_line: format!(" IdentityFile {}", entry.identity_file),
1375 is_non_directive: false,
1376 });
1377 }
1378 if !entry.proxy_jump.is_empty() {
1379 directives.push(Directive {
1380 key: "ProxyJump".to_string(),
1381 value: entry.proxy_jump.clone(),
1382 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
1383 is_non_directive: false,
1384 });
1385 }
1386
1387 HostBlock {
1388 host_pattern: entry.alias.clone(),
1389 raw_host_line: format!("Host {}", entry.alias),
1390 directives,
1391 }
1392 }
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397 use super::*;
1398
1399 fn parse_str(content: &str) -> SshConfigFile {
1400 SshConfigFile {
1401 elements: SshConfigFile::parse_content(content),
1402 path: PathBuf::from("/tmp/test_config"),
1403 crlf: false,
1404 bom: false,
1405 }
1406 }
1407
1408 #[test]
1409 fn tunnel_directives_extracts_forwards() {
1410 let config = parse_str(
1411 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1412 );
1413 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1414 let rules = block.tunnel_directives();
1415 assert_eq!(rules.len(), 3);
1416 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1417 assert_eq!(rules[0].bind_port, 8080);
1418 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1419 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1420 } else {
1421 panic!("Expected HostBlock");
1422 }
1423 }
1424
1425 #[test]
1426 fn tunnel_count_counts_forwards() {
1427 let config = parse_str(
1428 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n",
1429 );
1430 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1431 assert_eq!(block.tunnel_count(), 2);
1432 } else {
1433 panic!("Expected HostBlock");
1434 }
1435 }
1436
1437 #[test]
1438 fn tunnel_count_zero_for_no_forwards() {
1439 let config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1440 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1441 assert_eq!(block.tunnel_count(), 0);
1442 assert!(!block.has_tunnels());
1443 } else {
1444 panic!("Expected HostBlock");
1445 }
1446 }
1447
1448 #[test]
1449 fn has_tunnels_true_with_forward() {
1450 let config = parse_str("Host myserver\n HostName 10.0.0.1\n DynamicForward 1080\n");
1451 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1452 assert!(block.has_tunnels());
1453 } else {
1454 panic!("Expected HostBlock");
1455 }
1456 }
1457
1458 #[test]
1459 fn add_forward_inserts_directive() {
1460 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1461 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1462 let output = config.serialize();
1463 assert!(output.contains("LocalForward 8080 localhost:80"));
1464 assert!(output.contains("HostName 10.0.0.1"));
1466 assert!(output.contains("User admin"));
1467 }
1468
1469 #[test]
1470 fn add_forward_preserves_indentation() {
1471 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1472 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1473 let output = config.serialize();
1474 assert!(output.contains("\tLocalForward 8080 localhost:80"));
1475 }
1476
1477 #[test]
1478 fn add_multiple_forwards_same_type() {
1479 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1480 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1481 config.add_forward("myserver", "LocalForward", "9090 localhost:90");
1482 let output = config.serialize();
1483 assert!(output.contains("LocalForward 8080 localhost:80"));
1484 assert!(output.contains("LocalForward 9090 localhost:90"));
1485 }
1486
1487 #[test]
1488 fn remove_forward_removes_exact_match() {
1489 let mut config = parse_str(
1490 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1491 );
1492 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1493 let output = config.serialize();
1494 assert!(!output.contains("8080 localhost:80"));
1495 assert!(output.contains("9090 localhost:90"));
1496 }
1497
1498 #[test]
1499 fn remove_forward_leaves_other_directives() {
1500 let mut config = parse_str(
1501 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n User admin\n",
1502 );
1503 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1504 let output = config.serialize();
1505 assert!(!output.contains("LocalForward"));
1506 assert!(output.contains("HostName 10.0.0.1"));
1507 assert!(output.contains("User admin"));
1508 }
1509
1510 #[test]
1511 fn remove_forward_no_match_is_noop() {
1512 let original = "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n";
1513 let mut config = parse_str(original);
1514 config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
1515 assert_eq!(config.serialize(), original);
1516 }
1517
1518 #[test]
1519 fn host_entry_tunnel_count_populated() {
1520 let config = parse_str(
1521 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n DynamicForward 1080\n",
1522 );
1523 let entries = config.host_entries();
1524 assert_eq!(entries.len(), 1);
1525 assert_eq!(entries[0].tunnel_count, 2);
1526 }
1527
1528 #[test]
1529 fn remove_forward_returns_true_on_match() {
1530 let mut config =
1531 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1532 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1533 }
1534
1535 #[test]
1536 fn remove_forward_returns_false_on_no_match() {
1537 let mut config =
1538 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1539 assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
1540 }
1541
1542 #[test]
1543 fn remove_forward_returns_false_for_unknown_host() {
1544 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1545 assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
1546 }
1547
1548 #[test]
1549 fn has_forward_finds_match() {
1550 let config =
1551 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1552 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1553 }
1554
1555 #[test]
1556 fn has_forward_no_match() {
1557 let config =
1558 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1559 assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
1560 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1561 }
1562
1563 #[test]
1564 fn has_forward_case_insensitive_key() {
1565 let config =
1566 parse_str("Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n");
1567 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1568 }
1569
1570 #[test]
1571 fn add_forward_to_empty_block() {
1572 let mut config = parse_str("Host myserver\n");
1573 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1574 let output = config.serialize();
1575 assert!(output.contains("LocalForward 8080 localhost:80"));
1576 }
1577
1578 #[test]
1579 fn remove_forward_case_insensitive_key_match() {
1580 let mut config =
1581 parse_str("Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n");
1582 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1583 assert!(!config.serialize().contains("localforward"));
1584 }
1585
1586 #[test]
1587 fn tunnel_count_case_insensitive() {
1588 let config = parse_str(
1589 "Host myserver\n localforward 8080 localhost:80\n REMOTEFORWARD 9090 localhost:90\n dynamicforward 1080\n",
1590 );
1591 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1592 assert_eq!(block.tunnel_count(), 3);
1593 } else {
1594 panic!("Expected HostBlock");
1595 }
1596 }
1597
1598 #[test]
1599 fn tunnel_directives_extracts_all_types() {
1600 let config = parse_str(
1601 "Host myserver\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1602 );
1603 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1604 let rules = block.tunnel_directives();
1605 assert_eq!(rules.len(), 3);
1606 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1607 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1608 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1609 } else {
1610 panic!("Expected HostBlock");
1611 }
1612 }
1613
1614 #[test]
1615 fn tunnel_directives_skips_malformed() {
1616 let config = parse_str("Host myserver\n LocalForward not_valid\n DynamicForward 1080\n");
1617 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1618 let rules = block.tunnel_directives();
1619 assert_eq!(rules.len(), 1);
1620 assert_eq!(rules[0].bind_port, 1080);
1621 } else {
1622 panic!("Expected HostBlock");
1623 }
1624 }
1625
1626 #[test]
1627 fn find_tunnel_directives_multi_pattern_host() {
1628 let config =
1629 parse_str("Host prod staging\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1630 let rules = config.find_tunnel_directives("prod");
1631 assert_eq!(rules.len(), 1);
1632 assert_eq!(rules[0].bind_port, 8080);
1633 let rules2 = config.find_tunnel_directives("staging");
1634 assert_eq!(rules2.len(), 1);
1635 }
1636
1637 #[test]
1638 fn find_tunnel_directives_no_match() {
1639 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
1640 let rules = config.find_tunnel_directives("nohost");
1641 assert!(rules.is_empty());
1642 }
1643
1644 #[test]
1645 fn has_forward_exact_match() {
1646 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
1647 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1648 assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
1649 assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
1650 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1651 }
1652
1653 #[test]
1654 fn has_forward_whitespace_normalized() {
1655 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
1656 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1658 }
1659
1660 #[test]
1661 fn has_forward_multi_pattern_host() {
1662 let config = parse_str("Host prod staging\n LocalForward 8080 localhost:80\n");
1663 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1664 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1665 }
1666
1667 #[test]
1668 fn add_forward_multi_pattern_host() {
1669 let mut config = parse_str("Host prod staging\n HostName 10.0.0.1\n");
1670 config.add_forward("prod", "LocalForward", "8080 localhost:80");
1671 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1672 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1673 }
1674
1675 #[test]
1676 fn remove_forward_multi_pattern_host() {
1677 let mut config = parse_str(
1678 "Host prod staging\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1679 );
1680 assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
1681 assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1682 assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
1684 }
1685
1686 #[test]
1687 fn edit_tunnel_detects_duplicate_after_remove() {
1688 let mut config = parse_str(
1690 "Host myserver\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1691 );
1692 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1694 assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
1696 }
1697
1698 #[test]
1699 fn has_forward_tab_whitespace_normalized() {
1700 let config = parse_str("Host myserver\n LocalForward 8080\tlocalhost:80\n");
1701 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1703 }
1704
1705 #[test]
1706 fn remove_forward_tab_whitespace_normalized() {
1707 let mut config = parse_str("Host myserver\n LocalForward 8080\tlocalhost:80\n");
1708 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1710 assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1711 }
1712
1713 #[test]
1714 fn upsert_preserves_space_separator_when_value_contains_equals() {
1715 let mut config = parse_str("Host myserver\n IdentityFile ~/.ssh/id=prod\n");
1716 let entry = HostEntry {
1717 alias: "myserver".to_string(),
1718 hostname: "10.0.0.1".to_string(),
1719 identity_file: "~/.ssh/id=staging".to_string(),
1720 port: 22,
1721 ..Default::default()
1722 };
1723 config.update_host("myserver", &entry);
1724 let output = config.serialize();
1725 assert!(
1727 output.contains(" IdentityFile ~/.ssh/id=staging"),
1728 "got: {}",
1729 output
1730 );
1731 assert!(!output.contains("IdentityFile="), "got: {}", output);
1732 }
1733
1734 #[test]
1735 fn upsert_preserves_equals_separator() {
1736 let mut config = parse_str("Host myserver\n IdentityFile=~/.ssh/id_rsa\n");
1737 let entry = HostEntry {
1738 alias: "myserver".to_string(),
1739 hostname: "10.0.0.1".to_string(),
1740 identity_file: "~/.ssh/id_ed25519".to_string(),
1741 port: 22,
1742 ..Default::default()
1743 };
1744 config.update_host("myserver", &entry);
1745 let output = config.serialize();
1746 assert!(
1747 output.contains("IdentityFile=~/.ssh/id_ed25519"),
1748 "got: {}",
1749 output
1750 );
1751 }
1752
1753 #[test]
1754 fn upsert_preserves_spaced_equals_separator() {
1755 let mut config = parse_str("Host myserver\n IdentityFile = ~/.ssh/id_rsa\n");
1756 let entry = HostEntry {
1757 alias: "myserver".to_string(),
1758 hostname: "10.0.0.1".to_string(),
1759 identity_file: "~/.ssh/id_ed25519".to_string(),
1760 port: 22,
1761 ..Default::default()
1762 };
1763 config.update_host("myserver", &entry);
1764 let output = config.serialize();
1765 assert!(
1766 output.contains("IdentityFile = ~/.ssh/id_ed25519"),
1767 "got: {}",
1768 output
1769 );
1770 }
1771
1772 #[test]
1773 fn is_included_host_false_for_main_config() {
1774 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1775 assert!(!config.is_included_host("myserver"));
1776 }
1777
1778 #[test]
1779 fn is_included_host_false_for_nonexistent() {
1780 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1781 assert!(!config.is_included_host("nohost"));
1782 }
1783
1784 #[test]
1785 fn is_included_host_multi_pattern_main_config() {
1786 let config = parse_str("Host prod staging\n HostName 10.0.0.1\n");
1787 assert!(!config.is_included_host("prod"));
1788 assert!(!config.is_included_host("staging"));
1789 }
1790
1791 fn first_block(config: &SshConfigFile) -> &HostBlock {
1796 match config.elements.first().unwrap() {
1797 ConfigElement::HostBlock(b) => b,
1798 _ => panic!("Expected HostBlock"),
1799 }
1800 }
1801
1802 fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
1803 let mut count = 0;
1804 for el in &config.elements {
1805 if let ConfigElement::HostBlock(b) = el {
1806 if count == idx {
1807 return b;
1808 }
1809 count += 1;
1810 }
1811 }
1812 panic!("No HostBlock at index {}", idx);
1813 }
1814
1815 #[test]
1816 fn askpass_returns_none_when_absent() {
1817 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1818 assert_eq!(first_block(&config).askpass(), None);
1819 }
1820
1821 #[test]
1822 fn askpass_returns_keychain() {
1823 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1824 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1825 }
1826
1827 #[test]
1828 fn askpass_returns_op_uri() {
1829 let config = parse_str(
1830 "Host myserver\n HostName 10.0.0.1\n # purple:askpass op://Vault/Item/field\n",
1831 );
1832 assert_eq!(
1833 first_block(&config).askpass(),
1834 Some("op://Vault/Item/field".to_string())
1835 );
1836 }
1837
1838 #[test]
1839 fn askpass_returns_vault_with_field() {
1840 let config = parse_str(
1841 "Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:secret/ssh#password\n",
1842 );
1843 assert_eq!(
1844 first_block(&config).askpass(),
1845 Some("vault:secret/ssh#password".to_string())
1846 );
1847 }
1848
1849 #[test]
1850 fn askpass_returns_bw_source() {
1851 let config =
1852 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:my-item\n");
1853 assert_eq!(
1854 first_block(&config).askpass(),
1855 Some("bw:my-item".to_string())
1856 );
1857 }
1858
1859 #[test]
1860 fn askpass_returns_pass_source() {
1861 let config =
1862 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass pass:ssh/prod\n");
1863 assert_eq!(
1864 first_block(&config).askpass(),
1865 Some("pass:ssh/prod".to_string())
1866 );
1867 }
1868
1869 #[test]
1870 fn askpass_returns_custom_command() {
1871 let config =
1872 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass get-pass %a %h\n");
1873 assert_eq!(
1874 first_block(&config).askpass(),
1875 Some("get-pass %a %h".to_string())
1876 );
1877 }
1878
1879 #[test]
1880 fn askpass_ignores_empty_value() {
1881 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass \n");
1882 assert_eq!(first_block(&config).askpass(), None);
1883 }
1884
1885 #[test]
1886 fn askpass_ignores_non_askpass_purple_comments() {
1887 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod\n");
1888 assert_eq!(first_block(&config).askpass(), None);
1889 }
1890
1891 #[test]
1892 fn set_askpass_adds_comment() {
1893 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1894 config.set_host_askpass("myserver", "keychain");
1895 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1896 }
1897
1898 #[test]
1899 fn set_askpass_replaces_existing() {
1900 let mut config =
1901 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1902 config.set_host_askpass("myserver", "op://V/I/p");
1903 assert_eq!(
1904 first_block(&config).askpass(),
1905 Some("op://V/I/p".to_string())
1906 );
1907 }
1908
1909 #[test]
1910 fn set_askpass_empty_removes_comment() {
1911 let mut config =
1912 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1913 config.set_host_askpass("myserver", "");
1914 assert_eq!(first_block(&config).askpass(), None);
1915 }
1916
1917 #[test]
1918 fn set_askpass_preserves_other_directives() {
1919 let mut config =
1920 parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n # purple:tags prod\n");
1921 config.set_host_askpass("myserver", "vault:secret/ssh");
1922 assert_eq!(
1923 first_block(&config).askpass(),
1924 Some("vault:secret/ssh".to_string())
1925 );
1926 let entry = first_block(&config).to_host_entry();
1927 assert_eq!(entry.user, "admin");
1928 assert!(entry.tags.contains(&"prod".to_string()));
1929 }
1930
1931 #[test]
1932 fn set_askpass_preserves_indent() {
1933 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1934 config.set_host_askpass("myserver", "keychain");
1935 let raw = first_block(&config)
1936 .directives
1937 .iter()
1938 .find(|d| d.raw_line.contains("purple:askpass"))
1939 .unwrap();
1940 assert!(
1941 raw.raw_line.starts_with(" "),
1942 "Expected 4-space indent, got: {:?}",
1943 raw.raw_line
1944 );
1945 }
1946
1947 #[test]
1948 fn set_askpass_on_nonexistent_host() {
1949 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1950 config.set_host_askpass("nohost", "keychain");
1951 assert_eq!(first_block(&config).askpass(), None);
1952 }
1953
1954 #[test]
1955 fn to_entry_includes_askpass() {
1956 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:item\n");
1957 let entries = config.host_entries();
1958 assert_eq!(entries.len(), 1);
1959 assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
1960 }
1961
1962 #[test]
1963 fn to_entry_askpass_none_when_absent() {
1964 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1965 let entries = config.host_entries();
1966 assert_eq!(entries.len(), 1);
1967 assert_eq!(entries[0].askpass, None);
1968 }
1969
1970 #[test]
1971 fn set_askpass_vault_with_hash_field() {
1972 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1973 config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
1974 assert_eq!(
1975 first_block(&config).askpass(),
1976 Some("vault:secret/data/team#api_key".to_string())
1977 );
1978 }
1979
1980 #[test]
1981 fn set_askpass_custom_command_with_percent() {
1982 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1983 config.set_host_askpass("myserver", "get-pass %a %h");
1984 assert_eq!(
1985 first_block(&config).askpass(),
1986 Some("get-pass %a %h".to_string())
1987 );
1988 }
1989
1990 #[test]
1991 fn multiple_hosts_independent_askpass() {
1992 let mut config = parse_str("Host alpha\n HostName a.com\n\nHost beta\n HostName b.com\n");
1993 config.set_host_askpass("alpha", "keychain");
1994 config.set_host_askpass("beta", "vault:secret/ssh");
1995 assert_eq!(
1996 block_by_index(&config, 0).askpass(),
1997 Some("keychain".to_string())
1998 );
1999 assert_eq!(
2000 block_by_index(&config, 1).askpass(),
2001 Some("vault:secret/ssh".to_string())
2002 );
2003 }
2004
2005 #[test]
2006 fn set_askpass_then_clear_then_set_again() {
2007 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2008 config.set_host_askpass("myserver", "keychain");
2009 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2010 config.set_host_askpass("myserver", "");
2011 assert_eq!(first_block(&config).askpass(), None);
2012 config.set_host_askpass("myserver", "op://V/I/p");
2013 assert_eq!(
2014 first_block(&config).askpass(),
2015 Some("op://V/I/p".to_string())
2016 );
2017 }
2018
2019 #[test]
2020 fn askpass_tab_indent_preserved() {
2021 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
2022 config.set_host_askpass("myserver", "pass:ssh/prod");
2023 let raw = first_block(&config)
2024 .directives
2025 .iter()
2026 .find(|d| d.raw_line.contains("purple:askpass"))
2027 .unwrap();
2028 assert!(
2029 raw.raw_line.starts_with("\t"),
2030 "Expected tab indent, got: {:?}",
2031 raw.raw_line
2032 );
2033 }
2034
2035 #[test]
2036 fn askpass_coexists_with_provider_comment() {
2037 let config = parse_str(
2038 "Host myserver\n HostName 10.0.0.1\n # purple:provider do:123\n # purple:askpass keychain\n",
2039 );
2040 let block = first_block(&config);
2041 assert_eq!(block.askpass(), Some("keychain".to_string()));
2042 assert!(block.provider().is_some());
2043 }
2044
2045 #[test]
2046 fn set_askpass_does_not_remove_tags() {
2047 let mut config =
2048 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod,staging\n");
2049 config.set_host_askpass("myserver", "keychain");
2050 let entry = first_block(&config).to_host_entry();
2051 assert_eq!(entry.askpass, Some("keychain".to_string()));
2052 assert!(entry.tags.contains(&"prod".to_string()));
2053 assert!(entry.tags.contains(&"staging".to_string()));
2054 }
2055
2056 #[test]
2057 fn askpass_idempotent_set_same_value() {
2058 let mut config =
2059 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2060 config.set_host_askpass("myserver", "keychain");
2061 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2062 let serialized = config.serialize();
2063 assert_eq!(
2064 serialized.matches("purple:askpass").count(),
2065 1,
2066 "Should have exactly one askpass comment"
2067 );
2068 }
2069
2070 #[test]
2071 fn askpass_with_value_containing_equals() {
2072 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2073 config.set_host_askpass("myserver", "cmd --opt=val %h");
2074 assert_eq!(
2075 first_block(&config).askpass(),
2076 Some("cmd --opt=val %h".to_string())
2077 );
2078 }
2079
2080 #[test]
2081 fn askpass_with_value_containing_hash() {
2082 let config =
2083 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:a/b#c\n");
2084 assert_eq!(
2085 first_block(&config).askpass(),
2086 Some("vault:a/b#c".to_string())
2087 );
2088 }
2089
2090 #[test]
2091 fn askpass_with_long_op_uri() {
2092 let uri = "op://My Personal Vault/SSH Production Server/password";
2093 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2094 config.set_host_askpass("myserver", uri);
2095 assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
2096 }
2097
2098 #[test]
2099 fn askpass_does_not_interfere_with_host_matching() {
2100 let config = parse_str(
2102 "Host myserver\n HostName 10.0.0.1\n User root\n # purple:askpass keychain\n",
2103 );
2104 let entry = first_block(&config).to_host_entry();
2105 assert_eq!(entry.user, "root");
2106 assert_eq!(entry.hostname, "10.0.0.1");
2107 assert_eq!(entry.askpass, Some("keychain".to_string()));
2108 }
2109
2110 #[test]
2111 fn set_askpass_on_host_with_many_directives() {
2112 let config_str = "\
2113Host myserver
2114 HostName 10.0.0.1
2115 User admin
2116 Port 2222
2117 IdentityFile ~/.ssh/id_ed25519
2118 ProxyJump bastion
2119 # purple:tags prod,us-east
2120";
2121 let mut config = parse_str(config_str);
2122 config.set_host_askpass("myserver", "pass:ssh/prod");
2123 let entry = first_block(&config).to_host_entry();
2124 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2125 assert_eq!(entry.user, "admin");
2126 assert_eq!(entry.port, 2222);
2127 assert!(entry.tags.contains(&"prod".to_string()));
2128 }
2129
2130 #[test]
2131 fn askpass_with_crlf_line_endings() {
2132 let config =
2133 parse_str("Host myserver\r\n HostName 10.0.0.1\r\n # purple:askpass keychain\r\n");
2134 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2135 }
2136
2137 #[test]
2138 fn askpass_only_on_first_matching_host() {
2139 let config = parse_str(
2141 "Host dup\n HostName a.com\n # purple:askpass keychain\n\nHost dup\n HostName b.com\n # purple:askpass vault:x\n",
2142 );
2143 let entries = config.host_entries();
2144 assert_eq!(entries[0].askpass, Some("keychain".to_string()));
2146 }
2147
2148 #[test]
2149 fn set_askpass_preserves_other_non_directive_comments() {
2150 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";
2151 let mut config = parse_str(config_str);
2152 config.set_host_askpass("myserver", "new-source");
2153 let serialized = config.serialize();
2154 assert!(serialized.contains("# This is a user comment"));
2155 assert!(serialized.contains("# Another comment"));
2156 assert!(serialized.contains("# purple:askpass new-source"));
2157 assert!(!serialized.contains("# purple:askpass old"));
2158 }
2159
2160 #[test]
2161 fn askpass_mixed_with_tunnel_directives() {
2162 let config_str = "\
2163Host myserver
2164 HostName 10.0.0.1
2165 LocalForward 8080 localhost:80
2166 # purple:askpass bw:item
2167 RemoteForward 9090 localhost:9090
2168";
2169 let config = parse_str(config_str);
2170 let entry = first_block(&config).to_host_entry();
2171 assert_eq!(entry.askpass, Some("bw:item".to_string()));
2172 assert_eq!(entry.tunnel_count, 2);
2173 }
2174
2175 #[test]
2180 fn set_askpass_idempotent_same_value() {
2181 let config_str = "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n";
2182 let mut config = parse_str(config_str);
2183 config.set_host_askpass("myserver", "keychain");
2184 let output = config.serialize();
2185 assert_eq!(output.matches("purple:askpass").count(), 1);
2187 assert!(output.contains("# purple:askpass keychain"));
2188 }
2189
2190 #[test]
2191 fn set_askpass_with_equals_in_value() {
2192 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2193 config.set_host_askpass("myserver", "cmd --opt=val");
2194 let entries = config.host_entries();
2195 assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
2196 }
2197
2198 #[test]
2199 fn set_askpass_with_hash_in_value() {
2200 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2201 config.set_host_askpass("myserver", "vault:secret/data#field");
2202 let entries = config.host_entries();
2203 assert_eq!(
2204 entries[0].askpass,
2205 Some("vault:secret/data#field".to_string())
2206 );
2207 }
2208
2209 #[test]
2210 fn set_askpass_long_op_uri() {
2211 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2212 let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
2213 config.set_host_askpass("myserver", long_uri);
2214 assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
2215 }
2216
2217 #[test]
2218 fn askpass_host_with_multi_pattern_is_skipped() {
2219 let config_str = "Host prod staging\n HostName 10.0.0.1\n";
2222 let mut config = parse_str(config_str);
2223 config.set_host_askpass("prod", "keychain");
2224 assert!(config.host_entries().is_empty());
2226 }
2227
2228 #[test]
2229 fn askpass_survives_directive_reorder() {
2230 let config_str = "\
2232Host myserver
2233 # purple:askpass op://V/I/p
2234 HostName 10.0.0.1
2235 User root
2236";
2237 let config = parse_str(config_str);
2238 let entry = first_block(&config).to_host_entry();
2239 assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
2240 assert_eq!(entry.hostname, "10.0.0.1");
2241 }
2242
2243 #[test]
2244 fn askpass_among_many_purple_comments() {
2245 let config_str = "\
2246Host myserver
2247 HostName 10.0.0.1
2248 # purple:tags prod,us-east
2249 # purple:provider do:12345
2250 # purple:askpass pass:ssh/prod
2251";
2252 let config = parse_str(config_str);
2253 let entry = first_block(&config).to_host_entry();
2254 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2255 assert!(entry.tags.contains(&"prod".to_string()));
2256 }
2257
2258 #[test]
2259 fn meta_empty_when_no_comment() {
2260 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2261 let config = parse_str(config_str);
2262 let meta = first_block(&config).meta();
2263 assert!(meta.is_empty());
2264 }
2265
2266 #[test]
2267 fn meta_parses_key_value_pairs() {
2268 let config_str = "\
2269Host myhost
2270 HostName 1.2.3.4
2271 # purple:meta region=nyc3,plan=s-1vcpu-1gb
2272";
2273 let config = parse_str(config_str);
2274 let meta = first_block(&config).meta();
2275 assert_eq!(meta.len(), 2);
2276 assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
2277 assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
2278 }
2279
2280 #[test]
2281 fn meta_round_trip() {
2282 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2283 let mut config = parse_str(config_str);
2284 let meta = vec![
2285 ("region".to_string(), "fra1".to_string()),
2286 ("plan".to_string(), "cx11".to_string()),
2287 ];
2288 config.set_host_meta("myhost", &meta);
2289 let output = config.serialize();
2290 assert!(output.contains("# purple:meta region=fra1,plan=cx11"));
2291
2292 let config2 = parse_str(&output);
2293 let parsed = first_block(&config2).meta();
2294 assert_eq!(parsed, meta);
2295 }
2296
2297 #[test]
2298 fn meta_replaces_existing() {
2299 let config_str = "\
2300Host myhost
2301 HostName 1.2.3.4
2302 # purple:meta region=old
2303";
2304 let mut config = parse_str(config_str);
2305 config.set_host_meta("myhost", &[("region".to_string(), "new".to_string())]);
2306 let output = config.serialize();
2307 assert!(!output.contains("region=old"));
2308 assert!(output.contains("region=new"));
2309 }
2310
2311 #[test]
2312 fn meta_removed_when_empty() {
2313 let config_str = "\
2314Host myhost
2315 HostName 1.2.3.4
2316 # purple:meta region=nyc3
2317";
2318 let mut config = parse_str(config_str);
2319 config.set_host_meta("myhost", &[]);
2320 let output = config.serialize();
2321 assert!(!output.contains("purple:meta"));
2322 }
2323
2324 #[test]
2325 fn meta_sanitizes_commas_in_values() {
2326 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2327 let mut config = parse_str(config_str);
2328 let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
2329 config.set_host_meta("myhost", &meta);
2330 let output = config.serialize();
2331 assert!(output.contains("plan=s-1vcpu1gb"));
2333
2334 let config2 = parse_str(&output);
2335 let parsed = first_block(&config2).meta();
2336 assert_eq!(parsed[0].1, "s-1vcpu1gb");
2337 }
2338
2339 #[test]
2340 fn meta_in_host_entry() {
2341 let config_str = "\
2342Host myhost
2343 HostName 1.2.3.4
2344 # purple:meta region=nyc3,plan=s-1vcpu-1gb
2345";
2346 let config = parse_str(config_str);
2347 let entry = first_block(&config).to_host_entry();
2348 assert_eq!(entry.provider_meta.len(), 2);
2349 assert_eq!(entry.provider_meta[0].0, "region");
2350 assert_eq!(entry.provider_meta[1].0, "plan");
2351 }
2352
2353 #[test]
2354 fn repair_absorbed_group_comment() {
2355 let mut config = SshConfigFile {
2357 elements: vec![ConfigElement::HostBlock(HostBlock {
2358 host_pattern: "myserver".to_string(),
2359 raw_host_line: "Host myserver".to_string(),
2360 directives: vec![
2361 Directive {
2362 key: "HostName".to_string(),
2363 value: "10.0.0.1".to_string(),
2364 raw_line: " HostName 10.0.0.1".to_string(),
2365 is_non_directive: false,
2366 },
2367 Directive {
2368 key: String::new(),
2369 value: String::new(),
2370 raw_line: "# purple:group Production".to_string(),
2371 is_non_directive: true,
2372 },
2373 ],
2374 })],
2375 path: PathBuf::from("/tmp/test_config"),
2376 crlf: false,
2377 bom: false,
2378 };
2379 let count = config.repair_absorbed_group_comments();
2380 assert_eq!(count, 1);
2381 assert_eq!(config.elements.len(), 2);
2382 if let ConfigElement::HostBlock(block) = &config.elements[0] {
2384 assert_eq!(block.directives.len(), 1);
2385 assert_eq!(block.directives[0].key, "HostName");
2386 } else {
2387 panic!("Expected HostBlock");
2388 }
2389 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2391 assert_eq!(line, "# purple:group Production");
2392 } else {
2393 panic!("Expected GlobalLine for group comment");
2394 }
2395 }
2396
2397 #[test]
2398 fn repair_strips_trailing_blanks_before_group() {
2399 let mut config = SshConfigFile {
2400 elements: vec![ConfigElement::HostBlock(HostBlock {
2401 host_pattern: "myserver".to_string(),
2402 raw_host_line: "Host myserver".to_string(),
2403 directives: vec![
2404 Directive {
2405 key: "HostName".to_string(),
2406 value: "10.0.0.1".to_string(),
2407 raw_line: " HostName 10.0.0.1".to_string(),
2408 is_non_directive: false,
2409 },
2410 Directive {
2411 key: String::new(),
2412 value: String::new(),
2413 raw_line: "".to_string(),
2414 is_non_directive: true,
2415 },
2416 Directive {
2417 key: String::new(),
2418 value: String::new(),
2419 raw_line: "# purple:group Staging".to_string(),
2420 is_non_directive: true,
2421 },
2422 ],
2423 })],
2424 path: PathBuf::from("/tmp/test_config"),
2425 crlf: false,
2426 bom: false,
2427 };
2428 let count = config.repair_absorbed_group_comments();
2429 assert_eq!(count, 1);
2430 if let ConfigElement::HostBlock(block) = &config.elements[0] {
2432 assert_eq!(block.directives.len(), 1);
2433 } else {
2434 panic!("Expected HostBlock");
2435 }
2436 assert_eq!(config.elements.len(), 3);
2438 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2439 assert!(line.trim().is_empty());
2440 } else {
2441 panic!("Expected blank GlobalLine");
2442 }
2443 if let ConfigElement::GlobalLine(line) = &config.elements[2] {
2444 assert!(line.starts_with("# purple:group"));
2445 } else {
2446 panic!("Expected group GlobalLine");
2447 }
2448 }
2449
2450 #[test]
2451 fn repair_clean_config_returns_zero() {
2452 let mut config =
2453 parse_str("# purple:group Production\nHost myserver\n HostName 10.0.0.1\n");
2454 let count = config.repair_absorbed_group_comments();
2455 assert_eq!(count, 0);
2456 }
2457
2458 #[test]
2459 fn repair_roundtrip_serializes_correctly() {
2460 let mut config = SshConfigFile {
2462 elements: vec![
2463 ConfigElement::HostBlock(HostBlock {
2464 host_pattern: "server1".to_string(),
2465 raw_host_line: "Host server1".to_string(),
2466 directives: vec![
2467 Directive {
2468 key: "HostName".to_string(),
2469 value: "10.0.0.1".to_string(),
2470 raw_line: " HostName 10.0.0.1".to_string(),
2471 is_non_directive: false,
2472 },
2473 Directive {
2474 key: String::new(),
2475 value: String::new(),
2476 raw_line: "".to_string(),
2477 is_non_directive: true,
2478 },
2479 Directive {
2480 key: String::new(),
2481 value: String::new(),
2482 raw_line: "# purple:group Staging".to_string(),
2483 is_non_directive: true,
2484 },
2485 ],
2486 }),
2487 ConfigElement::HostBlock(HostBlock {
2488 host_pattern: "server2".to_string(),
2489 raw_host_line: "Host server2".to_string(),
2490 directives: vec![Directive {
2491 key: "HostName".to_string(),
2492 value: "10.0.0.2".to_string(),
2493 raw_line: " HostName 10.0.0.2".to_string(),
2494 is_non_directive: false,
2495 }],
2496 }),
2497 ],
2498 path: PathBuf::from("/tmp/test_config"),
2499 crlf: false,
2500 bom: false,
2501 };
2502 let count = config.repair_absorbed_group_comments();
2503 assert_eq!(count, 1);
2504 let output = config.serialize();
2505 let expected = "\
2507Host server1
2508 HostName 10.0.0.1
2509
2510# purple:group Staging
2511Host server2
2512 HostName 10.0.0.2
2513";
2514 assert_eq!(output, expected);
2515 }
2516
2517 #[test]
2522 fn delete_last_provider_host_removes_group_header() {
2523 let config_str = "\
2524# purple:group DigitalOcean
2525Host do-web
2526 HostName 1.2.3.4
2527 # purple:provider digitalocean:123
2528";
2529 let mut config = parse_str(config_str);
2530 config.delete_host("do-web");
2531 let has_header = config
2532 .elements
2533 .iter()
2534 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
2535 assert!(
2536 !has_header,
2537 "Group header should be removed when last provider host is deleted"
2538 );
2539 }
2540
2541 #[test]
2542 fn delete_one_of_multiple_provider_hosts_preserves_group_header() {
2543 let config_str = "\
2544# purple:group DigitalOcean
2545Host do-web
2546 HostName 1.2.3.4
2547 # purple:provider digitalocean:123
2548
2549Host do-db
2550 HostName 5.6.7.8
2551 # purple:provider digitalocean:456
2552";
2553 let mut config = parse_str(config_str);
2554 config.delete_host("do-web");
2555 let has_header = config.elements.iter().any(|e| {
2556 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2557 });
2558 assert!(
2559 has_header,
2560 "Group header should be preserved when other provider hosts remain"
2561 );
2562 assert_eq!(config.host_entries().len(), 1);
2563 }
2564
2565 #[test]
2566 fn delete_non_provider_host_leaves_group_headers() {
2567 let config_str = "\
2568Host personal
2569 HostName 10.0.0.1
2570
2571# purple:group DigitalOcean
2572Host do-web
2573 HostName 1.2.3.4
2574 # purple:provider digitalocean:123
2575";
2576 let mut config = parse_str(config_str);
2577 config.delete_host("personal");
2578 let has_header = config.elements.iter().any(|e| {
2579 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2580 });
2581 assert!(
2582 has_header,
2583 "Group header should not be affected by deleting a non-provider host"
2584 );
2585 assert_eq!(config.host_entries().len(), 1);
2586 }
2587
2588 #[test]
2589 fn delete_host_undoable_keeps_group_header_for_undo() {
2590 let config_str = "\
2594# purple:group Vultr
2595Host vultr-web
2596 HostName 2.3.4.5
2597 # purple:provider vultr:789
2598";
2599 let mut config = parse_str(config_str);
2600 let result = config.delete_host_undoable("vultr-web");
2601 assert!(result.is_some());
2602 let has_header = config
2603 .elements
2604 .iter()
2605 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
2606 assert!(has_header, "Group header should be kept for undo");
2607 }
2608
2609 #[test]
2610 fn delete_host_undoable_preserves_header_when_others_remain() {
2611 let config_str = "\
2612# purple:group AWS EC2
2613Host aws-web
2614 HostName 3.4.5.6
2615 # purple:provider aws:i-111
2616
2617Host aws-db
2618 HostName 7.8.9.0
2619 # purple:provider aws:i-222
2620";
2621 let mut config = parse_str(config_str);
2622 let result = config.delete_host_undoable("aws-web");
2623 assert!(result.is_some());
2624 let has_header = config.elements.iter().any(
2625 |e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group AWS EC2")),
2626 );
2627 assert!(
2628 has_header,
2629 "Group header preserved when other provider hosts remain (undoable)"
2630 );
2631 }
2632
2633 #[test]
2634 fn delete_host_undoable_returns_original_position_for_undo() {
2635 let config_str = "\
2638# purple:group Vultr
2639Host vultr-web
2640 HostName 2.3.4.5
2641 # purple:provider vultr:789
2642
2643Host manual
2644 HostName 10.0.0.1
2645";
2646 let mut config = parse_str(config_str);
2647 let (element, pos) = config.delete_host_undoable("vultr-web").unwrap();
2648 assert_eq!(pos, 1, "Position should be the original host index");
2650 config.insert_host_at(element, pos);
2652 let output = config.serialize();
2654 assert!(
2655 output.contains("# purple:group Vultr"),
2656 "Group header should be present"
2657 );
2658 assert!(output.contains("Host vultr-web"), "Host should be restored");
2659 assert!(output.contains("Host manual"), "Manual host should survive");
2660 assert_eq!(config_str, output);
2661 }
2662
2663 #[test]
2668 fn add_host_inserts_before_trailing_wildcard() {
2669 let config_str = "\
2670Host existing
2671 HostName 10.0.0.1
2672
2673Host *
2674 ServerAliveInterval 60
2675";
2676 let mut config = parse_str(config_str);
2677 let entry = HostEntry {
2678 alias: "newhost".to_string(),
2679 hostname: "10.0.0.2".to_string(),
2680 port: 22,
2681 ..Default::default()
2682 };
2683 config.add_host(&entry);
2684 let output = config.serialize();
2685 let new_pos = output.find("Host newhost").unwrap();
2686 let wildcard_pos = output.find("Host *").unwrap();
2687 assert!(
2688 new_pos < wildcard_pos,
2689 "New host should appear before Host *: {}",
2690 output
2691 );
2692 let existing_pos = output.find("Host existing").unwrap();
2693 assert!(existing_pos < new_pos);
2694 }
2695
2696 #[test]
2697 fn add_host_appends_when_no_wildcards() {
2698 let config_str = "\
2699Host existing
2700 HostName 10.0.0.1
2701";
2702 let mut config = parse_str(config_str);
2703 let entry = HostEntry {
2704 alias: "newhost".to_string(),
2705 hostname: "10.0.0.2".to_string(),
2706 port: 22,
2707 ..Default::default()
2708 };
2709 config.add_host(&entry);
2710 let output = config.serialize();
2711 let existing_pos = output.find("Host existing").unwrap();
2712 let new_pos = output.find("Host newhost").unwrap();
2713 assert!(existing_pos < new_pos, "New host should be appended at end");
2714 }
2715
2716 #[test]
2717 fn add_host_appends_when_wildcard_at_beginning() {
2718 let config_str = "\
2720Host *
2721 ServerAliveInterval 60
2722
2723Host existing
2724 HostName 10.0.0.1
2725";
2726 let mut config = parse_str(config_str);
2727 let entry = HostEntry {
2728 alias: "newhost".to_string(),
2729 hostname: "10.0.0.2".to_string(),
2730 port: 22,
2731 ..Default::default()
2732 };
2733 config.add_host(&entry);
2734 let output = config.serialize();
2735 let existing_pos = output.find("Host existing").unwrap();
2736 let new_pos = output.find("Host newhost").unwrap();
2737 assert!(
2738 existing_pos < new_pos,
2739 "New host should be appended at end when wildcard is at top: {}",
2740 output
2741 );
2742 }
2743
2744 #[test]
2745 fn add_host_inserts_before_trailing_pattern_host() {
2746 let config_str = "\
2747Host existing
2748 HostName 10.0.0.1
2749
2750Host *.example.com
2751 ProxyJump bastion
2752";
2753 let mut config = parse_str(config_str);
2754 let entry = HostEntry {
2755 alias: "newhost".to_string(),
2756 hostname: "10.0.0.2".to_string(),
2757 port: 22,
2758 ..Default::default()
2759 };
2760 config.add_host(&entry);
2761 let output = config.serialize();
2762 let new_pos = output.find("Host newhost").unwrap();
2763 let pattern_pos = output.find("Host *.example.com").unwrap();
2764 assert!(
2765 new_pos < pattern_pos,
2766 "New host should appear before pattern host: {}",
2767 output
2768 );
2769 }
2770
2771 #[test]
2772 fn add_host_no_triple_blank_lines() {
2773 let config_str = "\
2774Host existing
2775 HostName 10.0.0.1
2776
2777Host *
2778 ServerAliveInterval 60
2779";
2780 let mut config = parse_str(config_str);
2781 let entry = HostEntry {
2782 alias: "newhost".to_string(),
2783 hostname: "10.0.0.2".to_string(),
2784 port: 22,
2785 ..Default::default()
2786 };
2787 config.add_host(&entry);
2788 let output = config.serialize();
2789 assert!(
2790 !output.contains("\n\n\n"),
2791 "Should not have triple blank lines: {}",
2792 output
2793 );
2794 }
2795
2796 #[test]
2797 fn provider_group_display_name_matches_providers_mod() {
2798 let providers = [
2803 "digitalocean",
2804 "vultr",
2805 "linode",
2806 "hetzner",
2807 "upcloud",
2808 "proxmox",
2809 "aws",
2810 "scaleway",
2811 "gcp",
2812 "azure",
2813 "tailscale",
2814 ];
2815 for name in &providers {
2816 assert_eq!(
2817 provider_group_display_name(name),
2818 crate::providers::provider_display_name(name),
2819 "Display name mismatch for provider '{}': model.rs has '{}' but providers/mod.rs has '{}'",
2820 name,
2821 provider_group_display_name(name),
2822 crate::providers::provider_display_name(name),
2823 );
2824 }
2825 }
2826
2827 #[test]
2828 fn test_sanitize_tag_strips_control_chars() {
2829 assert_eq!(HostBlock::sanitize_tag("prod"), "prod");
2830 assert_eq!(HostBlock::sanitize_tag("prod\n"), "prod");
2831 assert_eq!(HostBlock::sanitize_tag("pr\x00od"), "prod");
2832 assert_eq!(HostBlock::sanitize_tag("\t\r\n"), "");
2833 }
2834
2835 #[test]
2836 fn test_sanitize_tag_strips_commas() {
2837 assert_eq!(HostBlock::sanitize_tag("prod,staging"), "prodstaging");
2838 assert_eq!(HostBlock::sanitize_tag(",,,"), "");
2839 }
2840
2841 #[test]
2842 fn test_sanitize_tag_strips_bidi() {
2843 assert_eq!(HostBlock::sanitize_tag("prod\u{202E}tset"), "prodtset");
2844 assert_eq!(HostBlock::sanitize_tag("\u{200B}zero\u{FEFF}"), "zero");
2845 }
2846
2847 #[test]
2848 fn test_sanitize_tag_truncates_long() {
2849 let long = "a".repeat(200);
2850 assert_eq!(HostBlock::sanitize_tag(&long).len(), 128);
2851 }
2852
2853 #[test]
2854 fn test_sanitize_tag_preserves_unicode() {
2855 assert_eq!(HostBlock::sanitize_tag("日本語"), "日本語");
2856 assert_eq!(HostBlock::sanitize_tag("café"), "café");
2857 }
2858
2859 #[test]
2864 fn test_provider_tags_parsing() {
2865 let config =
2866 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags a,b,c\n");
2867 let entry = first_block(&config).to_host_entry();
2868 assert_eq!(entry.provider_tags, vec!["a", "b", "c"]);
2869 }
2870
2871 #[test]
2872 fn test_provider_tags_empty() {
2873 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2874 let entry = first_block(&config).to_host_entry();
2875 assert!(entry.provider_tags.is_empty());
2876 }
2877
2878 #[test]
2879 fn test_has_provider_tags_comment_present() {
2880 let config =
2881 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags prod\n");
2882 assert!(first_block(&config).has_provider_tags_comment());
2883 assert!(first_block(&config).to_host_entry().has_provider_tags);
2884 }
2885
2886 #[test]
2887 fn test_has_provider_tags_comment_sentinel() {
2888 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags\n");
2890 assert!(first_block(&config).has_provider_tags_comment());
2891 assert!(first_block(&config).to_host_entry().has_provider_tags);
2892 assert!(
2893 first_block(&config)
2894 .to_host_entry()
2895 .provider_tags
2896 .is_empty()
2897 );
2898 }
2899
2900 #[test]
2901 fn test_has_provider_tags_comment_absent() {
2902 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2903 assert!(!first_block(&config).has_provider_tags_comment());
2904 assert!(!first_block(&config).to_host_entry().has_provider_tags);
2905 }
2906
2907 #[test]
2908 fn test_set_tags_does_not_delete_provider_tags() {
2909 let mut config = parse_str(
2910 "Host myserver\n HostName 10.0.0.1\n # purple:tags user1\n # purple:provider_tags cloud1,cloud2\n",
2911 );
2912 config.set_host_tags("myserver", &["newuser".to_string()]);
2913 let entry = first_block(&config).to_host_entry();
2914 assert_eq!(entry.tags, vec!["newuser"]);
2915 assert_eq!(entry.provider_tags, vec!["cloud1", "cloud2"]);
2916 }
2917
2918 #[test]
2919 fn test_set_provider_tags_does_not_delete_user_tags() {
2920 let mut config = parse_str(
2921 "Host myserver\n HostName 10.0.0.1\n # purple:tags user1,user2\n # purple:provider_tags old\n",
2922 );
2923 config.set_host_provider_tags("myserver", &["new1".to_string(), "new2".to_string()]);
2924 let entry = first_block(&config).to_host_entry();
2925 assert_eq!(entry.tags, vec!["user1", "user2"]);
2926 assert_eq!(entry.provider_tags, vec!["new1", "new2"]);
2927 }
2928
2929 #[test]
2930 fn test_set_askpass_does_not_delete_similar_comments() {
2931 let mut config = parse_str(
2933 "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n # purple:askpass_backup test\n",
2934 );
2935 config.set_host_askpass("myserver", "op://vault/item/pass");
2936 let entry = first_block(&config).to_host_entry();
2937 assert_eq!(entry.askpass, Some("op://vault/item/pass".to_string()));
2938 let serialized = config.serialize();
2940 assert!(serialized.contains("purple:askpass_backup test"));
2941 }
2942
2943 #[test]
2944 fn test_set_meta_does_not_delete_similar_comments() {
2945 let mut config = parse_str(
2947 "Host myserver\n HostName 10.0.0.1\n # purple:meta region=us-east\n # purple:metadata foo\n",
2948 );
2949 config.set_host_meta("myserver", &[("region".to_string(), "eu-west".to_string())]);
2950 let serialized = config.serialize();
2951 assert!(serialized.contains("purple:meta region=eu-west"));
2952 assert!(serialized.contains("purple:metadata foo"));
2953 }
2954
2955 #[test]
2956 fn test_set_meta_sanitizes_control_chars() {
2957 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2958 config.set_host_meta(
2959 "myserver",
2960 &[
2961 ("region".to_string(), "us\x00east".to_string()),
2962 ("zone".to_string(), "a\u{202E}b".to_string()),
2963 ],
2964 );
2965 let serialized = config.serialize();
2966 assert!(serialized.contains("region=useast"));
2968 assert!(serialized.contains("zone=ab"));
2969 assert!(!serialized.contains('\x00'));
2970 assert!(!serialized.contains('\u{202E}'));
2971 }
2972}