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