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 pub stale: Option<u64>,
112}
113
114impl Default for HostEntry {
115 fn default() -> Self {
116 Self {
117 alias: String::new(),
118 hostname: String::new(),
119 user: String::new(),
120 port: 22,
121 identity_file: String::new(),
122 proxy_jump: String::new(),
123 source_file: None,
124 tags: Vec::new(),
125 provider_tags: Vec::new(),
126 has_provider_tags: false,
127 provider: None,
128 tunnel_count: 0,
129 askpass: None,
130 provider_meta: Vec::new(),
131 stale: None,
132 }
133 }
134}
135
136impl HostEntry {
137 pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
142 let escaped = self.alias.replace('\'', "'\\''");
143 let default = dirs::home_dir()
144 .map(|h| h.join(".ssh/config"))
145 .unwrap_or_default();
146 if config_path == default {
147 format!("ssh -- '{}'", escaped)
148 } else {
149 let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
150 format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
151 }
152 }
153}
154
155pub fn is_host_pattern(pattern: &str) -> bool {
159 pattern.contains('*')
160 || pattern.contains('?')
161 || pattern.contains('[')
162 || pattern.starts_with('!')
163 || pattern.contains(' ')
164 || pattern.contains('\t')
165}
166
167impl HostBlock {
168 fn content_end(&self) -> usize {
170 let mut pos = self.directives.len();
171 while pos > 0 {
172 if self.directives[pos - 1].is_non_directive
173 && self.directives[pos - 1].raw_line.trim().is_empty()
174 {
175 pos -= 1;
176 } else {
177 break;
178 }
179 }
180 pos
181 }
182
183 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
185 let end = self.content_end();
186 self.directives.drain(end..).collect()
187 }
188
189 fn ensure_trailing_blank(&mut self) {
191 self.pop_trailing_blanks();
192 self.directives.push(Directive {
193 key: String::new(),
194 value: String::new(),
195 raw_line: String::new(),
196 is_non_directive: true,
197 });
198 }
199
200 fn detect_indent(&self) -> String {
202 for d in &self.directives {
203 if !d.is_non_directive && !d.raw_line.is_empty() {
204 let trimmed = d.raw_line.trim_start();
205 let indent_len = d.raw_line.len() - trimmed.len();
206 if indent_len > 0 {
207 return d.raw_line[..indent_len].to_string();
208 }
209 }
210 }
211 " ".to_string()
212 }
213
214 pub fn tags(&self) -> Vec<String> {
216 for d in &self.directives {
217 if d.is_non_directive {
218 let trimmed = d.raw_line.trim();
219 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
220 return rest
221 .split(',')
222 .map(|t| t.trim().to_string())
223 .filter(|t| !t.is_empty())
224 .collect();
225 }
226 }
227 }
228 Vec::new()
229 }
230
231 pub fn provider_tags(&self) -> Vec<String> {
233 for d in &self.directives {
234 if d.is_non_directive {
235 let trimmed = d.raw_line.trim();
236 if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
237 return rest
238 .split(',')
239 .map(|t| t.trim().to_string())
240 .filter(|t| !t.is_empty())
241 .collect();
242 }
243 }
244 }
245 Vec::new()
246 }
247
248 pub fn has_provider_tags_comment(&self) -> bool {
251 self.directives.iter().any(|d| {
252 d.is_non_directive && {
253 let t = d.raw_line.trim();
254 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
255 }
256 })
257 }
258
259 pub fn provider(&self) -> Option<(String, String)> {
262 for d in &self.directives {
263 if d.is_non_directive {
264 let trimmed = d.raw_line.trim();
265 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
266 if let Some((name, id)) = rest.split_once(':') {
267 return Some((name.trim().to_string(), id.trim().to_string()));
268 }
269 }
270 }
271 }
272 None
273 }
274
275 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
277 let indent = self.detect_indent();
278 self.directives.retain(|d| {
279 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
280 });
281 let pos = self.content_end();
282 self.directives.insert(
283 pos,
284 Directive {
285 key: String::new(),
286 value: String::new(),
287 raw_line: format!(
288 "{}# purple:provider {}:{}",
289 indent, provider_name, server_id
290 ),
291 is_non_directive: true,
292 },
293 );
294 }
295
296 pub fn askpass(&self) -> Option<String> {
298 for d in &self.directives {
299 if d.is_non_directive {
300 let trimmed = d.raw_line.trim();
301 if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
302 let val = rest.trim();
303 if !val.is_empty() {
304 return Some(val.to_string());
305 }
306 }
307 }
308 }
309 None
310 }
311
312 pub fn set_askpass(&mut self, source: &str) {
315 let indent = self.detect_indent();
316 self.directives.retain(|d| {
317 !(d.is_non_directive && {
318 let t = d.raw_line.trim();
319 t == "# purple:askpass" || t.starts_with("# purple:askpass ")
320 })
321 });
322 if !source.is_empty() {
323 let pos = self.content_end();
324 self.directives.insert(
325 pos,
326 Directive {
327 key: String::new(),
328 value: String::new(),
329 raw_line: format!("{}# purple:askpass {}", indent, source),
330 is_non_directive: true,
331 },
332 );
333 }
334 }
335
336 pub fn meta(&self) -> Vec<(String, String)> {
339 for d in &self.directives {
340 if d.is_non_directive {
341 let trimmed = d.raw_line.trim();
342 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
343 return rest
344 .split(',')
345 .filter_map(|pair| {
346 let (k, v) = pair.split_once('=')?;
347 let k = k.trim();
348 let v = v.trim();
349 if k.is_empty() {
350 None
351 } else {
352 Some((k.to_string(), v.to_string()))
353 }
354 })
355 .collect();
356 }
357 }
358 }
359 Vec::new()
360 }
361
362 pub fn set_meta(&mut self, meta: &[(String, String)]) {
365 let indent = self.detect_indent();
366 self.directives.retain(|d| {
367 !(d.is_non_directive && {
368 let t = d.raw_line.trim();
369 t == "# purple:meta" || t.starts_with("# purple:meta ")
370 })
371 });
372 if !meta.is_empty() {
373 let encoded: Vec<String> = meta
374 .iter()
375 .map(|(k, v)| {
376 let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
377 let clean_v = Self::sanitize_tag(&v.replace(',', ""));
378 format!("{}={}", clean_k, clean_v)
379 })
380 .collect();
381 let pos = self.content_end();
382 self.directives.insert(
383 pos,
384 Directive {
385 key: String::new(),
386 value: String::new(),
387 raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
388 is_non_directive: true,
389 },
390 );
391 }
392 }
393
394 pub fn stale(&self) -> Option<u64> {
397 for d in &self.directives {
398 if d.is_non_directive {
399 let trimmed = d.raw_line.trim();
400 if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
401 return rest.trim().parse::<u64>().ok();
402 }
403 }
404 }
405 None
406 }
407
408 pub fn set_stale(&mut self, timestamp: u64) {
411 let indent = self.detect_indent();
412 self.clear_stale();
413 let pos = self.content_end();
414 self.directives.insert(
415 pos,
416 Directive {
417 key: String::new(),
418 value: String::new(),
419 raw_line: format!("{}# purple:stale {}", indent, timestamp),
420 is_non_directive: true,
421 },
422 );
423 }
424
425 pub fn clear_stale(&mut self) {
427 self.directives.retain(|d| {
428 !(d.is_non_directive && {
429 let t = d.raw_line.trim();
430 t == "# purple:stale" || t.starts_with("# purple:stale ")
431 })
432 });
433 }
434
435 fn sanitize_tag(tag: &str) -> String {
438 tag.chars()
439 .filter(|c| {
440 !c.is_control()
441 && *c != ','
442 && !('\u{200B}'..='\u{200F}').contains(c) && !('\u{202A}'..='\u{202E}').contains(c) && !('\u{2066}'..='\u{2069}').contains(c) && *c != '\u{FEFF}' })
447 .take(128)
448 .collect()
449 }
450
451 pub fn set_tags(&mut self, tags: &[String]) {
453 let indent = self.detect_indent();
454 self.directives.retain(|d| {
455 !(d.is_non_directive && {
456 let t = d.raw_line.trim();
457 t == "# purple:tags" || t.starts_with("# purple:tags ")
458 })
459 });
460 let sanitized: Vec<String> = tags
461 .iter()
462 .map(|t| Self::sanitize_tag(t))
463 .filter(|t| !t.is_empty())
464 .collect();
465 if !sanitized.is_empty() {
466 let pos = self.content_end();
467 self.directives.insert(
468 pos,
469 Directive {
470 key: String::new(),
471 value: String::new(),
472 raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
473 is_non_directive: true,
474 },
475 );
476 }
477 }
478
479 pub fn set_provider_tags(&mut self, tags: &[String]) {
482 let indent = self.detect_indent();
483 self.directives.retain(|d| {
484 !(d.is_non_directive && {
485 let t = d.raw_line.trim();
486 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
487 })
488 });
489 let sanitized: Vec<String> = tags
490 .iter()
491 .map(|t| Self::sanitize_tag(t))
492 .filter(|t| !t.is_empty())
493 .collect();
494 let raw = if sanitized.is_empty() {
495 format!("{}# purple:provider_tags", indent)
496 } else {
497 format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
498 };
499 let pos = self.content_end();
500 self.directives.insert(
501 pos,
502 Directive {
503 key: String::new(),
504 value: String::new(),
505 raw_line: raw,
506 is_non_directive: true,
507 },
508 );
509 }
510
511 pub fn to_host_entry(&self) -> HostEntry {
513 let mut entry = HostEntry {
514 alias: self.host_pattern.clone(),
515 port: 22,
516 ..Default::default()
517 };
518 for d in &self.directives {
519 if d.is_non_directive {
520 continue;
521 }
522 if d.key.eq_ignore_ascii_case("hostname") {
523 entry.hostname = d.value.clone();
524 } else if d.key.eq_ignore_ascii_case("user") {
525 entry.user = d.value.clone();
526 } else if d.key.eq_ignore_ascii_case("port") {
527 entry.port = d.value.parse().unwrap_or(22);
528 } else if d.key.eq_ignore_ascii_case("identityfile") {
529 if entry.identity_file.is_empty() {
530 entry.identity_file = d.value.clone();
531 }
532 } else if d.key.eq_ignore_ascii_case("proxyjump") {
533 entry.proxy_jump = d.value.clone();
534 }
535 }
536 entry.tags = self.tags();
537 entry.provider_tags = self.provider_tags();
538 entry.has_provider_tags = self.has_provider_tags_comment();
539 entry.provider = self.provider().map(|(name, _)| name);
540 entry.tunnel_count = self.tunnel_count();
541 entry.askpass = self.askpass();
542 entry.provider_meta = self.meta();
543 entry.stale = self.stale();
544 entry
545 }
546
547 pub fn tunnel_count(&self) -> u16 {
549 let count = self
550 .directives
551 .iter()
552 .filter(|d| {
553 !d.is_non_directive
554 && (d.key.eq_ignore_ascii_case("localforward")
555 || d.key.eq_ignore_ascii_case("remoteforward")
556 || d.key.eq_ignore_ascii_case("dynamicforward"))
557 })
558 .count();
559 count.min(u16::MAX as usize) as u16
560 }
561
562 #[allow(dead_code)]
564 pub fn has_tunnels(&self) -> bool {
565 self.directives.iter().any(|d| {
566 !d.is_non_directive
567 && (d.key.eq_ignore_ascii_case("localforward")
568 || d.key.eq_ignore_ascii_case("remoteforward")
569 || d.key.eq_ignore_ascii_case("dynamicforward"))
570 })
571 }
572
573 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
575 self.directives
576 .iter()
577 .filter(|d| !d.is_non_directive)
578 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
579 .collect()
580 }
581}
582
583impl SshConfigFile {
584 pub fn host_entries(&self) -> Vec<HostEntry> {
586 let mut entries = Vec::new();
587 Self::collect_host_entries(&self.elements, &mut entries);
588 entries
589 }
590
591 pub fn include_paths(&self) -> Vec<PathBuf> {
593 let mut paths = Vec::new();
594 Self::collect_include_paths(&self.elements, &mut paths);
595 paths
596 }
597
598 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
599 for e in elements {
600 if let ConfigElement::Include(include) = e {
601 for file in &include.resolved_files {
602 paths.push(file.path.clone());
603 Self::collect_include_paths(&file.elements, paths);
604 }
605 }
606 }
607 }
608
609 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
612 let config_dir = self.path.parent();
613 let mut seen = std::collections::HashSet::new();
614 let mut dirs = Vec::new();
615 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
616 dirs
617 }
618
619 fn collect_include_glob_dirs(
620 elements: &[ConfigElement],
621 config_dir: Option<&std::path::Path>,
622 seen: &mut std::collections::HashSet<PathBuf>,
623 dirs: &mut Vec<PathBuf>,
624 ) {
625 for e in elements {
626 if let ConfigElement::Include(include) = e {
627 for single in Self::split_include_patterns(&include.pattern) {
629 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
630 let resolved = if expanded.starts_with('/') {
631 PathBuf::from(&expanded)
632 } else if let Some(dir) = config_dir {
633 dir.join(&expanded)
634 } else {
635 continue;
636 };
637 if let Some(parent) = resolved.parent() {
638 let parent = parent.to_path_buf();
639 if seen.insert(parent.clone()) {
640 dirs.push(parent);
641 }
642 }
643 }
644 for file in &include.resolved_files {
646 Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
647 }
648 }
649 }
650 }
651
652 pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
655 let active_providers: std::collections::HashSet<String> = self
657 .elements
658 .iter()
659 .filter_map(|e| {
660 if let ConfigElement::HostBlock(block) = e {
661 block
662 .provider()
663 .map(|(name, _)| provider_group_display_name(&name).to_string())
664 } else {
665 None
666 }
667 })
668 .collect();
669
670 let mut removed = 0;
671 self.elements.retain(|e| {
672 if let ConfigElement::GlobalLine(line) = e {
673 if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
674 if !active_providers.contains(rest.trim()) {
675 removed += 1;
676 return false;
677 }
678 }
679 }
680 true
681 });
682 removed
683 }
684
685 pub fn repair_absorbed_group_comments(&mut self) -> usize {
689 let mut repaired = 0;
690 let mut idx = 0;
691 while idx < self.elements.len() {
692 let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
693 block
694 .directives
695 .iter()
696 .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
697 } else {
698 false
699 };
700
701 if !needs_repair {
702 idx += 1;
703 continue;
704 }
705
706 let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
708 block
709 } else {
710 unreachable!()
711 };
712
713 let group_idx = block
714 .directives
715 .iter()
716 .position(|d| {
717 d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
718 })
719 .unwrap();
720
721 let mut keep_end = group_idx;
723 while keep_end > 0
724 && block.directives[keep_end - 1].is_non_directive
725 && block.directives[keep_end - 1].raw_line.trim().is_empty()
726 {
727 keep_end -= 1;
728 }
729
730 let extracted: Vec<ConfigElement> = block
732 .directives
733 .drain(keep_end..)
734 .map(|d| ConfigElement::GlobalLine(d.raw_line))
735 .collect();
736
737 let insert_at = idx + 1;
739 for (i, elem) in extracted.into_iter().enumerate() {
740 self.elements.insert(insert_at + i, elem);
741 }
742
743 repaired += 1;
744 idx = insert_at;
746 while idx < self.elements.len() {
748 if let ConfigElement::HostBlock(_) = &self.elements[idx] {
749 break;
750 }
751 idx += 1;
752 }
753 }
754 repaired
755 }
756
757 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
759 for e in elements {
760 match e {
761 ConfigElement::HostBlock(block) => {
762 if is_host_pattern(&block.host_pattern) {
763 continue;
764 }
765 entries.push(block.to_host_entry());
766 }
767 ConfigElement::Include(include) => {
768 for file in &include.resolved_files {
769 let start = entries.len();
770 Self::collect_host_entries(&file.elements, entries);
771 for entry in &mut entries[start..] {
772 if entry.source_file.is_none() {
773 entry.source_file = Some(file.path.clone());
774 }
775 }
776 }
777 }
778 ConfigElement::GlobalLine(_) => {}
779 }
780 }
781 }
782
783 pub fn has_host(&self, alias: &str) -> bool {
786 Self::has_host_in_elements(&self.elements, alias)
787 }
788
789 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
790 for e in elements {
791 match e {
792 ConfigElement::HostBlock(block) => {
793 if block.host_pattern.split_whitespace().any(|p| p == alias) {
794 return true;
795 }
796 }
797 ConfigElement::Include(include) => {
798 for file in &include.resolved_files {
799 if Self::has_host_in_elements(&file.elements, alias) {
800 return true;
801 }
802 }
803 }
804 ConfigElement::GlobalLine(_) => {}
805 }
806 }
807 false
808 }
809
810 pub fn is_included_host(&self, alias: &str) -> bool {
813 for e in &self.elements {
815 match e {
816 ConfigElement::HostBlock(block) => {
817 if block.host_pattern.split_whitespace().any(|p| p == alias) {
818 return false;
819 }
820 }
821 ConfigElement::Include(include) => {
822 for file in &include.resolved_files {
823 if Self::has_host_in_elements(&file.elements, alias) {
824 return true;
825 }
826 }
827 }
828 ConfigElement::GlobalLine(_) => {}
829 }
830 }
831 false
832 }
833
834 pub fn add_host(&mut self, entry: &HostEntry) {
839 let block = Self::entry_to_block(entry);
840 let insert_pos = self.find_trailing_pattern_start();
841
842 if let Some(pos) = insert_pos {
843 let needs_blank_before = pos > 0
845 && !matches!(
846 self.elements.get(pos - 1),
847 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
848 );
849 let mut idx = pos;
850 if needs_blank_before {
851 self.elements
852 .insert(idx, ConfigElement::GlobalLine(String::new()));
853 idx += 1;
854 }
855 self.elements.insert(idx, ConfigElement::HostBlock(block));
856 let after = idx + 1;
858 if after < self.elements.len()
859 && !matches!(
860 self.elements.get(after),
861 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
862 )
863 {
864 self.elements
865 .insert(after, ConfigElement::GlobalLine(String::new()));
866 }
867 } else {
868 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
870 self.elements.push(ConfigElement::GlobalLine(String::new()));
871 }
872 self.elements.push(ConfigElement::HostBlock(block));
873 }
874 }
875
876 fn find_trailing_pattern_start(&self) -> Option<usize> {
881 let mut first_pattern_pos = None;
882 for i in (0..self.elements.len()).rev() {
883 match &self.elements[i] {
884 ConfigElement::HostBlock(block) => {
885 if is_host_pattern(&block.host_pattern) {
886 first_pattern_pos = Some(i);
887 } else {
888 break;
890 }
891 }
892 ConfigElement::GlobalLine(_) => {
893 if first_pattern_pos.is_some() {
895 first_pattern_pos = Some(i);
896 }
897 }
898 ConfigElement::Include(_) => break,
899 }
900 }
901 first_pattern_pos.filter(|&pos| pos > 0)
903 }
904
905 pub fn last_element_has_trailing_blank(&self) -> bool {
907 match self.elements.last() {
908 Some(ConfigElement::HostBlock(block)) => block
909 .directives
910 .last()
911 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
912 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
913 _ => false,
914 }
915 }
916
917 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
920 for element in &mut self.elements {
921 if let ConfigElement::HostBlock(block) = element {
922 if block.host_pattern == old_alias {
923 if entry.alias != block.host_pattern {
925 block.host_pattern = entry.alias.clone();
926 block.raw_host_line = format!("Host {}", entry.alias);
927 }
928
929 Self::upsert_directive(block, "HostName", &entry.hostname);
931 Self::upsert_directive(block, "User", &entry.user);
932 if entry.port != 22 {
933 Self::upsert_directive(block, "Port", &entry.port.to_string());
934 } else {
935 block
937 .directives
938 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
939 }
940 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
941 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
942 return;
943 }
944 }
945 }
946 }
947
948 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
950 if value.is_empty() {
951 block
952 .directives
953 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
954 return;
955 }
956 let indent = block.detect_indent();
957 for d in &mut block.directives {
958 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
959 if d.value != value {
961 d.value = value.to_string();
962 let trimmed = d.raw_line.trim_start();
968 let after_key = &trimmed[d.key.len()..];
969 let sep = if after_key.trim_start().starts_with('=') {
970 let eq_pos = after_key.find('=').unwrap();
971 let after_eq = &after_key[eq_pos + 1..];
972 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
973 after_key[..eq_pos + 1 + trailing_ws].to_string()
974 } else {
975 " ".to_string()
976 };
977 let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
979 d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
980 }
981 return;
982 }
983 }
984 let pos = block.content_end();
986 block.directives.insert(
987 pos,
988 Directive {
989 key: key.to_string(),
990 value: value.to_string(),
991 raw_line: format!("{}{} {}", indent, key, value),
992 is_non_directive: false,
993 },
994 );
995 }
996
997 fn extract_inline_comment(raw_line: &str, key: &str) -> String {
1001 let trimmed = raw_line.trim_start();
1002 if trimmed.len() <= key.len() {
1003 return String::new();
1004 }
1005 let after_key = &trimmed[key.len()..];
1007 let rest = after_key.trim_start();
1008 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
1009 let bytes = rest.as_bytes();
1011 let mut in_quote = false;
1012 for i in 0..bytes.len() {
1013 if bytes[i] == b'"' {
1014 in_quote = !in_quote;
1015 } else if !in_quote
1016 && bytes[i] == b'#'
1017 && i > 0
1018 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
1019 {
1020 let clean_end = rest[..i].trim_end().len();
1022 return rest[clean_end..].to_string();
1023 }
1024 }
1025 String::new()
1026 }
1027
1028 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
1030 for element in &mut self.elements {
1031 if let ConfigElement::HostBlock(block) = element {
1032 if block.host_pattern == alias {
1033 block.set_provider(provider_name, server_id);
1034 return;
1035 }
1036 }
1037 }
1038 }
1039
1040 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
1044 let mut results = Vec::new();
1045 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1046 results
1047 }
1048
1049 fn collect_provider_hosts(
1050 elements: &[ConfigElement],
1051 provider_name: &str,
1052 results: &mut Vec<(String, String)>,
1053 ) {
1054 for element in elements {
1055 match element {
1056 ConfigElement::HostBlock(block) => {
1057 if let Some((name, id)) = block.provider() {
1058 if name == provider_name {
1059 results.push((block.host_pattern.clone(), id));
1060 }
1061 }
1062 }
1063 ConfigElement::Include(include) => {
1064 for file in &include.resolved_files {
1065 Self::collect_provider_hosts(&file.elements, provider_name, results);
1066 }
1067 }
1068 ConfigElement::GlobalLine(_) => {}
1069 }
1070 }
1071 }
1072
1073 fn values_match(a: &str, b: &str) -> bool {
1076 a.split_whitespace().eq(b.split_whitespace())
1077 }
1078
1079 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1083 for element in &mut self.elements {
1084 if let ConfigElement::HostBlock(block) = element {
1085 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1086 let indent = block.detect_indent();
1087 let pos = block.content_end();
1088 block.directives.insert(
1089 pos,
1090 Directive {
1091 key: directive_key.to_string(),
1092 value: value.to_string(),
1093 raw_line: format!("{}{} {}", indent, directive_key, value),
1094 is_non_directive: false,
1095 },
1096 );
1097 return;
1098 }
1099 }
1100 }
1101 }
1102
1103 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1108 for element in &mut self.elements {
1109 if let ConfigElement::HostBlock(block) = element {
1110 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1111 if let Some(pos) = block.directives.iter().position(|d| {
1112 !d.is_non_directive
1113 && d.key.eq_ignore_ascii_case(directive_key)
1114 && Self::values_match(&d.value, value)
1115 }) {
1116 block.directives.remove(pos);
1117 return true;
1118 }
1119 return false;
1120 }
1121 }
1122 }
1123 false
1124 }
1125
1126 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1129 for element in &self.elements {
1130 if let ConfigElement::HostBlock(block) = element {
1131 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1132 return block.directives.iter().any(|d| {
1133 !d.is_non_directive
1134 && d.key.eq_ignore_ascii_case(directive_key)
1135 && Self::values_match(&d.value, value)
1136 });
1137 }
1138 }
1139 }
1140 false
1141 }
1142
1143 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1147 Self::find_tunnel_directives_in(&self.elements, alias)
1148 }
1149
1150 fn find_tunnel_directives_in(
1151 elements: &[ConfigElement],
1152 alias: &str,
1153 ) -> Vec<crate::tunnel::TunnelRule> {
1154 for element in elements {
1155 match element {
1156 ConfigElement::HostBlock(block) => {
1157 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1158 return block.tunnel_directives();
1159 }
1160 }
1161 ConfigElement::Include(include) => {
1162 for file in &include.resolved_files {
1163 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1164 if !rules.is_empty() {
1165 return rules;
1166 }
1167 }
1168 }
1169 ConfigElement::GlobalLine(_) => {}
1170 }
1171 }
1172 Vec::new()
1173 }
1174
1175 pub fn deduplicate_alias(&self, base: &str) -> String {
1177 self.deduplicate_alias_excluding(base, None)
1178 }
1179
1180 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1183 let is_taken = |alias: &str| {
1184 if exclude == Some(alias) {
1185 return false;
1186 }
1187 self.has_host(alias)
1188 };
1189 if !is_taken(base) {
1190 return base.to_string();
1191 }
1192 for n in 2..=9999 {
1193 let candidate = format!("{}-{}", base, n);
1194 if !is_taken(&candidate) {
1195 return candidate;
1196 }
1197 }
1198 format!("{}-{}", base, std::process::id())
1200 }
1201
1202 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1204 for element in &mut self.elements {
1205 if let ConfigElement::HostBlock(block) = element {
1206 if block.host_pattern == alias {
1207 block.set_tags(tags);
1208 return;
1209 }
1210 }
1211 }
1212 }
1213
1214 pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1216 for element in &mut self.elements {
1217 if let ConfigElement::HostBlock(block) = element {
1218 if block.host_pattern == alias {
1219 block.set_provider_tags(tags);
1220 return;
1221 }
1222 }
1223 }
1224 }
1225
1226 pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1228 for element in &mut self.elements {
1229 if let ConfigElement::HostBlock(block) = element {
1230 if block.host_pattern == alias {
1231 block.set_askpass(source);
1232 return;
1233 }
1234 }
1235 }
1236 }
1237
1238 pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1240 for element in &mut self.elements {
1241 if let ConfigElement::HostBlock(block) = element {
1242 if block.host_pattern == alias {
1243 block.set_meta(meta);
1244 return;
1245 }
1246 }
1247 }
1248 }
1249
1250 pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1252 for element in &mut self.elements {
1253 if let ConfigElement::HostBlock(block) = element {
1254 if block.host_pattern == alias {
1255 block.set_stale(timestamp);
1256 return;
1257 }
1258 }
1259 }
1260 }
1261
1262 pub fn clear_host_stale(&mut self, alias: &str) {
1264 for element in &mut self.elements {
1265 if let ConfigElement::HostBlock(block) = element {
1266 if block.host_pattern == alias {
1267 block.clear_stale();
1268 return;
1269 }
1270 }
1271 }
1272 }
1273
1274 pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1276 let mut result = Vec::new();
1277 for element in &self.elements {
1278 if let ConfigElement::HostBlock(block) = element {
1279 if let Some(ts) = block.stale() {
1280 result.push((block.host_pattern.clone(), ts));
1281 }
1282 }
1283 }
1284 result
1285 }
1286
1287 #[allow(dead_code)]
1289 pub fn delete_host(&mut self, alias: &str) {
1290 let provider_name = self.elements.iter().find_map(|e| {
1293 if let ConfigElement::HostBlock(b) = e {
1294 if b.host_pattern == alias {
1295 return b.provider().map(|(name, _)| name);
1296 }
1297 }
1298 None
1299 });
1300
1301 self.elements.retain(|e| match e {
1302 ConfigElement::HostBlock(block) => block.host_pattern != alias,
1303 _ => true,
1304 });
1305
1306 if let Some(name) = provider_name {
1308 self.remove_orphaned_group_header(&name);
1309 }
1310
1311 self.elements.dedup_by(|a, b| {
1313 matches!(
1314 (&*a, &*b),
1315 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1316 if x.trim().is_empty() && y.trim().is_empty()
1317 )
1318 });
1319 }
1320
1321 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1326 let pos = self
1327 .elements
1328 .iter()
1329 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
1330 let element = self.elements.remove(pos);
1331 Some((element, pos))
1332 }
1333
1334 #[allow(dead_code)]
1336 fn find_group_header_position(&self, provider_name: &str) -> Option<usize> {
1337 let display = provider_group_display_name(provider_name);
1338 let header = format!("# purple:group {}", display);
1339 self.elements
1340 .iter()
1341 .position(|e| matches!(e, ConfigElement::GlobalLine(line) if *line == header))
1342 }
1343
1344 fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1347 if self.find_hosts_by_provider(provider_name).is_empty() {
1348 let display = provider_group_display_name(provider_name);
1349 let header = format!("# purple:group {}", display);
1350 self.elements
1351 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1352 }
1353 }
1354
1355 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1357 let pos = position.min(self.elements.len());
1358 self.elements.insert(pos, element);
1359 }
1360
1361 pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1365 let mut last_pos = None;
1366 for (i, element) in self.elements.iter().enumerate() {
1367 if let ConfigElement::HostBlock(block) = element {
1368 if let Some((name, _)) = block.provider() {
1369 if name == provider_name {
1370 last_pos = Some(i);
1371 }
1372 }
1373 }
1374 }
1375 last_pos.map(|p| p + 1)
1377 }
1378
1379 #[allow(dead_code)]
1381 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1382 let pos_a = self
1383 .elements
1384 .iter()
1385 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1386 let pos_b = self
1387 .elements
1388 .iter()
1389 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1390 if let (Some(a), Some(b)) = (pos_a, pos_b) {
1391 if a == b {
1392 return false;
1393 }
1394 let (first, second) = (a.min(b), a.max(b));
1395
1396 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1398 block.pop_trailing_blanks();
1399 }
1400 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1401 block.pop_trailing_blanks();
1402 }
1403
1404 self.elements.swap(first, second);
1406
1407 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1409 block.ensure_trailing_blank();
1410 }
1411
1412 if second < self.elements.len() - 1 {
1414 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1415 block.ensure_trailing_blank();
1416 }
1417 }
1418
1419 return true;
1420 }
1421 false
1422 }
1423
1424 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1426 let mut directives = Vec::new();
1427
1428 if !entry.hostname.is_empty() {
1429 directives.push(Directive {
1430 key: "HostName".to_string(),
1431 value: entry.hostname.clone(),
1432 raw_line: format!(" HostName {}", entry.hostname),
1433 is_non_directive: false,
1434 });
1435 }
1436 if !entry.user.is_empty() {
1437 directives.push(Directive {
1438 key: "User".to_string(),
1439 value: entry.user.clone(),
1440 raw_line: format!(" User {}", entry.user),
1441 is_non_directive: false,
1442 });
1443 }
1444 if entry.port != 22 {
1445 directives.push(Directive {
1446 key: "Port".to_string(),
1447 value: entry.port.to_string(),
1448 raw_line: format!(" Port {}", entry.port),
1449 is_non_directive: false,
1450 });
1451 }
1452 if !entry.identity_file.is_empty() {
1453 directives.push(Directive {
1454 key: "IdentityFile".to_string(),
1455 value: entry.identity_file.clone(),
1456 raw_line: format!(" IdentityFile {}", entry.identity_file),
1457 is_non_directive: false,
1458 });
1459 }
1460 if !entry.proxy_jump.is_empty() {
1461 directives.push(Directive {
1462 key: "ProxyJump".to_string(),
1463 value: entry.proxy_jump.clone(),
1464 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
1465 is_non_directive: false,
1466 });
1467 }
1468
1469 HostBlock {
1470 host_pattern: entry.alias.clone(),
1471 raw_host_line: format!("Host {}", entry.alias),
1472 directives,
1473 }
1474 }
1475}
1476
1477#[cfg(test)]
1478mod tests {
1479 use super::*;
1480
1481 fn parse_str(content: &str) -> SshConfigFile {
1482 SshConfigFile {
1483 elements: SshConfigFile::parse_content(content),
1484 path: PathBuf::from("/tmp/test_config"),
1485 crlf: false,
1486 bom: false,
1487 }
1488 }
1489
1490 #[test]
1491 fn tunnel_directives_extracts_forwards() {
1492 let config = parse_str(
1493 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1494 );
1495 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1496 let rules = block.tunnel_directives();
1497 assert_eq!(rules.len(), 3);
1498 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1499 assert_eq!(rules[0].bind_port, 8080);
1500 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1501 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1502 } else {
1503 panic!("Expected HostBlock");
1504 }
1505 }
1506
1507 #[test]
1508 fn tunnel_count_counts_forwards() {
1509 let config = parse_str(
1510 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n",
1511 );
1512 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1513 assert_eq!(block.tunnel_count(), 2);
1514 } else {
1515 panic!("Expected HostBlock");
1516 }
1517 }
1518
1519 #[test]
1520 fn tunnel_count_zero_for_no_forwards() {
1521 let config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1522 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1523 assert_eq!(block.tunnel_count(), 0);
1524 assert!(!block.has_tunnels());
1525 } else {
1526 panic!("Expected HostBlock");
1527 }
1528 }
1529
1530 #[test]
1531 fn has_tunnels_true_with_forward() {
1532 let config = parse_str("Host myserver\n HostName 10.0.0.1\n DynamicForward 1080\n");
1533 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1534 assert!(block.has_tunnels());
1535 } else {
1536 panic!("Expected HostBlock");
1537 }
1538 }
1539
1540 #[test]
1541 fn add_forward_inserts_directive() {
1542 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1543 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1544 let output = config.serialize();
1545 assert!(output.contains("LocalForward 8080 localhost:80"));
1546 assert!(output.contains("HostName 10.0.0.1"));
1548 assert!(output.contains("User admin"));
1549 }
1550
1551 #[test]
1552 fn add_forward_preserves_indentation() {
1553 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1554 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1555 let output = config.serialize();
1556 assert!(output.contains("\tLocalForward 8080 localhost:80"));
1557 }
1558
1559 #[test]
1560 fn add_multiple_forwards_same_type() {
1561 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1562 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1563 config.add_forward("myserver", "LocalForward", "9090 localhost:90");
1564 let output = config.serialize();
1565 assert!(output.contains("LocalForward 8080 localhost:80"));
1566 assert!(output.contains("LocalForward 9090 localhost:90"));
1567 }
1568
1569 #[test]
1570 fn remove_forward_removes_exact_match() {
1571 let mut config = parse_str(
1572 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1573 );
1574 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1575 let output = config.serialize();
1576 assert!(!output.contains("8080 localhost:80"));
1577 assert!(output.contains("9090 localhost:90"));
1578 }
1579
1580 #[test]
1581 fn remove_forward_leaves_other_directives() {
1582 let mut config = parse_str(
1583 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n User admin\n",
1584 );
1585 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1586 let output = config.serialize();
1587 assert!(!output.contains("LocalForward"));
1588 assert!(output.contains("HostName 10.0.0.1"));
1589 assert!(output.contains("User admin"));
1590 }
1591
1592 #[test]
1593 fn remove_forward_no_match_is_noop() {
1594 let original = "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n";
1595 let mut config = parse_str(original);
1596 config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
1597 assert_eq!(config.serialize(), original);
1598 }
1599
1600 #[test]
1601 fn host_entry_tunnel_count_populated() {
1602 let config = parse_str(
1603 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n DynamicForward 1080\n",
1604 );
1605 let entries = config.host_entries();
1606 assert_eq!(entries.len(), 1);
1607 assert_eq!(entries[0].tunnel_count, 2);
1608 }
1609
1610 #[test]
1611 fn remove_forward_returns_true_on_match() {
1612 let mut config =
1613 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1614 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1615 }
1616
1617 #[test]
1618 fn remove_forward_returns_false_on_no_match() {
1619 let mut config =
1620 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1621 assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
1622 }
1623
1624 #[test]
1625 fn remove_forward_returns_false_for_unknown_host() {
1626 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1627 assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
1628 }
1629
1630 #[test]
1631 fn has_forward_finds_match() {
1632 let config =
1633 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1634 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1635 }
1636
1637 #[test]
1638 fn has_forward_no_match() {
1639 let config =
1640 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1641 assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
1642 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1643 }
1644
1645 #[test]
1646 fn has_forward_case_insensitive_key() {
1647 let config =
1648 parse_str("Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n");
1649 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1650 }
1651
1652 #[test]
1653 fn add_forward_to_empty_block() {
1654 let mut config = parse_str("Host myserver\n");
1655 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1656 let output = config.serialize();
1657 assert!(output.contains("LocalForward 8080 localhost:80"));
1658 }
1659
1660 #[test]
1661 fn remove_forward_case_insensitive_key_match() {
1662 let mut config =
1663 parse_str("Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n");
1664 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1665 assert!(!config.serialize().contains("localforward"));
1666 }
1667
1668 #[test]
1669 fn tunnel_count_case_insensitive() {
1670 let config = parse_str(
1671 "Host myserver\n localforward 8080 localhost:80\n REMOTEFORWARD 9090 localhost:90\n dynamicforward 1080\n",
1672 );
1673 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1674 assert_eq!(block.tunnel_count(), 3);
1675 } else {
1676 panic!("Expected HostBlock");
1677 }
1678 }
1679
1680 #[test]
1681 fn tunnel_directives_extracts_all_types() {
1682 let config = parse_str(
1683 "Host myserver\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1684 );
1685 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1686 let rules = block.tunnel_directives();
1687 assert_eq!(rules.len(), 3);
1688 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1689 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1690 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1691 } else {
1692 panic!("Expected HostBlock");
1693 }
1694 }
1695
1696 #[test]
1697 fn tunnel_directives_skips_malformed() {
1698 let config = parse_str("Host myserver\n LocalForward not_valid\n DynamicForward 1080\n");
1699 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1700 let rules = block.tunnel_directives();
1701 assert_eq!(rules.len(), 1);
1702 assert_eq!(rules[0].bind_port, 1080);
1703 } else {
1704 panic!("Expected HostBlock");
1705 }
1706 }
1707
1708 #[test]
1709 fn find_tunnel_directives_multi_pattern_host() {
1710 let config =
1711 parse_str("Host prod staging\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
1712 let rules = config.find_tunnel_directives("prod");
1713 assert_eq!(rules.len(), 1);
1714 assert_eq!(rules[0].bind_port, 8080);
1715 let rules2 = config.find_tunnel_directives("staging");
1716 assert_eq!(rules2.len(), 1);
1717 }
1718
1719 #[test]
1720 fn find_tunnel_directives_no_match() {
1721 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
1722 let rules = config.find_tunnel_directives("nohost");
1723 assert!(rules.is_empty());
1724 }
1725
1726 #[test]
1727 fn has_forward_exact_match() {
1728 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
1729 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1730 assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
1731 assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
1732 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1733 }
1734
1735 #[test]
1736 fn has_forward_whitespace_normalized() {
1737 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
1738 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1740 }
1741
1742 #[test]
1743 fn has_forward_multi_pattern_host() {
1744 let config = parse_str("Host prod staging\n LocalForward 8080 localhost:80\n");
1745 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1746 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1747 }
1748
1749 #[test]
1750 fn add_forward_multi_pattern_host() {
1751 let mut config = parse_str("Host prod staging\n HostName 10.0.0.1\n");
1752 config.add_forward("prod", "LocalForward", "8080 localhost:80");
1753 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1754 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1755 }
1756
1757 #[test]
1758 fn remove_forward_multi_pattern_host() {
1759 let mut config = parse_str(
1760 "Host prod staging\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1761 );
1762 assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
1763 assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1764 assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
1766 }
1767
1768 #[test]
1769 fn edit_tunnel_detects_duplicate_after_remove() {
1770 let mut config = parse_str(
1772 "Host myserver\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
1773 );
1774 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1776 assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
1778 }
1779
1780 #[test]
1781 fn has_forward_tab_whitespace_normalized() {
1782 let config = parse_str("Host myserver\n LocalForward 8080\tlocalhost:80\n");
1783 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1785 }
1786
1787 #[test]
1788 fn remove_forward_tab_whitespace_normalized() {
1789 let mut config = parse_str("Host myserver\n LocalForward 8080\tlocalhost:80\n");
1790 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1792 assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1793 }
1794
1795 #[test]
1796 fn upsert_preserves_space_separator_when_value_contains_equals() {
1797 let mut config = parse_str("Host myserver\n IdentityFile ~/.ssh/id=prod\n");
1798 let entry = HostEntry {
1799 alias: "myserver".to_string(),
1800 hostname: "10.0.0.1".to_string(),
1801 identity_file: "~/.ssh/id=staging".to_string(),
1802 port: 22,
1803 ..Default::default()
1804 };
1805 config.update_host("myserver", &entry);
1806 let output = config.serialize();
1807 assert!(
1809 output.contains(" IdentityFile ~/.ssh/id=staging"),
1810 "got: {}",
1811 output
1812 );
1813 assert!(!output.contains("IdentityFile="), "got: {}", output);
1814 }
1815
1816 #[test]
1817 fn upsert_preserves_equals_separator() {
1818 let mut config = parse_str("Host myserver\n IdentityFile=~/.ssh/id_rsa\n");
1819 let entry = HostEntry {
1820 alias: "myserver".to_string(),
1821 hostname: "10.0.0.1".to_string(),
1822 identity_file: "~/.ssh/id_ed25519".to_string(),
1823 port: 22,
1824 ..Default::default()
1825 };
1826 config.update_host("myserver", &entry);
1827 let output = config.serialize();
1828 assert!(
1829 output.contains("IdentityFile=~/.ssh/id_ed25519"),
1830 "got: {}",
1831 output
1832 );
1833 }
1834
1835 #[test]
1836 fn upsert_preserves_spaced_equals_separator() {
1837 let mut config = parse_str("Host myserver\n IdentityFile = ~/.ssh/id_rsa\n");
1838 let entry = HostEntry {
1839 alias: "myserver".to_string(),
1840 hostname: "10.0.0.1".to_string(),
1841 identity_file: "~/.ssh/id_ed25519".to_string(),
1842 port: 22,
1843 ..Default::default()
1844 };
1845 config.update_host("myserver", &entry);
1846 let output = config.serialize();
1847 assert!(
1848 output.contains("IdentityFile = ~/.ssh/id_ed25519"),
1849 "got: {}",
1850 output
1851 );
1852 }
1853
1854 #[test]
1855 fn is_included_host_false_for_main_config() {
1856 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1857 assert!(!config.is_included_host("myserver"));
1858 }
1859
1860 #[test]
1861 fn is_included_host_false_for_nonexistent() {
1862 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1863 assert!(!config.is_included_host("nohost"));
1864 }
1865
1866 #[test]
1867 fn is_included_host_multi_pattern_main_config() {
1868 let config = parse_str("Host prod staging\n HostName 10.0.0.1\n");
1869 assert!(!config.is_included_host("prod"));
1870 assert!(!config.is_included_host("staging"));
1871 }
1872
1873 fn first_block(config: &SshConfigFile) -> &HostBlock {
1878 match config.elements.first().unwrap() {
1879 ConfigElement::HostBlock(b) => b,
1880 _ => panic!("Expected HostBlock"),
1881 }
1882 }
1883
1884 fn first_block_mut(config: &mut SshConfigFile) -> &mut HostBlock {
1885 match config.elements.first_mut().unwrap() {
1886 ConfigElement::HostBlock(b) => b,
1887 _ => panic!("Expected HostBlock"),
1888 }
1889 }
1890
1891 fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
1892 let mut count = 0;
1893 for el in &config.elements {
1894 if let ConfigElement::HostBlock(b) = el {
1895 if count == idx {
1896 return b;
1897 }
1898 count += 1;
1899 }
1900 }
1901 panic!("No HostBlock at index {}", idx);
1902 }
1903
1904 #[test]
1905 fn askpass_returns_none_when_absent() {
1906 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1907 assert_eq!(first_block(&config).askpass(), None);
1908 }
1909
1910 #[test]
1911 fn askpass_returns_keychain() {
1912 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1913 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1914 }
1915
1916 #[test]
1917 fn askpass_returns_op_uri() {
1918 let config = parse_str(
1919 "Host myserver\n HostName 10.0.0.1\n # purple:askpass op://Vault/Item/field\n",
1920 );
1921 assert_eq!(
1922 first_block(&config).askpass(),
1923 Some("op://Vault/Item/field".to_string())
1924 );
1925 }
1926
1927 #[test]
1928 fn askpass_returns_vault_with_field() {
1929 let config = parse_str(
1930 "Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:secret/ssh#password\n",
1931 );
1932 assert_eq!(
1933 first_block(&config).askpass(),
1934 Some("vault:secret/ssh#password".to_string())
1935 );
1936 }
1937
1938 #[test]
1939 fn askpass_returns_bw_source() {
1940 let config =
1941 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:my-item\n");
1942 assert_eq!(
1943 first_block(&config).askpass(),
1944 Some("bw:my-item".to_string())
1945 );
1946 }
1947
1948 #[test]
1949 fn askpass_returns_pass_source() {
1950 let config =
1951 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass pass:ssh/prod\n");
1952 assert_eq!(
1953 first_block(&config).askpass(),
1954 Some("pass:ssh/prod".to_string())
1955 );
1956 }
1957
1958 #[test]
1959 fn askpass_returns_custom_command() {
1960 let config =
1961 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass get-pass %a %h\n");
1962 assert_eq!(
1963 first_block(&config).askpass(),
1964 Some("get-pass %a %h".to_string())
1965 );
1966 }
1967
1968 #[test]
1969 fn askpass_ignores_empty_value() {
1970 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass \n");
1971 assert_eq!(first_block(&config).askpass(), None);
1972 }
1973
1974 #[test]
1975 fn askpass_ignores_non_askpass_purple_comments() {
1976 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod\n");
1977 assert_eq!(first_block(&config).askpass(), None);
1978 }
1979
1980 #[test]
1981 fn set_askpass_adds_comment() {
1982 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1983 config.set_host_askpass("myserver", "keychain");
1984 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1985 }
1986
1987 #[test]
1988 fn set_askpass_replaces_existing() {
1989 let mut config =
1990 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
1991 config.set_host_askpass("myserver", "op://V/I/p");
1992 assert_eq!(
1993 first_block(&config).askpass(),
1994 Some("op://V/I/p".to_string())
1995 );
1996 }
1997
1998 #[test]
1999 fn set_askpass_empty_removes_comment() {
2000 let mut config =
2001 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2002 config.set_host_askpass("myserver", "");
2003 assert_eq!(first_block(&config).askpass(), None);
2004 }
2005
2006 #[test]
2007 fn set_askpass_preserves_other_directives() {
2008 let mut config =
2009 parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n # purple:tags prod\n");
2010 config.set_host_askpass("myserver", "vault:secret/ssh");
2011 assert_eq!(
2012 first_block(&config).askpass(),
2013 Some("vault:secret/ssh".to_string())
2014 );
2015 let entry = first_block(&config).to_host_entry();
2016 assert_eq!(entry.user, "admin");
2017 assert!(entry.tags.contains(&"prod".to_string()));
2018 }
2019
2020 #[test]
2021 fn set_askpass_preserves_indent() {
2022 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2023 config.set_host_askpass("myserver", "keychain");
2024 let raw = first_block(&config)
2025 .directives
2026 .iter()
2027 .find(|d| d.raw_line.contains("purple:askpass"))
2028 .unwrap();
2029 assert!(
2030 raw.raw_line.starts_with(" "),
2031 "Expected 4-space indent, got: {:?}",
2032 raw.raw_line
2033 );
2034 }
2035
2036 #[test]
2037 fn set_askpass_on_nonexistent_host() {
2038 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2039 config.set_host_askpass("nohost", "keychain");
2040 assert_eq!(first_block(&config).askpass(), None);
2041 }
2042
2043 #[test]
2044 fn to_entry_includes_askpass() {
2045 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:item\n");
2046 let entries = config.host_entries();
2047 assert_eq!(entries.len(), 1);
2048 assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
2049 }
2050
2051 #[test]
2052 fn to_entry_askpass_none_when_absent() {
2053 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2054 let entries = config.host_entries();
2055 assert_eq!(entries.len(), 1);
2056 assert_eq!(entries[0].askpass, None);
2057 }
2058
2059 #[test]
2060 fn set_askpass_vault_with_hash_field() {
2061 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2062 config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
2063 assert_eq!(
2064 first_block(&config).askpass(),
2065 Some("vault:secret/data/team#api_key".to_string())
2066 );
2067 }
2068
2069 #[test]
2070 fn set_askpass_custom_command_with_percent() {
2071 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2072 config.set_host_askpass("myserver", "get-pass %a %h");
2073 assert_eq!(
2074 first_block(&config).askpass(),
2075 Some("get-pass %a %h".to_string())
2076 );
2077 }
2078
2079 #[test]
2080 fn multiple_hosts_independent_askpass() {
2081 let mut config = parse_str("Host alpha\n HostName a.com\n\nHost beta\n HostName b.com\n");
2082 config.set_host_askpass("alpha", "keychain");
2083 config.set_host_askpass("beta", "vault:secret/ssh");
2084 assert_eq!(
2085 block_by_index(&config, 0).askpass(),
2086 Some("keychain".to_string())
2087 );
2088 assert_eq!(
2089 block_by_index(&config, 1).askpass(),
2090 Some("vault:secret/ssh".to_string())
2091 );
2092 }
2093
2094 #[test]
2095 fn set_askpass_then_clear_then_set_again() {
2096 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2097 config.set_host_askpass("myserver", "keychain");
2098 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2099 config.set_host_askpass("myserver", "");
2100 assert_eq!(first_block(&config).askpass(), None);
2101 config.set_host_askpass("myserver", "op://V/I/p");
2102 assert_eq!(
2103 first_block(&config).askpass(),
2104 Some("op://V/I/p".to_string())
2105 );
2106 }
2107
2108 #[test]
2109 fn askpass_tab_indent_preserved() {
2110 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
2111 config.set_host_askpass("myserver", "pass:ssh/prod");
2112 let raw = first_block(&config)
2113 .directives
2114 .iter()
2115 .find(|d| d.raw_line.contains("purple:askpass"))
2116 .unwrap();
2117 assert!(
2118 raw.raw_line.starts_with("\t"),
2119 "Expected tab indent, got: {:?}",
2120 raw.raw_line
2121 );
2122 }
2123
2124 #[test]
2125 fn askpass_coexists_with_provider_comment() {
2126 let config = parse_str(
2127 "Host myserver\n HostName 10.0.0.1\n # purple:provider do:123\n # purple:askpass keychain\n",
2128 );
2129 let block = first_block(&config);
2130 assert_eq!(block.askpass(), Some("keychain".to_string()));
2131 assert!(block.provider().is_some());
2132 }
2133
2134 #[test]
2135 fn set_askpass_does_not_remove_tags() {
2136 let mut config =
2137 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod,staging\n");
2138 config.set_host_askpass("myserver", "keychain");
2139 let entry = first_block(&config).to_host_entry();
2140 assert_eq!(entry.askpass, Some("keychain".to_string()));
2141 assert!(entry.tags.contains(&"prod".to_string()));
2142 assert!(entry.tags.contains(&"staging".to_string()));
2143 }
2144
2145 #[test]
2146 fn askpass_idempotent_set_same_value() {
2147 let mut config =
2148 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2149 config.set_host_askpass("myserver", "keychain");
2150 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2151 let serialized = config.serialize();
2152 assert_eq!(
2153 serialized.matches("purple:askpass").count(),
2154 1,
2155 "Should have exactly one askpass comment"
2156 );
2157 }
2158
2159 #[test]
2160 fn askpass_with_value_containing_equals() {
2161 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2162 config.set_host_askpass("myserver", "cmd --opt=val %h");
2163 assert_eq!(
2164 first_block(&config).askpass(),
2165 Some("cmd --opt=val %h".to_string())
2166 );
2167 }
2168
2169 #[test]
2170 fn askpass_with_value_containing_hash() {
2171 let config =
2172 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:a/b#c\n");
2173 assert_eq!(
2174 first_block(&config).askpass(),
2175 Some("vault:a/b#c".to_string())
2176 );
2177 }
2178
2179 #[test]
2180 fn askpass_with_long_op_uri() {
2181 let uri = "op://My Personal Vault/SSH Production Server/password";
2182 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2183 config.set_host_askpass("myserver", uri);
2184 assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
2185 }
2186
2187 #[test]
2188 fn askpass_does_not_interfere_with_host_matching() {
2189 let config = parse_str(
2191 "Host myserver\n HostName 10.0.0.1\n User root\n # purple:askpass keychain\n",
2192 );
2193 let entry = first_block(&config).to_host_entry();
2194 assert_eq!(entry.user, "root");
2195 assert_eq!(entry.hostname, "10.0.0.1");
2196 assert_eq!(entry.askpass, Some("keychain".to_string()));
2197 }
2198
2199 #[test]
2200 fn set_askpass_on_host_with_many_directives() {
2201 let config_str = "\
2202Host myserver
2203 HostName 10.0.0.1
2204 User admin
2205 Port 2222
2206 IdentityFile ~/.ssh/id_ed25519
2207 ProxyJump bastion
2208 # purple:tags prod,us-east
2209";
2210 let mut config = parse_str(config_str);
2211 config.set_host_askpass("myserver", "pass:ssh/prod");
2212 let entry = first_block(&config).to_host_entry();
2213 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2214 assert_eq!(entry.user, "admin");
2215 assert_eq!(entry.port, 2222);
2216 assert!(entry.tags.contains(&"prod".to_string()));
2217 }
2218
2219 #[test]
2220 fn askpass_with_crlf_line_endings() {
2221 let config =
2222 parse_str("Host myserver\r\n HostName 10.0.0.1\r\n # purple:askpass keychain\r\n");
2223 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2224 }
2225
2226 #[test]
2227 fn askpass_only_on_first_matching_host() {
2228 let config = parse_str(
2230 "Host dup\n HostName a.com\n # purple:askpass keychain\n\nHost dup\n HostName b.com\n # purple:askpass vault:x\n",
2231 );
2232 let entries = config.host_entries();
2233 assert_eq!(entries[0].askpass, Some("keychain".to_string()));
2235 }
2236
2237 #[test]
2238 fn set_askpass_preserves_other_non_directive_comments() {
2239 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";
2240 let mut config = parse_str(config_str);
2241 config.set_host_askpass("myserver", "new-source");
2242 let serialized = config.serialize();
2243 assert!(serialized.contains("# This is a user comment"));
2244 assert!(serialized.contains("# Another comment"));
2245 assert!(serialized.contains("# purple:askpass new-source"));
2246 assert!(!serialized.contains("# purple:askpass old"));
2247 }
2248
2249 #[test]
2250 fn askpass_mixed_with_tunnel_directives() {
2251 let config_str = "\
2252Host myserver
2253 HostName 10.0.0.1
2254 LocalForward 8080 localhost:80
2255 # purple:askpass bw:item
2256 RemoteForward 9090 localhost:9090
2257";
2258 let config = parse_str(config_str);
2259 let entry = first_block(&config).to_host_entry();
2260 assert_eq!(entry.askpass, Some("bw:item".to_string()));
2261 assert_eq!(entry.tunnel_count, 2);
2262 }
2263
2264 #[test]
2269 fn set_askpass_idempotent_same_value() {
2270 let config_str = "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n";
2271 let mut config = parse_str(config_str);
2272 config.set_host_askpass("myserver", "keychain");
2273 let output = config.serialize();
2274 assert_eq!(output.matches("purple:askpass").count(), 1);
2276 assert!(output.contains("# purple:askpass keychain"));
2277 }
2278
2279 #[test]
2280 fn set_askpass_with_equals_in_value() {
2281 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2282 config.set_host_askpass("myserver", "cmd --opt=val");
2283 let entries = config.host_entries();
2284 assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
2285 }
2286
2287 #[test]
2288 fn set_askpass_with_hash_in_value() {
2289 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2290 config.set_host_askpass("myserver", "vault:secret/data#field");
2291 let entries = config.host_entries();
2292 assert_eq!(
2293 entries[0].askpass,
2294 Some("vault:secret/data#field".to_string())
2295 );
2296 }
2297
2298 #[test]
2299 fn set_askpass_long_op_uri() {
2300 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2301 let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
2302 config.set_host_askpass("myserver", long_uri);
2303 assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
2304 }
2305
2306 #[test]
2307 fn askpass_host_with_multi_pattern_is_skipped() {
2308 let config_str = "Host prod staging\n HostName 10.0.0.1\n";
2311 let mut config = parse_str(config_str);
2312 config.set_host_askpass("prod", "keychain");
2313 assert!(config.host_entries().is_empty());
2315 }
2316
2317 #[test]
2318 fn askpass_survives_directive_reorder() {
2319 let config_str = "\
2321Host myserver
2322 # purple:askpass op://V/I/p
2323 HostName 10.0.0.1
2324 User root
2325";
2326 let config = parse_str(config_str);
2327 let entry = first_block(&config).to_host_entry();
2328 assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
2329 assert_eq!(entry.hostname, "10.0.0.1");
2330 }
2331
2332 #[test]
2333 fn askpass_among_many_purple_comments() {
2334 let config_str = "\
2335Host myserver
2336 HostName 10.0.0.1
2337 # purple:tags prod,us-east
2338 # purple:provider do:12345
2339 # purple:askpass pass:ssh/prod
2340";
2341 let config = parse_str(config_str);
2342 let entry = first_block(&config).to_host_entry();
2343 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2344 assert!(entry.tags.contains(&"prod".to_string()));
2345 }
2346
2347 #[test]
2348 fn meta_empty_when_no_comment() {
2349 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2350 let config = parse_str(config_str);
2351 let meta = first_block(&config).meta();
2352 assert!(meta.is_empty());
2353 }
2354
2355 #[test]
2356 fn meta_parses_key_value_pairs() {
2357 let config_str = "\
2358Host myhost
2359 HostName 1.2.3.4
2360 # purple:meta region=nyc3,plan=s-1vcpu-1gb
2361";
2362 let config = parse_str(config_str);
2363 let meta = first_block(&config).meta();
2364 assert_eq!(meta.len(), 2);
2365 assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
2366 assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
2367 }
2368
2369 #[test]
2370 fn meta_round_trip() {
2371 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2372 let mut config = parse_str(config_str);
2373 let meta = vec![
2374 ("region".to_string(), "fra1".to_string()),
2375 ("plan".to_string(), "cx11".to_string()),
2376 ];
2377 config.set_host_meta("myhost", &meta);
2378 let output = config.serialize();
2379 assert!(output.contains("# purple:meta region=fra1,plan=cx11"));
2380
2381 let config2 = parse_str(&output);
2382 let parsed = first_block(&config2).meta();
2383 assert_eq!(parsed, meta);
2384 }
2385
2386 #[test]
2387 fn meta_replaces_existing() {
2388 let config_str = "\
2389Host myhost
2390 HostName 1.2.3.4
2391 # purple:meta region=old
2392";
2393 let mut config = parse_str(config_str);
2394 config.set_host_meta("myhost", &[("region".to_string(), "new".to_string())]);
2395 let output = config.serialize();
2396 assert!(!output.contains("region=old"));
2397 assert!(output.contains("region=new"));
2398 }
2399
2400 #[test]
2401 fn meta_removed_when_empty() {
2402 let config_str = "\
2403Host myhost
2404 HostName 1.2.3.4
2405 # purple:meta region=nyc3
2406";
2407 let mut config = parse_str(config_str);
2408 config.set_host_meta("myhost", &[]);
2409 let output = config.serialize();
2410 assert!(!output.contains("purple:meta"));
2411 }
2412
2413 #[test]
2414 fn meta_sanitizes_commas_in_values() {
2415 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2416 let mut config = parse_str(config_str);
2417 let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
2418 config.set_host_meta("myhost", &meta);
2419 let output = config.serialize();
2420 assert!(output.contains("plan=s-1vcpu1gb"));
2422
2423 let config2 = parse_str(&output);
2424 let parsed = first_block(&config2).meta();
2425 assert_eq!(parsed[0].1, "s-1vcpu1gb");
2426 }
2427
2428 #[test]
2429 fn meta_in_host_entry() {
2430 let config_str = "\
2431Host myhost
2432 HostName 1.2.3.4
2433 # purple:meta region=nyc3,plan=s-1vcpu-1gb
2434";
2435 let config = parse_str(config_str);
2436 let entry = first_block(&config).to_host_entry();
2437 assert_eq!(entry.provider_meta.len(), 2);
2438 assert_eq!(entry.provider_meta[0].0, "region");
2439 assert_eq!(entry.provider_meta[1].0, "plan");
2440 }
2441
2442 #[test]
2443 fn repair_absorbed_group_comment() {
2444 let mut config = SshConfigFile {
2446 elements: vec![ConfigElement::HostBlock(HostBlock {
2447 host_pattern: "myserver".to_string(),
2448 raw_host_line: "Host myserver".to_string(),
2449 directives: vec![
2450 Directive {
2451 key: "HostName".to_string(),
2452 value: "10.0.0.1".to_string(),
2453 raw_line: " HostName 10.0.0.1".to_string(),
2454 is_non_directive: false,
2455 },
2456 Directive {
2457 key: String::new(),
2458 value: String::new(),
2459 raw_line: "# purple:group Production".to_string(),
2460 is_non_directive: true,
2461 },
2462 ],
2463 })],
2464 path: PathBuf::from("/tmp/test_config"),
2465 crlf: false,
2466 bom: false,
2467 };
2468 let count = config.repair_absorbed_group_comments();
2469 assert_eq!(count, 1);
2470 assert_eq!(config.elements.len(), 2);
2471 if let ConfigElement::HostBlock(block) = &config.elements[0] {
2473 assert_eq!(block.directives.len(), 1);
2474 assert_eq!(block.directives[0].key, "HostName");
2475 } else {
2476 panic!("Expected HostBlock");
2477 }
2478 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2480 assert_eq!(line, "# purple:group Production");
2481 } else {
2482 panic!("Expected GlobalLine for group comment");
2483 }
2484 }
2485
2486 #[test]
2487 fn repair_strips_trailing_blanks_before_group() {
2488 let mut config = SshConfigFile {
2489 elements: vec![ConfigElement::HostBlock(HostBlock {
2490 host_pattern: "myserver".to_string(),
2491 raw_host_line: "Host myserver".to_string(),
2492 directives: vec![
2493 Directive {
2494 key: "HostName".to_string(),
2495 value: "10.0.0.1".to_string(),
2496 raw_line: " HostName 10.0.0.1".to_string(),
2497 is_non_directive: false,
2498 },
2499 Directive {
2500 key: String::new(),
2501 value: String::new(),
2502 raw_line: "".to_string(),
2503 is_non_directive: true,
2504 },
2505 Directive {
2506 key: String::new(),
2507 value: String::new(),
2508 raw_line: "# purple:group Staging".to_string(),
2509 is_non_directive: true,
2510 },
2511 ],
2512 })],
2513 path: PathBuf::from("/tmp/test_config"),
2514 crlf: false,
2515 bom: false,
2516 };
2517 let count = config.repair_absorbed_group_comments();
2518 assert_eq!(count, 1);
2519 if let ConfigElement::HostBlock(block) = &config.elements[0] {
2521 assert_eq!(block.directives.len(), 1);
2522 } else {
2523 panic!("Expected HostBlock");
2524 }
2525 assert_eq!(config.elements.len(), 3);
2527 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2528 assert!(line.trim().is_empty());
2529 } else {
2530 panic!("Expected blank GlobalLine");
2531 }
2532 if let ConfigElement::GlobalLine(line) = &config.elements[2] {
2533 assert!(line.starts_with("# purple:group"));
2534 } else {
2535 panic!("Expected group GlobalLine");
2536 }
2537 }
2538
2539 #[test]
2540 fn repair_clean_config_returns_zero() {
2541 let mut config =
2542 parse_str("# purple:group Production\nHost myserver\n HostName 10.0.0.1\n");
2543 let count = config.repair_absorbed_group_comments();
2544 assert_eq!(count, 0);
2545 }
2546
2547 #[test]
2548 fn repair_roundtrip_serializes_correctly() {
2549 let mut config = SshConfigFile {
2551 elements: vec![
2552 ConfigElement::HostBlock(HostBlock {
2553 host_pattern: "server1".to_string(),
2554 raw_host_line: "Host server1".to_string(),
2555 directives: vec![
2556 Directive {
2557 key: "HostName".to_string(),
2558 value: "10.0.0.1".to_string(),
2559 raw_line: " HostName 10.0.0.1".to_string(),
2560 is_non_directive: false,
2561 },
2562 Directive {
2563 key: String::new(),
2564 value: String::new(),
2565 raw_line: "".to_string(),
2566 is_non_directive: true,
2567 },
2568 Directive {
2569 key: String::new(),
2570 value: String::new(),
2571 raw_line: "# purple:group Staging".to_string(),
2572 is_non_directive: true,
2573 },
2574 ],
2575 }),
2576 ConfigElement::HostBlock(HostBlock {
2577 host_pattern: "server2".to_string(),
2578 raw_host_line: "Host server2".to_string(),
2579 directives: vec![Directive {
2580 key: "HostName".to_string(),
2581 value: "10.0.0.2".to_string(),
2582 raw_line: " HostName 10.0.0.2".to_string(),
2583 is_non_directive: false,
2584 }],
2585 }),
2586 ],
2587 path: PathBuf::from("/tmp/test_config"),
2588 crlf: false,
2589 bom: false,
2590 };
2591 let count = config.repair_absorbed_group_comments();
2592 assert_eq!(count, 1);
2593 let output = config.serialize();
2594 let expected = "\
2596Host server1
2597 HostName 10.0.0.1
2598
2599# purple:group Staging
2600Host server2
2601 HostName 10.0.0.2
2602";
2603 assert_eq!(output, expected);
2604 }
2605
2606 #[test]
2611 fn delete_last_provider_host_removes_group_header() {
2612 let config_str = "\
2613# purple:group DigitalOcean
2614Host do-web
2615 HostName 1.2.3.4
2616 # purple:provider digitalocean:123
2617";
2618 let mut config = parse_str(config_str);
2619 config.delete_host("do-web");
2620 let has_header = config
2621 .elements
2622 .iter()
2623 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
2624 assert!(
2625 !has_header,
2626 "Group header should be removed when last provider host is deleted"
2627 );
2628 }
2629
2630 #[test]
2631 fn delete_one_of_multiple_provider_hosts_preserves_group_header() {
2632 let config_str = "\
2633# purple:group DigitalOcean
2634Host do-web
2635 HostName 1.2.3.4
2636 # purple:provider digitalocean:123
2637
2638Host do-db
2639 HostName 5.6.7.8
2640 # purple:provider digitalocean:456
2641";
2642 let mut config = parse_str(config_str);
2643 config.delete_host("do-web");
2644 let has_header = config.elements.iter().any(|e| {
2645 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2646 });
2647 assert!(
2648 has_header,
2649 "Group header should be preserved when other provider hosts remain"
2650 );
2651 assert_eq!(config.host_entries().len(), 1);
2652 }
2653
2654 #[test]
2655 fn delete_non_provider_host_leaves_group_headers() {
2656 let config_str = "\
2657Host personal
2658 HostName 10.0.0.1
2659
2660# purple:group DigitalOcean
2661Host do-web
2662 HostName 1.2.3.4
2663 # purple:provider digitalocean:123
2664";
2665 let mut config = parse_str(config_str);
2666 config.delete_host("personal");
2667 let has_header = config.elements.iter().any(|e| {
2668 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2669 });
2670 assert!(
2671 has_header,
2672 "Group header should not be affected by deleting a non-provider host"
2673 );
2674 assert_eq!(config.host_entries().len(), 1);
2675 }
2676
2677 #[test]
2678 fn delete_host_undoable_keeps_group_header_for_undo() {
2679 let config_str = "\
2683# purple:group Vultr
2684Host vultr-web
2685 HostName 2.3.4.5
2686 # purple:provider vultr:789
2687";
2688 let mut config = parse_str(config_str);
2689 let result = config.delete_host_undoable("vultr-web");
2690 assert!(result.is_some());
2691 let has_header = config
2692 .elements
2693 .iter()
2694 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
2695 assert!(has_header, "Group header should be kept for undo");
2696 }
2697
2698 #[test]
2699 fn delete_host_undoable_preserves_header_when_others_remain() {
2700 let config_str = "\
2701# purple:group AWS EC2
2702Host aws-web
2703 HostName 3.4.5.6
2704 # purple:provider aws:i-111
2705
2706Host aws-db
2707 HostName 7.8.9.0
2708 # purple:provider aws:i-222
2709";
2710 let mut config = parse_str(config_str);
2711 let result = config.delete_host_undoable("aws-web");
2712 assert!(result.is_some());
2713 let has_header = config.elements.iter().any(
2714 |e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group AWS EC2")),
2715 );
2716 assert!(
2717 has_header,
2718 "Group header preserved when other provider hosts remain (undoable)"
2719 );
2720 }
2721
2722 #[test]
2723 fn delete_host_undoable_returns_original_position_for_undo() {
2724 let config_str = "\
2727# purple:group Vultr
2728Host vultr-web
2729 HostName 2.3.4.5
2730 # purple:provider vultr:789
2731
2732Host manual
2733 HostName 10.0.0.1
2734";
2735 let mut config = parse_str(config_str);
2736 let (element, pos) = config.delete_host_undoable("vultr-web").unwrap();
2737 assert_eq!(pos, 1, "Position should be the original host index");
2739 config.insert_host_at(element, pos);
2741 let output = config.serialize();
2743 assert!(
2744 output.contains("# purple:group Vultr"),
2745 "Group header should be present"
2746 );
2747 assert!(output.contains("Host vultr-web"), "Host should be restored");
2748 assert!(output.contains("Host manual"), "Manual host should survive");
2749 assert_eq!(config_str, output);
2750 }
2751
2752 #[test]
2757 fn add_host_inserts_before_trailing_wildcard() {
2758 let config_str = "\
2759Host existing
2760 HostName 10.0.0.1
2761
2762Host *
2763 ServerAliveInterval 60
2764";
2765 let mut config = parse_str(config_str);
2766 let entry = HostEntry {
2767 alias: "newhost".to_string(),
2768 hostname: "10.0.0.2".to_string(),
2769 port: 22,
2770 ..Default::default()
2771 };
2772 config.add_host(&entry);
2773 let output = config.serialize();
2774 let new_pos = output.find("Host newhost").unwrap();
2775 let wildcard_pos = output.find("Host *").unwrap();
2776 assert!(
2777 new_pos < wildcard_pos,
2778 "New host should appear before Host *: {}",
2779 output
2780 );
2781 let existing_pos = output.find("Host existing").unwrap();
2782 assert!(existing_pos < new_pos);
2783 }
2784
2785 #[test]
2786 fn add_host_appends_when_no_wildcards() {
2787 let config_str = "\
2788Host existing
2789 HostName 10.0.0.1
2790";
2791 let mut config = parse_str(config_str);
2792 let entry = HostEntry {
2793 alias: "newhost".to_string(),
2794 hostname: "10.0.0.2".to_string(),
2795 port: 22,
2796 ..Default::default()
2797 };
2798 config.add_host(&entry);
2799 let output = config.serialize();
2800 let existing_pos = output.find("Host existing").unwrap();
2801 let new_pos = output.find("Host newhost").unwrap();
2802 assert!(existing_pos < new_pos, "New host should be appended at end");
2803 }
2804
2805 #[test]
2806 fn add_host_appends_when_wildcard_at_beginning() {
2807 let config_str = "\
2809Host *
2810 ServerAliveInterval 60
2811
2812Host existing
2813 HostName 10.0.0.1
2814";
2815 let mut config = parse_str(config_str);
2816 let entry = HostEntry {
2817 alias: "newhost".to_string(),
2818 hostname: "10.0.0.2".to_string(),
2819 port: 22,
2820 ..Default::default()
2821 };
2822 config.add_host(&entry);
2823 let output = config.serialize();
2824 let existing_pos = output.find("Host existing").unwrap();
2825 let new_pos = output.find("Host newhost").unwrap();
2826 assert!(
2827 existing_pos < new_pos,
2828 "New host should be appended at end when wildcard is at top: {}",
2829 output
2830 );
2831 }
2832
2833 #[test]
2834 fn add_host_inserts_before_trailing_pattern_host() {
2835 let config_str = "\
2836Host existing
2837 HostName 10.0.0.1
2838
2839Host *.example.com
2840 ProxyJump bastion
2841";
2842 let mut config = parse_str(config_str);
2843 let entry = HostEntry {
2844 alias: "newhost".to_string(),
2845 hostname: "10.0.0.2".to_string(),
2846 port: 22,
2847 ..Default::default()
2848 };
2849 config.add_host(&entry);
2850 let output = config.serialize();
2851 let new_pos = output.find("Host newhost").unwrap();
2852 let pattern_pos = output.find("Host *.example.com").unwrap();
2853 assert!(
2854 new_pos < pattern_pos,
2855 "New host should appear before pattern host: {}",
2856 output
2857 );
2858 }
2859
2860 #[test]
2861 fn add_host_no_triple_blank_lines() {
2862 let config_str = "\
2863Host existing
2864 HostName 10.0.0.1
2865
2866Host *
2867 ServerAliveInterval 60
2868";
2869 let mut config = parse_str(config_str);
2870 let entry = HostEntry {
2871 alias: "newhost".to_string(),
2872 hostname: "10.0.0.2".to_string(),
2873 port: 22,
2874 ..Default::default()
2875 };
2876 config.add_host(&entry);
2877 let output = config.serialize();
2878 assert!(
2879 !output.contains("\n\n\n"),
2880 "Should not have triple blank lines: {}",
2881 output
2882 );
2883 }
2884
2885 #[test]
2886 fn provider_group_display_name_matches_providers_mod() {
2887 let providers = [
2892 "digitalocean",
2893 "vultr",
2894 "linode",
2895 "hetzner",
2896 "upcloud",
2897 "proxmox",
2898 "aws",
2899 "scaleway",
2900 "gcp",
2901 "azure",
2902 "tailscale",
2903 ];
2904 for name in &providers {
2905 assert_eq!(
2906 provider_group_display_name(name),
2907 crate::providers::provider_display_name(name),
2908 "Display name mismatch for provider '{}': model.rs has '{}' but providers/mod.rs has '{}'",
2909 name,
2910 provider_group_display_name(name),
2911 crate::providers::provider_display_name(name),
2912 );
2913 }
2914 }
2915
2916 #[test]
2917 fn test_sanitize_tag_strips_control_chars() {
2918 assert_eq!(HostBlock::sanitize_tag("prod"), "prod");
2919 assert_eq!(HostBlock::sanitize_tag("prod\n"), "prod");
2920 assert_eq!(HostBlock::sanitize_tag("pr\x00od"), "prod");
2921 assert_eq!(HostBlock::sanitize_tag("\t\r\n"), "");
2922 }
2923
2924 #[test]
2925 fn test_sanitize_tag_strips_commas() {
2926 assert_eq!(HostBlock::sanitize_tag("prod,staging"), "prodstaging");
2927 assert_eq!(HostBlock::sanitize_tag(",,,"), "");
2928 }
2929
2930 #[test]
2931 fn test_sanitize_tag_strips_bidi() {
2932 assert_eq!(HostBlock::sanitize_tag("prod\u{202E}tset"), "prodtset");
2933 assert_eq!(HostBlock::sanitize_tag("\u{200B}zero\u{FEFF}"), "zero");
2934 }
2935
2936 #[test]
2937 fn test_sanitize_tag_truncates_long() {
2938 let long = "a".repeat(200);
2939 assert_eq!(HostBlock::sanitize_tag(&long).len(), 128);
2940 }
2941
2942 #[test]
2943 fn test_sanitize_tag_preserves_unicode() {
2944 assert_eq!(HostBlock::sanitize_tag("日本語"), "日本語");
2945 assert_eq!(HostBlock::sanitize_tag("café"), "café");
2946 }
2947
2948 #[test]
2953 fn test_provider_tags_parsing() {
2954 let config =
2955 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags a,b,c\n");
2956 let entry = first_block(&config).to_host_entry();
2957 assert_eq!(entry.provider_tags, vec!["a", "b", "c"]);
2958 }
2959
2960 #[test]
2961 fn test_provider_tags_empty() {
2962 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2963 let entry = first_block(&config).to_host_entry();
2964 assert!(entry.provider_tags.is_empty());
2965 }
2966
2967 #[test]
2968 fn test_has_provider_tags_comment_present() {
2969 let config =
2970 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags prod\n");
2971 assert!(first_block(&config).has_provider_tags_comment());
2972 assert!(first_block(&config).to_host_entry().has_provider_tags);
2973 }
2974
2975 #[test]
2976 fn test_has_provider_tags_comment_sentinel() {
2977 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags\n");
2979 assert!(first_block(&config).has_provider_tags_comment());
2980 assert!(first_block(&config).to_host_entry().has_provider_tags);
2981 assert!(
2982 first_block(&config)
2983 .to_host_entry()
2984 .provider_tags
2985 .is_empty()
2986 );
2987 }
2988
2989 #[test]
2990 fn test_has_provider_tags_comment_absent() {
2991 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2992 assert!(!first_block(&config).has_provider_tags_comment());
2993 assert!(!first_block(&config).to_host_entry().has_provider_tags);
2994 }
2995
2996 #[test]
2997 fn test_set_tags_does_not_delete_provider_tags() {
2998 let mut config = parse_str(
2999 "Host myserver\n HostName 10.0.0.1\n # purple:tags user1\n # purple:provider_tags cloud1,cloud2\n",
3000 );
3001 config.set_host_tags("myserver", &["newuser".to_string()]);
3002 let entry = first_block(&config).to_host_entry();
3003 assert_eq!(entry.tags, vec!["newuser"]);
3004 assert_eq!(entry.provider_tags, vec!["cloud1", "cloud2"]);
3005 }
3006
3007 #[test]
3008 fn test_set_provider_tags_does_not_delete_user_tags() {
3009 let mut config = parse_str(
3010 "Host myserver\n HostName 10.0.0.1\n # purple:tags user1,user2\n # purple:provider_tags old\n",
3011 );
3012 config.set_host_provider_tags("myserver", &["new1".to_string(), "new2".to_string()]);
3013 let entry = first_block(&config).to_host_entry();
3014 assert_eq!(entry.tags, vec!["user1", "user2"]);
3015 assert_eq!(entry.provider_tags, vec!["new1", "new2"]);
3016 }
3017
3018 #[test]
3019 fn test_set_askpass_does_not_delete_similar_comments() {
3020 let mut config = parse_str(
3022 "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n # purple:askpass_backup test\n",
3023 );
3024 config.set_host_askpass("myserver", "op://vault/item/pass");
3025 let entry = first_block(&config).to_host_entry();
3026 assert_eq!(entry.askpass, Some("op://vault/item/pass".to_string()));
3027 let serialized = config.serialize();
3029 assert!(serialized.contains("purple:askpass_backup test"));
3030 }
3031
3032 #[test]
3033 fn test_set_meta_does_not_delete_similar_comments() {
3034 let mut config = parse_str(
3036 "Host myserver\n HostName 10.0.0.1\n # purple:meta region=us-east\n # purple:metadata foo\n",
3037 );
3038 config.set_host_meta("myserver", &[("region".to_string(), "eu-west".to_string())]);
3039 let serialized = config.serialize();
3040 assert!(serialized.contains("purple:meta region=eu-west"));
3041 assert!(serialized.contains("purple:metadata foo"));
3042 }
3043
3044 #[test]
3045 fn test_set_meta_sanitizes_control_chars() {
3046 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
3047 config.set_host_meta(
3048 "myserver",
3049 &[
3050 ("region".to_string(), "us\x00east".to_string()),
3051 ("zone".to_string(), "a\u{202E}b".to_string()),
3052 ],
3053 );
3054 let serialized = config.serialize();
3055 assert!(serialized.contains("region=useast"));
3057 assert!(serialized.contains("zone=ab"));
3058 assert!(!serialized.contains('\x00'));
3059 assert!(!serialized.contains('\u{202E}'));
3060 }
3061
3062 #[test]
3065 fn stale_returns_timestamp() {
3066 let config_str = "\
3067Host web
3068 HostName 1.2.3.4
3069 # purple:stale 1711900000
3070";
3071 let config = parse_str(config_str);
3072 assert_eq!(first_block(&config).stale(), Some(1711900000));
3073 }
3074
3075 #[test]
3076 fn stale_returns_none_when_absent() {
3077 let config_str = "Host web\n HostName 1.2.3.4\n";
3078 let config = parse_str(config_str);
3079 assert_eq!(first_block(&config).stale(), None);
3080 }
3081
3082 #[test]
3083 fn stale_returns_none_for_malformed() {
3084 for bad in &[
3085 "Host w\n HostName 1.2.3.4\n # purple:stale abc\n",
3086 "Host w\n HostName 1.2.3.4\n # purple:stale\n",
3087 "Host w\n HostName 1.2.3.4\n # purple:stale -1\n",
3088 ] {
3089 let config = parse_str(bad);
3090 assert_eq!(first_block(&config).stale(), None, "input: {bad}");
3091 }
3092 }
3093
3094 #[test]
3095 fn set_stale_adds_comment() {
3096 let config_str = "Host web\n HostName 1.2.3.4\n";
3097 let mut config = parse_str(config_str);
3098 first_block_mut(&mut config).set_stale(1711900000);
3099 assert_eq!(first_block(&config).stale(), Some(1711900000));
3100 assert!(config.serialize().contains("# purple:stale 1711900000"));
3101 }
3102
3103 #[test]
3104 fn set_stale_replaces_existing() {
3105 let config_str = "\
3106Host web
3107 HostName 1.2.3.4
3108 # purple:stale 1000
3109";
3110 let mut config = parse_str(config_str);
3111 first_block_mut(&mut config).set_stale(2000);
3112 assert_eq!(first_block(&config).stale(), Some(2000));
3113 let output = config.serialize();
3114 assert!(!output.contains("1000"));
3115 assert!(output.contains("# purple:stale 2000"));
3116 }
3117
3118 #[test]
3119 fn clear_stale_removes_comment() {
3120 let config_str = "\
3121Host web
3122 HostName 1.2.3.4
3123 # purple:stale 1711900000
3124";
3125 let mut config = parse_str(config_str);
3126 first_block_mut(&mut config).clear_stale();
3127 assert_eq!(first_block(&config).stale(), None);
3128 assert!(!config.serialize().contains("purple:stale"));
3129 }
3130
3131 #[test]
3132 fn clear_stale_when_absent_is_noop() {
3133 let config_str = "Host web\n HostName 1.2.3.4\n";
3134 let mut config = parse_str(config_str);
3135 let before = config.serialize();
3136 first_block_mut(&mut config).clear_stale();
3137 assert_eq!(config.serialize(), before);
3138 }
3139
3140 #[test]
3141 fn stale_roundtrip() {
3142 let config_str = "\
3143Host web
3144 HostName 1.2.3.4
3145 # purple:stale 1711900000
3146";
3147 let config = parse_str(config_str);
3148 let output = config.serialize();
3149 let config2 = parse_str(&output);
3150 assert_eq!(first_block(&config2).stale(), Some(1711900000));
3151 }
3152
3153 #[test]
3154 fn stale_in_host_entry() {
3155 let config_str = "\
3156Host web
3157 HostName 1.2.3.4
3158 # purple:stale 1711900000
3159";
3160 let config = parse_str(config_str);
3161 let entry = first_block(&config).to_host_entry();
3162 assert_eq!(entry.stale, Some(1711900000));
3163 }
3164
3165 #[test]
3166 fn stale_coexists_with_other_annotations() {
3167 let config_str = "\
3168Host web
3169 HostName 1.2.3.4
3170 # purple:tags prod
3171 # purple:provider do:12345
3172 # purple:askpass keychain
3173 # purple:meta region=nyc3
3174 # purple:stale 1711900000
3175";
3176 let config = parse_str(config_str);
3177 let entry = first_block(&config).to_host_entry();
3178 assert_eq!(entry.stale, Some(1711900000));
3179 assert!(entry.tags.contains(&"prod".to_string()));
3180 assert_eq!(entry.provider, Some("do".to_string()));
3181 assert_eq!(entry.askpass, Some("keychain".to_string()));
3182 assert_eq!(entry.provider_meta[0].0, "region");
3183 }
3184
3185 #[test]
3186 fn set_host_stale_delegates() {
3187 let config_str = "\
3188Host web
3189 HostName 1.2.3.4
3190
3191Host db
3192 HostName 5.6.7.8
3193";
3194 let mut config = parse_str(config_str);
3195 config.set_host_stale("db", 1234567890);
3196 assert_eq!(config.host_entries()[1].stale, Some(1234567890));
3197 assert_eq!(config.host_entries()[0].stale, None);
3198 }
3199
3200 #[test]
3201 fn clear_host_stale_delegates() {
3202 let config_str = "\
3203Host web
3204 HostName 1.2.3.4
3205 # purple:stale 1711900000
3206";
3207 let mut config = parse_str(config_str);
3208 config.clear_host_stale("web");
3209 assert_eq!(first_block(&config).stale(), None);
3210 }
3211
3212 #[test]
3213 fn stale_hosts_collects_all() {
3214 let config_str = "\
3215Host web
3216 HostName 1.2.3.4
3217 # purple:stale 1000
3218
3219Host db
3220 HostName 5.6.7.8
3221
3222Host app
3223 HostName 9.10.11.12
3224 # purple:stale 2000
3225";
3226 let config = parse_str(config_str);
3227 let stale = config.stale_hosts();
3228 assert_eq!(stale.len(), 2);
3229 assert_eq!(stale[0], ("web".to_string(), 1000));
3230 assert_eq!(stale[1], ("app".to_string(), 2000));
3231 }
3232
3233 #[test]
3234 fn set_stale_preserves_indent() {
3235 let config_str = "Host web\n\tHostName 1.2.3.4\n";
3236 let mut config = parse_str(config_str);
3237 first_block_mut(&mut config).set_stale(1711900000);
3238 assert!(config.serialize().contains("\t# purple:stale 1711900000"));
3239 }
3240
3241 #[test]
3242 fn stale_does_not_match_similar_comments() {
3243 let config_str = "\
3244Host web
3245 HostName 1.2.3.4
3246 # purple:stale_backup 999
3247";
3248 let config = parse_str(config_str);
3249 assert_eq!(first_block(&config).stale(), None);
3250 }
3251
3252 #[test]
3253 fn stale_with_whitespace_in_timestamp() {
3254 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 1711900000 \n";
3255 let config = parse_str(config_str);
3256 assert_eq!(first_block(&config).stale(), Some(1711900000));
3257 }
3258
3259 #[test]
3260 fn stale_with_u64_max() {
3261 let ts = u64::MAX;
3262 let config_str = format!("Host w\n HostName 1.2.3.4\n # purple:stale {}\n", ts);
3263 let config = parse_str(&config_str);
3264 assert_eq!(first_block(&config).stale(), Some(ts));
3265 let output = config.serialize();
3267 let config2 = parse_str(&output);
3268 assert_eq!(first_block(&config2).stale(), Some(ts));
3269 }
3270
3271 #[test]
3272 fn stale_with_u64_overflow() {
3273 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 18446744073709551616\n";
3274 let config = parse_str(config_str);
3275 assert_eq!(first_block(&config).stale(), None);
3276 }
3277
3278 #[test]
3279 fn stale_timestamp_zero() {
3280 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 0\n";
3281 let config = parse_str(config_str);
3282 assert_eq!(first_block(&config).stale(), Some(0));
3283 }
3284
3285 #[test]
3286 fn set_host_stale_nonexistent_alias_is_noop() {
3287 let config_str = "Host web\n HostName 1.2.3.4\n";
3288 let mut config = parse_str(config_str);
3289 let before = config.serialize();
3290 config.set_host_stale("nonexistent", 12345);
3291 assert_eq!(config.serialize(), before);
3292 }
3293
3294 #[test]
3295 fn clear_host_stale_nonexistent_alias_is_noop() {
3296 let config_str = "Host web\n HostName 1.2.3.4\n";
3297 let mut config = parse_str(config_str);
3298 let before = config.serialize();
3299 config.clear_host_stale("nonexistent");
3300 assert_eq!(config.serialize(), before);
3301 }
3302
3303 #[test]
3304 fn stale_hosts_empty_config() {
3305 let config_str = "";
3306 let config = parse_str(config_str);
3307 assert!(config.stale_hosts().is_empty());
3308 }
3309
3310 #[test]
3311 fn stale_hosts_no_stale() {
3312 let config_str = "Host web\n HostName 1.2.3.4\n\nHost db\n HostName 5.6.7.8\n";
3313 let config = parse_str(config_str);
3314 assert!(config.stale_hosts().is_empty());
3315 }
3316
3317 #[test]
3318 fn clear_stale_preserves_other_purple_comments() {
3319 let config_str = "\
3320Host web
3321 HostName 1.2.3.4
3322 # purple:tags prod
3323 # purple:provider do:123
3324 # purple:askpass keychain
3325 # purple:meta region=nyc3
3326 # purple:stale 1711900000
3327";
3328 let mut config = parse_str(config_str);
3329 config.clear_host_stale("web");
3330 let entry = first_block(&config).to_host_entry();
3331 assert_eq!(entry.stale, None);
3332 assert!(entry.tags.contains(&"prod".to_string()));
3333 assert_eq!(entry.provider, Some("do".to_string()));
3334 assert_eq!(entry.askpass, Some("keychain".to_string()));
3335 assert_eq!(entry.provider_meta[0].0, "region");
3336 }
3337
3338 #[test]
3339 fn set_stale_preserves_other_purple_comments() {
3340 let config_str = "\
3341Host web
3342 HostName 1.2.3.4
3343 # purple:tags prod
3344 # purple:provider do:123
3345 # purple:askpass keychain
3346 # purple:meta region=nyc3
3347";
3348 let mut config = parse_str(config_str);
3349 config.set_host_stale("web", 1711900000);
3350 let entry = first_block(&config).to_host_entry();
3351 assert_eq!(entry.stale, Some(1711900000));
3352 assert!(entry.tags.contains(&"prod".to_string()));
3353 assert_eq!(entry.provider, Some("do".to_string()));
3354 assert_eq!(entry.askpass, Some("keychain".to_string()));
3355 assert_eq!(entry.provider_meta[0].0, "region");
3356 }
3357
3358 #[test]
3359 fn stale_multiple_comments_first_wins() {
3360 let config_str = "\
3361Host web
3362 HostName 1.2.3.4
3363 # purple:stale 1000
3364 # purple:stale 2000
3365";
3366 let config = parse_str(config_str);
3367 assert_eq!(first_block(&config).stale(), Some(1000));
3368 }
3369
3370 #[test]
3371 fn set_stale_removes_multiple_stale_comments() {
3372 let config_str = "\
3373Host web
3374 HostName 1.2.3.4
3375 # purple:stale 1000
3376 # purple:stale 2000
3377";
3378 let mut config = parse_str(config_str);
3379 first_block_mut(&mut config).set_stale(3000);
3380 assert_eq!(first_block(&config).stale(), Some(3000));
3381 let output = config.serialize();
3382 assert_eq!(output.matches("purple:stale").count(), 1);
3383 }
3384
3385 #[test]
3386 fn stale_absent_in_host_entry() {
3387 let config_str = "Host web\n HostName 1.2.3.4\n";
3388 let config = parse_str(config_str);
3389 assert_eq!(first_block(&config).to_host_entry().stale, None);
3390 }
3391
3392 #[test]
3393 fn set_stale_four_space_indent() {
3394 let config_str = "Host web\n HostName 1.2.3.4\n";
3395 let mut config = parse_str(config_str);
3396 first_block_mut(&mut config).set_stale(1711900000);
3397 assert!(config.serialize().contains(" # purple:stale 1711900000"));
3398 }
3399
3400 #[test]
3401 fn clear_stale_removes_bare_comment() {
3402 let config_str = "Host web\n HostName 1.2.3.4\n # purple:stale\n";
3403 let mut config = parse_str(config_str);
3404 first_block_mut(&mut config).clear_stale();
3405 assert!(!config.serialize().contains("purple:stale"));
3406 }
3407
3408 #[test]
3411 fn stale_preserves_blank_line_between_hosts() {
3412 let config_str = "\
3413Host web
3414 HostName 1.2.3.4
3415
3416Host db
3417 HostName 5.6.7.8
3418";
3419 let mut config = parse_str(config_str);
3420 config.set_host_stale("web", 1711900000);
3421 let output = config.serialize();
3422 assert!(
3424 output.contains("# purple:stale 1711900000\n\nHost db"),
3425 "blank line between hosts lost after set_stale:\n{}",
3426 output
3427 );
3428 }
3429
3430 #[test]
3431 fn stale_preserves_blank_line_before_group_header() {
3432 let config_str = "\
3433Host do-web
3434 HostName 1.2.3.4
3435 # purple:provider digitalocean:111
3436
3437# purple:group Hetzner
3438
3439Host hz-cache
3440 HostName 9.10.11.12
3441 # purple:provider hetzner:333
3442";
3443 let mut config = parse_str(config_str);
3444 config.set_host_stale("do-web", 1711900000);
3445 let output = config.serialize();
3446 assert!(
3448 output.contains("\n\n# purple:group Hetzner"),
3449 "blank line before group header lost after set_stale:\n{}",
3450 output
3451 );
3452 }
3453
3454 #[test]
3455 fn stale_set_and_clear_is_byte_identical() {
3456 let config_str = "\
3457Host manual
3458 HostName 10.0.0.1
3459 User admin
3460
3461# purple:group DigitalOcean
3462
3463Host do-web
3464 HostName 1.2.3.4
3465 User root
3466 # purple:provider digitalocean:111
3467 # purple:tags prod
3468
3469Host do-db
3470 HostName 5.6.7.8
3471 User root
3472 # purple:provider digitalocean:222
3473 # purple:meta region=nyc3
3474
3475# purple:group Hetzner
3476
3477Host hz-cache
3478 HostName 9.10.11.12
3479 User root
3480 # purple:provider hetzner:333
3481";
3482 let original = config_str.to_string();
3483 let mut config = parse_str(config_str);
3484
3485 config.set_host_stale("do-db", 1711900000);
3487 let after_stale = config.serialize();
3488 assert_ne!(after_stale, original, "stale should change the config");
3489
3490 config.clear_host_stale("do-db");
3492 let after_clear = config.serialize();
3493 assert_eq!(
3494 after_clear, original,
3495 "clearing stale must restore byte-identical config"
3496 );
3497 }
3498
3499 #[test]
3500 fn stale_does_not_accumulate_blank_lines() {
3501 let config_str = "Host web\n HostName 1.2.3.4\n\nHost db\n HostName 5.6.7.8\n";
3502 let mut config = parse_str(config_str);
3503
3504 for _ in 0..10 {
3506 config.set_host_stale("web", 1711900000);
3507 config.clear_host_stale("web");
3508 }
3509
3510 let output = config.serialize();
3511 assert_eq!(
3512 output, config_str,
3513 "repeated set/clear must not accumulate blank lines"
3514 );
3515 }
3516
3517 #[test]
3518 fn stale_preserves_all_directives_and_comments() {
3519 let config_str = "\
3520Host complex
3521 HostName 1.2.3.4
3522 User deploy
3523 Port 2222
3524 IdentityFile ~/.ssh/id_ed25519
3525 ProxyJump bastion
3526 LocalForward 8080 localhost:80
3527 # purple:provider digitalocean:999
3528 # purple:tags prod,us-east
3529 # purple:provider_tags web-tier
3530 # purple:askpass keychain
3531 # purple:meta region=nyc3,plan=s-1vcpu-1gb
3532 # This is a user comment
3533";
3534 let mut config = parse_str(config_str);
3535 let entry_before = first_block(&config).to_host_entry();
3536
3537 config.set_host_stale("complex", 1711900000);
3538 let entry_after = first_block(&config).to_host_entry();
3539
3540 assert_eq!(entry_after.hostname, entry_before.hostname);
3542 assert_eq!(entry_after.user, entry_before.user);
3543 assert_eq!(entry_after.port, entry_before.port);
3544 assert_eq!(entry_after.identity_file, entry_before.identity_file);
3545 assert_eq!(entry_after.proxy_jump, entry_before.proxy_jump);
3546 assert_eq!(entry_after.tags, entry_before.tags);
3547 assert_eq!(entry_after.provider_tags, entry_before.provider_tags);
3548 assert_eq!(entry_after.provider, entry_before.provider);
3549 assert_eq!(entry_after.askpass, entry_before.askpass);
3550 assert_eq!(entry_after.provider_meta, entry_before.provider_meta);
3551 assert_eq!(entry_after.tunnel_count, entry_before.tunnel_count);
3552 assert_eq!(entry_after.stale, Some(1711900000));
3553
3554 config.clear_host_stale("complex");
3556 let entry_cleared = first_block(&config).to_host_entry();
3557 assert_eq!(entry_cleared.stale, None);
3558 assert_eq!(entry_cleared.hostname, entry_before.hostname);
3559 assert_eq!(entry_cleared.tags, entry_before.tags);
3560 assert_eq!(entry_cleared.provider, entry_before.provider);
3561 assert_eq!(entry_cleared.askpass, entry_before.askpass);
3562 assert_eq!(entry_cleared.provider_meta, entry_before.provider_meta);
3563
3564 assert!(config.serialize().contains("# This is a user comment"));
3566 }
3567
3568 #[test]
3569 fn stale_on_last_host_preserves_trailing_newline() {
3570 let config_str = "Host web\n HostName 1.2.3.4\n";
3571 let mut config = parse_str(config_str);
3572 config.set_host_stale("web", 1711900000);
3573 let output = config.serialize();
3574 assert!(output.ends_with('\n'), "config must end with newline");
3575
3576 config.clear_host_stale("web");
3577 let output2 = config.serialize();
3578 assert_eq!(output2, config_str);
3579 }
3580
3581 #[test]
3582 fn stale_with_crlf_preserves_line_endings() {
3583 let config_str = "Host web\r\n HostName 1.2.3.4\r\n";
3584 let config = SshConfigFile {
3585 elements: SshConfigFile::parse_content(config_str),
3586 path: std::path::PathBuf::from("/tmp/test"),
3587 crlf: true,
3588 bom: false,
3589 };
3590 let mut config = config;
3591 config.set_host_stale("web", 1711900000);
3592 let output = config.serialize();
3593 for line in output.split('\n') {
3595 if !line.is_empty() {
3596 assert!(
3597 line.ends_with('\r'),
3598 "CRLF lost after set_stale. Line: {:?}",
3599 line
3600 );
3601 }
3602 }
3603
3604 config.clear_host_stale("web");
3605 assert_eq!(config.serialize(), config_str);
3606 }
3607}