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
156#[derive(Debug, Clone, Default)]
158pub struct PatternEntry {
159 pub pattern: String,
160 pub hostname: String,
161 pub user: String,
162 pub port: u16,
163 pub identity_file: String,
164 pub proxy_jump: String,
165 pub tags: Vec<String>,
166 pub askpass: Option<String>,
167 pub source_file: Option<PathBuf>,
168 pub directives: Vec<(String, String)>,
170}
171
172#[derive(Debug, Clone, Default)]
175pub struct InheritedHints {
176 pub proxy_jump: Option<(String, String)>,
177 pub user: Option<(String, String)>,
178 pub identity_file: Option<(String, String)>,
179}
180
181pub fn is_host_pattern(pattern: &str) -> bool {
185 pattern.contains('*')
186 || pattern.contains('?')
187 || pattern.contains('[')
188 || pattern.starts_with('!')
189 || pattern.contains(' ')
190 || pattern.contains('\t')
191}
192
193pub fn ssh_pattern_match(pattern: &str, text: &str) -> bool {
197 if let Some(rest) = pattern.strip_prefix('!') {
198 return !match_glob(rest, text);
199 }
200 match_glob(pattern, text)
201}
202
203fn match_glob(pattern: &str, text: &str) -> bool {
206 if text.is_empty() {
207 return pattern.is_empty();
208 }
209 if pattern.is_empty() {
210 return false;
211 }
212 let pat: Vec<char> = pattern.chars().collect();
213 let txt: Vec<char> = text.chars().collect();
214 glob_match(&pat, &txt)
215}
216
217fn glob_match(pat: &[char], txt: &[char]) -> bool {
219 let mut pi = 0;
220 let mut ti = 0;
221 let mut star: Option<(usize, usize)> = None; while ti < txt.len() {
224 if pi < pat.len() && pat[pi] == '?' {
225 pi += 1;
226 ti += 1;
227 } else if pi < pat.len() && pat[pi] == '*' {
228 star = Some((pi + 1, ti));
229 pi += 1;
230 } else if pi < pat.len() && pat[pi] == '[' {
231 if let Some((matches, end)) = match_char_class(pat, pi, txt[ti]) {
232 if matches {
233 pi = end;
234 ti += 1;
235 } else if let Some((spi, sti)) = star {
236 let sti = sti + 1;
237 star = Some((spi, sti));
238 pi = spi;
239 ti = sti;
240 } else {
241 return false;
242 }
243 } else if let Some((spi, sti)) = star {
244 let sti = sti + 1;
246 star = Some((spi, sti));
247 pi = spi;
248 ti = sti;
249 } else {
250 return false;
251 }
252 } else if pi < pat.len() && pat[pi] == txt[ti] {
253 pi += 1;
254 ti += 1;
255 } else if let Some((spi, sti)) = star {
256 let sti = sti + 1;
257 star = Some((spi, sti));
258 pi = spi;
259 ti = sti;
260 } else {
261 return false;
262 }
263 }
264
265 while pi < pat.len() && pat[pi] == '*' {
266 pi += 1;
267 }
268 pi == pat.len()
269}
270
271fn match_char_class(pat: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
275 let mut i = start + 1;
276 if i >= pat.len() {
277 return None;
278 }
279
280 let negate = pat[i] == '!' || pat[i] == '^';
281 if negate {
282 i += 1;
283 }
284
285 let mut matched = false;
286 while i < pat.len() && pat[i] != ']' {
287 if i + 2 < pat.len() && pat[i + 1] == '-' && pat[i + 2] != ']' {
288 let lo = pat[i];
289 let hi = pat[i + 2];
290 if ch >= lo && ch <= hi {
291 matched = true;
292 }
293 i += 3;
294 } else {
295 matched |= pat[i] == ch;
296 i += 1;
297 }
298 }
299
300 if i >= pat.len() {
301 return None;
302 }
303
304 let result = if negate { !matched } else { matched };
305 Some((result, i + 1))
306}
307
308pub fn host_pattern_matches(host_pattern: &str, alias: &str) -> bool {
312 let patterns: Vec<&str> = host_pattern.split_whitespace().collect();
313 if patterns.is_empty() {
314 return false;
315 }
316
317 let mut any_positive_match = false;
318 for pat in &patterns {
319 if let Some(neg) = pat.strip_prefix('!') {
320 if match_glob(neg, alias) {
321 return false;
322 }
323 } else if ssh_pattern_match(pat, alias) {
324 any_positive_match = true;
325 }
326 }
327
328 any_positive_match
329}
330
331pub fn proxy_jump_contains_self(proxy_jump: &str, alias: &str) -> bool {
336 proxy_jump.split(',').any(|hop| {
337 let h = hop.trim();
338 let h = h.split_once('@').map_or(h, |(_, host)| host);
340 let h = if let Some(bracketed) = h.strip_prefix('[') {
342 bracketed.split_once(']').map_or(h, |(host, _)| host)
343 } else {
344 h.rsplit_once(':').map_or(h, |(host, _)| host)
345 };
346 h == alias
347 })
348}
349
350fn apply_first_match_fields(
354 proxy_jump: &mut String,
355 user: &mut String,
356 identity_file: &mut String,
357 p: &PatternEntry,
358) {
359 if proxy_jump.is_empty() && !p.proxy_jump.is_empty() {
360 proxy_jump.clone_from(&p.proxy_jump);
361 }
362 if user.is_empty() && !p.user.is_empty() {
363 user.clone_from(&p.user);
364 }
365 if identity_file.is_empty() && !p.identity_file.is_empty() {
366 identity_file.clone_from(&p.identity_file);
367 }
368}
369
370impl HostBlock {
371 fn content_end(&self) -> usize {
373 let mut pos = self.directives.len();
374 while pos > 0 {
375 if self.directives[pos - 1].is_non_directive
376 && self.directives[pos - 1].raw_line.trim().is_empty()
377 {
378 pos -= 1;
379 } else {
380 break;
381 }
382 }
383 pos
384 }
385
386 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
388 let end = self.content_end();
389 self.directives.drain(end..).collect()
390 }
391
392 fn ensure_trailing_blank(&mut self) {
394 self.pop_trailing_blanks();
395 self.directives.push(Directive {
396 key: String::new(),
397 value: String::new(),
398 raw_line: String::new(),
399 is_non_directive: true,
400 });
401 }
402
403 fn detect_indent(&self) -> String {
405 for d in &self.directives {
406 if !d.is_non_directive && !d.raw_line.is_empty() {
407 let trimmed = d.raw_line.trim_start();
408 let indent_len = d.raw_line.len() - trimmed.len();
409 if indent_len > 0 {
410 return d.raw_line[..indent_len].to_string();
411 }
412 }
413 }
414 " ".to_string()
415 }
416
417 pub fn tags(&self) -> Vec<String> {
419 for d in &self.directives {
420 if d.is_non_directive {
421 let trimmed = d.raw_line.trim();
422 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
423 return rest
424 .split(',')
425 .map(|t| t.trim().to_string())
426 .filter(|t| !t.is_empty())
427 .collect();
428 }
429 }
430 }
431 Vec::new()
432 }
433
434 pub fn provider_tags(&self) -> Vec<String> {
436 for d in &self.directives {
437 if d.is_non_directive {
438 let trimmed = d.raw_line.trim();
439 if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
440 return rest
441 .split(',')
442 .map(|t| t.trim().to_string())
443 .filter(|t| !t.is_empty())
444 .collect();
445 }
446 }
447 }
448 Vec::new()
449 }
450
451 pub fn has_provider_tags_comment(&self) -> bool {
454 self.directives.iter().any(|d| {
455 d.is_non_directive && {
456 let t = d.raw_line.trim();
457 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
458 }
459 })
460 }
461
462 pub fn provider(&self) -> Option<(String, String)> {
465 for d in &self.directives {
466 if d.is_non_directive {
467 let trimmed = d.raw_line.trim();
468 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
469 if let Some((name, id)) = rest.split_once(':') {
470 return Some((name.trim().to_string(), id.trim().to_string()));
471 }
472 }
473 }
474 }
475 None
476 }
477
478 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
480 let indent = self.detect_indent();
481 self.directives.retain(|d| {
482 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
483 });
484 let pos = self.content_end();
485 self.directives.insert(
486 pos,
487 Directive {
488 key: String::new(),
489 value: String::new(),
490 raw_line: format!(
491 "{}# purple:provider {}:{}",
492 indent, provider_name, server_id
493 ),
494 is_non_directive: true,
495 },
496 );
497 }
498
499 pub fn askpass(&self) -> Option<String> {
501 for d in &self.directives {
502 if d.is_non_directive {
503 let trimmed = d.raw_line.trim();
504 if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
505 let val = rest.trim();
506 if !val.is_empty() {
507 return Some(val.to_string());
508 }
509 }
510 }
511 }
512 None
513 }
514
515 pub fn set_askpass(&mut self, source: &str) {
518 let indent = self.detect_indent();
519 self.directives.retain(|d| {
520 !(d.is_non_directive && {
521 let t = d.raw_line.trim();
522 t == "# purple:askpass" || t.starts_with("# purple:askpass ")
523 })
524 });
525 if !source.is_empty() {
526 let pos = self.content_end();
527 self.directives.insert(
528 pos,
529 Directive {
530 key: String::new(),
531 value: String::new(),
532 raw_line: format!("{}# purple:askpass {}", indent, source),
533 is_non_directive: true,
534 },
535 );
536 }
537 }
538
539 pub fn meta(&self) -> Vec<(String, String)> {
542 for d in &self.directives {
543 if d.is_non_directive {
544 let trimmed = d.raw_line.trim();
545 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
546 return rest
547 .split(',')
548 .filter_map(|pair| {
549 let (k, v) = pair.split_once('=')?;
550 let k = k.trim();
551 let v = v.trim();
552 if k.is_empty() {
553 None
554 } else {
555 Some((k.to_string(), v.to_string()))
556 }
557 })
558 .collect();
559 }
560 }
561 }
562 Vec::new()
563 }
564
565 pub fn set_meta(&mut self, meta: &[(String, String)]) {
568 let indent = self.detect_indent();
569 self.directives.retain(|d| {
570 !(d.is_non_directive && {
571 let t = d.raw_line.trim();
572 t == "# purple:meta" || t.starts_with("# purple:meta ")
573 })
574 });
575 if !meta.is_empty() {
576 let encoded: Vec<String> = meta
577 .iter()
578 .map(|(k, v)| {
579 let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
580 let clean_v = Self::sanitize_tag(&v.replace(',', ""));
581 format!("{}={}", clean_k, clean_v)
582 })
583 .collect();
584 let pos = self.content_end();
585 self.directives.insert(
586 pos,
587 Directive {
588 key: String::new(),
589 value: String::new(),
590 raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
591 is_non_directive: true,
592 },
593 );
594 }
595 }
596
597 pub fn stale(&self) -> Option<u64> {
600 for d in &self.directives {
601 if d.is_non_directive {
602 let trimmed = d.raw_line.trim();
603 if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
604 return rest.trim().parse::<u64>().ok();
605 }
606 }
607 }
608 None
609 }
610
611 pub fn set_stale(&mut self, timestamp: u64) {
614 let indent = self.detect_indent();
615 self.clear_stale();
616 let pos = self.content_end();
617 self.directives.insert(
618 pos,
619 Directive {
620 key: String::new(),
621 value: String::new(),
622 raw_line: format!("{}# purple:stale {}", indent, timestamp),
623 is_non_directive: true,
624 },
625 );
626 }
627
628 pub fn clear_stale(&mut self) {
630 self.directives.retain(|d| {
631 !(d.is_non_directive && {
632 let t = d.raw_line.trim();
633 t == "# purple:stale" || t.starts_with("# purple:stale ")
634 })
635 });
636 }
637
638 fn sanitize_tag(tag: &str) -> String {
641 tag.chars()
642 .filter(|c| {
643 !c.is_control()
644 && *c != ','
645 && !('\u{200B}'..='\u{200F}').contains(c) && !('\u{202A}'..='\u{202E}').contains(c) && !('\u{2066}'..='\u{2069}').contains(c) && *c != '\u{FEFF}' })
650 .take(128)
651 .collect()
652 }
653
654 pub fn set_tags(&mut self, tags: &[String]) {
656 let indent = self.detect_indent();
657 self.directives.retain(|d| {
658 !(d.is_non_directive && {
659 let t = d.raw_line.trim();
660 t == "# purple:tags" || t.starts_with("# purple:tags ")
661 })
662 });
663 let sanitized: Vec<String> = tags
664 .iter()
665 .map(|t| Self::sanitize_tag(t))
666 .filter(|t| !t.is_empty())
667 .collect();
668 if !sanitized.is_empty() {
669 let pos = self.content_end();
670 self.directives.insert(
671 pos,
672 Directive {
673 key: String::new(),
674 value: String::new(),
675 raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
676 is_non_directive: true,
677 },
678 );
679 }
680 }
681
682 pub fn set_provider_tags(&mut self, tags: &[String]) {
685 let indent = self.detect_indent();
686 self.directives.retain(|d| {
687 !(d.is_non_directive && {
688 let t = d.raw_line.trim();
689 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
690 })
691 });
692 let sanitized: Vec<String> = tags
693 .iter()
694 .map(|t| Self::sanitize_tag(t))
695 .filter(|t| !t.is_empty())
696 .collect();
697 let raw = if sanitized.is_empty() {
698 format!("{}# purple:provider_tags", indent)
699 } else {
700 format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
701 };
702 let pos = self.content_end();
703 self.directives.insert(
704 pos,
705 Directive {
706 key: String::new(),
707 value: String::new(),
708 raw_line: raw,
709 is_non_directive: true,
710 },
711 );
712 }
713
714 pub fn to_host_entry(&self) -> HostEntry {
716 let mut entry = HostEntry {
717 alias: self.host_pattern.clone(),
718 port: 22,
719 ..Default::default()
720 };
721 for d in &self.directives {
722 if d.is_non_directive {
723 continue;
724 }
725 if d.key.eq_ignore_ascii_case("hostname") {
726 entry.hostname = d.value.clone();
727 } else if d.key.eq_ignore_ascii_case("user") {
728 entry.user = d.value.clone();
729 } else if d.key.eq_ignore_ascii_case("port") {
730 entry.port = d.value.parse().unwrap_or(22);
731 } else if d.key.eq_ignore_ascii_case("identityfile") {
732 if entry.identity_file.is_empty() {
733 entry.identity_file = d.value.clone();
734 }
735 } else if d.key.eq_ignore_ascii_case("proxyjump") {
736 entry.proxy_jump = d.value.clone();
737 }
738 }
739 entry.tags = self.tags();
740 entry.provider_tags = self.provider_tags();
741 entry.has_provider_tags = self.has_provider_tags_comment();
742 entry.provider = self.provider().map(|(name, _)| name);
743 entry.tunnel_count = self.tunnel_count();
744 entry.askpass = self.askpass();
745 entry.provider_meta = self.meta();
746 entry.stale = self.stale();
747 entry
748 }
749
750 pub fn to_pattern_entry(&self) -> PatternEntry {
752 let mut entry = PatternEntry {
753 pattern: self.host_pattern.clone(),
754 hostname: String::new(),
755 user: String::new(),
756 port: 22,
757 identity_file: String::new(),
758 proxy_jump: String::new(),
759 tags: self.tags(),
760 askpass: self.askpass(),
761 source_file: None,
762 directives: Vec::new(),
763 };
764 for d in &self.directives {
765 if d.is_non_directive {
766 continue;
767 }
768 match d.key.to_ascii_lowercase().as_str() {
769 "hostname" => entry.hostname = d.value.clone(),
770 "user" => entry.user = d.value.clone(),
771 "port" => entry.port = d.value.parse().unwrap_or(22),
772 "identityfile" => {
773 if entry.identity_file.is_empty() {
774 entry.identity_file = d.value.clone();
775 }
776 }
777 "proxyjump" => entry.proxy_jump = d.value.clone(),
778 _ => {}
779 }
780 entry.directives.push((d.key.clone(), d.value.clone()));
781 }
782 entry
783 }
784
785 pub fn tunnel_count(&self) -> u16 {
787 let count = self
788 .directives
789 .iter()
790 .filter(|d| {
791 !d.is_non_directive
792 && (d.key.eq_ignore_ascii_case("localforward")
793 || d.key.eq_ignore_ascii_case("remoteforward")
794 || d.key.eq_ignore_ascii_case("dynamicforward"))
795 })
796 .count();
797 count.min(u16::MAX as usize) as u16
798 }
799
800 #[allow(dead_code)]
802 pub fn has_tunnels(&self) -> bool {
803 self.directives.iter().any(|d| {
804 !d.is_non_directive
805 && (d.key.eq_ignore_ascii_case("localforward")
806 || d.key.eq_ignore_ascii_case("remoteforward")
807 || d.key.eq_ignore_ascii_case("dynamicforward"))
808 })
809 }
810
811 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
813 self.directives
814 .iter()
815 .filter(|d| !d.is_non_directive)
816 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
817 .collect()
818 }
819}
820
821impl SshConfigFile {
822 pub fn host_entries(&self) -> Vec<HostEntry> {
827 let mut entries = Vec::new();
828 Self::collect_host_entries(&self.elements, &mut entries);
829 self.apply_pattern_inheritance(&mut entries);
830 entries
831 }
832
833 pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
837 Self::find_raw_host_entry(&self.elements, alias)
838 }
839
840 fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
841 for e in elements {
842 match e {
843 ConfigElement::HostBlock(block)
844 if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
845 {
846 return Some(block.to_host_entry());
847 }
848 ConfigElement::Include(inc) => {
849 for file in &inc.resolved_files {
850 if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
851 if found.source_file.is_none() {
852 found.source_file = Some(file.path.clone());
853 }
854 return Some(found);
855 }
856 }
857 }
858 _ => {}
859 }
860 }
861 None
862 }
863
864 fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
868 let all_patterns = self.pattern_entries();
871 for entry in entries.iter_mut() {
872 if !entry.proxy_jump.is_empty()
873 && !entry.user.is_empty()
874 && !entry.identity_file.is_empty()
875 {
876 continue;
877 }
878 for p in &all_patterns {
879 if !host_pattern_matches(&p.pattern, &entry.alias) {
880 continue;
881 }
882 apply_first_match_fields(
883 &mut entry.proxy_jump,
884 &mut entry.user,
885 &mut entry.identity_file,
886 p,
887 );
888 if !entry.proxy_jump.is_empty()
889 && !entry.user.is_empty()
890 && !entry.identity_file.is_empty()
891 {
892 break;
893 }
894 }
895 }
896 }
897
898 pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
904 let patterns = self.matching_patterns(alias);
905 let mut hints = InheritedHints::default();
906 for p in &patterns {
907 if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
908 hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
909 }
910 if hints.user.is_none() && !p.user.is_empty() {
911 hints.user = Some((p.user.clone(), p.pattern.clone()));
912 }
913 if hints.identity_file.is_none() && !p.identity_file.is_empty() {
914 hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
915 }
916 if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
917 break;
918 }
919 }
920 hints
921 }
922
923 pub fn pattern_entries(&self) -> Vec<PatternEntry> {
925 let mut entries = Vec::new();
926 Self::collect_pattern_entries(&self.elements, &mut entries);
927 entries
928 }
929
930 fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
931 for e in elements {
932 match e {
933 ConfigElement::HostBlock(block) => {
934 if !is_host_pattern(&block.host_pattern) {
935 continue;
936 }
937 entries.push(block.to_pattern_entry());
938 }
939 ConfigElement::Include(include) => {
940 for file in &include.resolved_files {
941 let start = entries.len();
942 Self::collect_pattern_entries(&file.elements, entries);
943 for entry in &mut entries[start..] {
944 if entry.source_file.is_none() {
945 entry.source_file = Some(file.path.clone());
946 }
947 }
948 }
949 }
950 ConfigElement::GlobalLine(_) => {}
951 }
952 }
953 }
954
955 pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
958 let mut matches = Vec::new();
959 Self::collect_matching_patterns(&self.elements, alias, &mut matches);
960 matches
961 }
962
963 fn collect_matching_patterns(
964 elements: &[ConfigElement],
965 alias: &str,
966 matches: &mut Vec<PatternEntry>,
967 ) {
968 for e in elements {
969 match e {
970 ConfigElement::HostBlock(block) => {
971 if !is_host_pattern(&block.host_pattern) {
972 continue;
973 }
974 if host_pattern_matches(&block.host_pattern, alias) {
975 matches.push(block.to_pattern_entry());
976 }
977 }
978 ConfigElement::Include(include) => {
979 for file in &include.resolved_files {
980 let start = matches.len();
981 Self::collect_matching_patterns(&file.elements, alias, matches);
982 for entry in &mut matches[start..] {
983 if entry.source_file.is_none() {
984 entry.source_file = Some(file.path.clone());
985 }
986 }
987 }
988 }
989 ConfigElement::GlobalLine(_) => {}
990 }
991 }
992 }
993
994 pub fn include_paths(&self) -> Vec<PathBuf> {
996 let mut paths = Vec::new();
997 Self::collect_include_paths(&self.elements, &mut paths);
998 paths
999 }
1000
1001 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
1002 for e in elements {
1003 if let ConfigElement::Include(include) = e {
1004 for file in &include.resolved_files {
1005 paths.push(file.path.clone());
1006 Self::collect_include_paths(&file.elements, paths);
1007 }
1008 }
1009 }
1010 }
1011
1012 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
1015 let config_dir = self.path.parent();
1016 let mut seen = std::collections::HashSet::new();
1017 let mut dirs = Vec::new();
1018 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
1019 dirs
1020 }
1021
1022 fn collect_include_glob_dirs(
1023 elements: &[ConfigElement],
1024 config_dir: Option<&std::path::Path>,
1025 seen: &mut std::collections::HashSet<PathBuf>,
1026 dirs: &mut Vec<PathBuf>,
1027 ) {
1028 for e in elements {
1029 if let ConfigElement::Include(include) = e {
1030 for single in Self::split_include_patterns(&include.pattern) {
1032 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
1033 let resolved = if expanded.starts_with('/') {
1034 PathBuf::from(&expanded)
1035 } else if let Some(dir) = config_dir {
1036 dir.join(&expanded)
1037 } else {
1038 continue;
1039 };
1040 if let Some(parent) = resolved.parent() {
1041 let parent = parent.to_path_buf();
1042 if seen.insert(parent.clone()) {
1043 dirs.push(parent);
1044 }
1045 }
1046 }
1047 for file in &include.resolved_files {
1049 Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
1050 }
1051 }
1052 }
1053 }
1054
1055 pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
1058 let active_providers: std::collections::HashSet<String> = self
1060 .elements
1061 .iter()
1062 .filter_map(|e| {
1063 if let ConfigElement::HostBlock(block) = e {
1064 block
1065 .provider()
1066 .map(|(name, _)| provider_group_display_name(&name).to_string())
1067 } else {
1068 None
1069 }
1070 })
1071 .collect();
1072
1073 let mut removed = 0;
1074 self.elements.retain(|e| {
1075 if let ConfigElement::GlobalLine(line) = e {
1076 if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
1077 if !active_providers.contains(rest.trim()) {
1078 removed += 1;
1079 return false;
1080 }
1081 }
1082 }
1083 true
1084 });
1085 removed
1086 }
1087
1088 pub fn repair_absorbed_group_comments(&mut self) -> usize {
1092 let mut repaired = 0;
1093 let mut idx = 0;
1094 while idx < self.elements.len() {
1095 let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
1096 block
1097 .directives
1098 .iter()
1099 .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
1100 } else {
1101 false
1102 };
1103
1104 if !needs_repair {
1105 idx += 1;
1106 continue;
1107 }
1108
1109 let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
1111 block
1112 } else {
1113 unreachable!()
1114 };
1115
1116 let group_idx = block
1117 .directives
1118 .iter()
1119 .position(|d| {
1120 d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
1121 })
1122 .unwrap();
1123
1124 let mut keep_end = group_idx;
1126 while keep_end > 0
1127 && block.directives[keep_end - 1].is_non_directive
1128 && block.directives[keep_end - 1].raw_line.trim().is_empty()
1129 {
1130 keep_end -= 1;
1131 }
1132
1133 let extracted: Vec<ConfigElement> = block
1135 .directives
1136 .drain(keep_end..)
1137 .map(|d| ConfigElement::GlobalLine(d.raw_line))
1138 .collect();
1139
1140 let insert_at = idx + 1;
1142 for (i, elem) in extracted.into_iter().enumerate() {
1143 self.elements.insert(insert_at + i, elem);
1144 }
1145
1146 repaired += 1;
1147 idx = insert_at;
1149 while idx < self.elements.len() {
1151 if let ConfigElement::HostBlock(_) = &self.elements[idx] {
1152 break;
1153 }
1154 idx += 1;
1155 }
1156 }
1157 repaired
1158 }
1159
1160 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
1162 for e in elements {
1163 match e {
1164 ConfigElement::HostBlock(block) => {
1165 if is_host_pattern(&block.host_pattern) {
1166 continue;
1167 }
1168 entries.push(block.to_host_entry());
1169 }
1170 ConfigElement::Include(include) => {
1171 for file in &include.resolved_files {
1172 let start = entries.len();
1173 Self::collect_host_entries(&file.elements, entries);
1174 for entry in &mut entries[start..] {
1175 if entry.source_file.is_none() {
1176 entry.source_file = Some(file.path.clone());
1177 }
1178 }
1179 }
1180 }
1181 ConfigElement::GlobalLine(_) => {}
1182 }
1183 }
1184 }
1185
1186 pub fn has_host(&self, alias: &str) -> bool {
1189 Self::has_host_in_elements(&self.elements, alias)
1190 }
1191
1192 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
1193 for e in elements {
1194 match e {
1195 ConfigElement::HostBlock(block) => {
1196 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1197 return true;
1198 }
1199 }
1200 ConfigElement::Include(include) => {
1201 for file in &include.resolved_files {
1202 if Self::has_host_in_elements(&file.elements, alias) {
1203 return true;
1204 }
1205 }
1206 }
1207 ConfigElement::GlobalLine(_) => {}
1208 }
1209 }
1210 false
1211 }
1212
1213 pub fn has_host_block(&self, pattern: &str) -> bool {
1218 self.elements
1219 .iter()
1220 .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
1221 }
1222
1223 pub fn is_included_host(&self, alias: &str) -> bool {
1226 for e in &self.elements {
1228 match e {
1229 ConfigElement::HostBlock(block) => {
1230 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1231 return false;
1232 }
1233 }
1234 ConfigElement::Include(include) => {
1235 for file in &include.resolved_files {
1236 if Self::has_host_in_elements(&file.elements, alias) {
1237 return true;
1238 }
1239 }
1240 }
1241 ConfigElement::GlobalLine(_) => {}
1242 }
1243 }
1244 false
1245 }
1246
1247 pub fn add_host(&mut self, entry: &HostEntry) {
1252 let block = Self::entry_to_block(entry);
1253 let insert_pos = self.find_trailing_pattern_start();
1254
1255 if let Some(pos) = insert_pos {
1256 let needs_blank_before = pos > 0
1258 && !matches!(
1259 self.elements.get(pos - 1),
1260 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1261 );
1262 let mut idx = pos;
1263 if needs_blank_before {
1264 self.elements
1265 .insert(idx, ConfigElement::GlobalLine(String::new()));
1266 idx += 1;
1267 }
1268 self.elements.insert(idx, ConfigElement::HostBlock(block));
1269 let after = idx + 1;
1271 if after < self.elements.len()
1272 && !matches!(
1273 self.elements.get(after),
1274 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1275 )
1276 {
1277 self.elements
1278 .insert(after, ConfigElement::GlobalLine(String::new()));
1279 }
1280 } else {
1281 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
1283 self.elements.push(ConfigElement::GlobalLine(String::new()));
1284 }
1285 self.elements.push(ConfigElement::HostBlock(block));
1286 }
1287 }
1288
1289 fn find_trailing_pattern_start(&self) -> Option<usize> {
1294 let mut first_pattern_pos = None;
1295 for i in (0..self.elements.len()).rev() {
1296 match &self.elements[i] {
1297 ConfigElement::HostBlock(block) => {
1298 if is_host_pattern(&block.host_pattern) {
1299 first_pattern_pos = Some(i);
1300 } else {
1301 break;
1303 }
1304 }
1305 ConfigElement::GlobalLine(_) => {
1306 if first_pattern_pos.is_some() {
1308 first_pattern_pos = Some(i);
1309 }
1310 }
1311 ConfigElement::Include(_) => break,
1312 }
1313 }
1314 first_pattern_pos.filter(|&pos| pos > 0)
1316 }
1317
1318 pub fn last_element_has_trailing_blank(&self) -> bool {
1320 match self.elements.last() {
1321 Some(ConfigElement::HostBlock(block)) => block
1322 .directives
1323 .last()
1324 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
1325 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
1326 _ => false,
1327 }
1328 }
1329
1330 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
1333 for element in &mut self.elements {
1334 if let ConfigElement::HostBlock(block) = element {
1335 if block.host_pattern == old_alias {
1336 if entry.alias != block.host_pattern {
1338 block.host_pattern = entry.alias.clone();
1339 block.raw_host_line = format!("Host {}", entry.alias);
1340 }
1341
1342 Self::upsert_directive(block, "HostName", &entry.hostname);
1344 Self::upsert_directive(block, "User", &entry.user);
1345 if entry.port != 22 {
1346 Self::upsert_directive(block, "Port", &entry.port.to_string());
1347 } else {
1348 block
1350 .directives
1351 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
1352 }
1353 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
1354 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
1355 return;
1356 }
1357 }
1358 }
1359 }
1360
1361 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
1363 if value.is_empty() {
1364 block
1365 .directives
1366 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
1367 return;
1368 }
1369 let indent = block.detect_indent();
1370 for d in &mut block.directives {
1371 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
1372 if d.value != value {
1374 d.value = value.to_string();
1375 let trimmed = d.raw_line.trim_start();
1381 let after_key = &trimmed[d.key.len()..];
1382 let sep = if after_key.trim_start().starts_with('=') {
1383 let eq_pos = after_key.find('=').unwrap();
1384 let after_eq = &after_key[eq_pos + 1..];
1385 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
1386 after_key[..eq_pos + 1 + trailing_ws].to_string()
1387 } else {
1388 " ".to_string()
1389 };
1390 let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
1392 d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
1393 }
1394 return;
1395 }
1396 }
1397 let pos = block.content_end();
1399 block.directives.insert(
1400 pos,
1401 Directive {
1402 key: key.to_string(),
1403 value: value.to_string(),
1404 raw_line: format!("{}{} {}", indent, key, value),
1405 is_non_directive: false,
1406 },
1407 );
1408 }
1409
1410 fn extract_inline_comment(raw_line: &str, key: &str) -> String {
1414 let trimmed = raw_line.trim_start();
1415 if trimmed.len() <= key.len() {
1416 return String::new();
1417 }
1418 let after_key = &trimmed[key.len()..];
1420 let rest = after_key.trim_start();
1421 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
1422 let bytes = rest.as_bytes();
1424 let mut in_quote = false;
1425 for i in 0..bytes.len() {
1426 if bytes[i] == b'"' {
1427 in_quote = !in_quote;
1428 } else if !in_quote
1429 && bytes[i] == b'#'
1430 && i > 0
1431 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
1432 {
1433 let clean_end = rest[..i].trim_end().len();
1435 return rest[clean_end..].to_string();
1436 }
1437 }
1438 String::new()
1439 }
1440
1441 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
1443 for element in &mut self.elements {
1444 if let ConfigElement::HostBlock(block) = element {
1445 if block.host_pattern == alias {
1446 block.set_provider(provider_name, server_id);
1447 return;
1448 }
1449 }
1450 }
1451 }
1452
1453 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
1457 let mut results = Vec::new();
1458 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1459 results
1460 }
1461
1462 fn collect_provider_hosts(
1463 elements: &[ConfigElement],
1464 provider_name: &str,
1465 results: &mut Vec<(String, String)>,
1466 ) {
1467 for element in elements {
1468 match element {
1469 ConfigElement::HostBlock(block) => {
1470 if let Some((name, id)) = block.provider() {
1471 if name == provider_name {
1472 results.push((block.host_pattern.clone(), id));
1473 }
1474 }
1475 }
1476 ConfigElement::Include(include) => {
1477 for file in &include.resolved_files {
1478 Self::collect_provider_hosts(&file.elements, provider_name, results);
1479 }
1480 }
1481 ConfigElement::GlobalLine(_) => {}
1482 }
1483 }
1484 }
1485
1486 fn values_match(a: &str, b: &str) -> bool {
1489 a.split_whitespace().eq(b.split_whitespace())
1490 }
1491
1492 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1496 for element in &mut self.elements {
1497 if let ConfigElement::HostBlock(block) = element {
1498 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1499 let indent = block.detect_indent();
1500 let pos = block.content_end();
1501 block.directives.insert(
1502 pos,
1503 Directive {
1504 key: directive_key.to_string(),
1505 value: value.to_string(),
1506 raw_line: format!("{}{} {}", indent, directive_key, value),
1507 is_non_directive: false,
1508 },
1509 );
1510 return;
1511 }
1512 }
1513 }
1514 }
1515
1516 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1521 for element in &mut self.elements {
1522 if let ConfigElement::HostBlock(block) = element {
1523 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1524 if let Some(pos) = block.directives.iter().position(|d| {
1525 !d.is_non_directive
1526 && d.key.eq_ignore_ascii_case(directive_key)
1527 && Self::values_match(&d.value, value)
1528 }) {
1529 block.directives.remove(pos);
1530 return true;
1531 }
1532 return false;
1533 }
1534 }
1535 }
1536 false
1537 }
1538
1539 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1542 for element in &self.elements {
1543 if let ConfigElement::HostBlock(block) = element {
1544 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1545 return block.directives.iter().any(|d| {
1546 !d.is_non_directive
1547 && d.key.eq_ignore_ascii_case(directive_key)
1548 && Self::values_match(&d.value, value)
1549 });
1550 }
1551 }
1552 }
1553 false
1554 }
1555
1556 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1560 Self::find_tunnel_directives_in(&self.elements, alias)
1561 }
1562
1563 fn find_tunnel_directives_in(
1564 elements: &[ConfigElement],
1565 alias: &str,
1566 ) -> Vec<crate::tunnel::TunnelRule> {
1567 for element in elements {
1568 match element {
1569 ConfigElement::HostBlock(block) => {
1570 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1571 return block.tunnel_directives();
1572 }
1573 }
1574 ConfigElement::Include(include) => {
1575 for file in &include.resolved_files {
1576 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1577 if !rules.is_empty() {
1578 return rules;
1579 }
1580 }
1581 }
1582 ConfigElement::GlobalLine(_) => {}
1583 }
1584 }
1585 Vec::new()
1586 }
1587
1588 pub fn deduplicate_alias(&self, base: &str) -> String {
1590 self.deduplicate_alias_excluding(base, None)
1591 }
1592
1593 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1596 let is_taken = |alias: &str| {
1597 if exclude == Some(alias) {
1598 return false;
1599 }
1600 self.has_host(alias)
1601 };
1602 if !is_taken(base) {
1603 return base.to_string();
1604 }
1605 for n in 2..=9999 {
1606 let candidate = format!("{}-{}", base, n);
1607 if !is_taken(&candidate) {
1608 return candidate;
1609 }
1610 }
1611 format!("{}-{}", base, std::process::id())
1613 }
1614
1615 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1617 for element in &mut self.elements {
1618 if let ConfigElement::HostBlock(block) = element {
1619 if block.host_pattern == alias {
1620 block.set_tags(tags);
1621 return;
1622 }
1623 }
1624 }
1625 }
1626
1627 pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1629 for element in &mut self.elements {
1630 if let ConfigElement::HostBlock(block) = element {
1631 if block.host_pattern == alias {
1632 block.set_provider_tags(tags);
1633 return;
1634 }
1635 }
1636 }
1637 }
1638
1639 pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1641 for element in &mut self.elements {
1642 if let ConfigElement::HostBlock(block) = element {
1643 if block.host_pattern == alias {
1644 block.set_askpass(source);
1645 return;
1646 }
1647 }
1648 }
1649 }
1650
1651 pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1653 for element in &mut self.elements {
1654 if let ConfigElement::HostBlock(block) = element {
1655 if block.host_pattern == alias {
1656 block.set_meta(meta);
1657 return;
1658 }
1659 }
1660 }
1661 }
1662
1663 pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1665 for element in &mut self.elements {
1666 if let ConfigElement::HostBlock(block) = element {
1667 if block.host_pattern == alias {
1668 block.set_stale(timestamp);
1669 return;
1670 }
1671 }
1672 }
1673 }
1674
1675 pub fn clear_host_stale(&mut self, alias: &str) {
1677 for element in &mut self.elements {
1678 if let ConfigElement::HostBlock(block) = element {
1679 if block.host_pattern == alias {
1680 block.clear_stale();
1681 return;
1682 }
1683 }
1684 }
1685 }
1686
1687 pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1689 let mut result = Vec::new();
1690 for element in &self.elements {
1691 if let ConfigElement::HostBlock(block) = element {
1692 if let Some(ts) = block.stale() {
1693 result.push((block.host_pattern.clone(), ts));
1694 }
1695 }
1696 }
1697 result
1698 }
1699
1700 #[allow(dead_code)]
1702 pub fn delete_host(&mut self, alias: &str) {
1703 let provider_name = self.elements.iter().find_map(|e| {
1706 if let ConfigElement::HostBlock(b) = e {
1707 if b.host_pattern == alias {
1708 return b.provider().map(|(name, _)| name);
1709 }
1710 }
1711 None
1712 });
1713
1714 self.elements.retain(|e| match e {
1715 ConfigElement::HostBlock(block) => block.host_pattern != alias,
1716 _ => true,
1717 });
1718
1719 if let Some(name) = provider_name {
1721 self.remove_orphaned_group_header(&name);
1722 }
1723
1724 self.elements.dedup_by(|a, b| {
1726 matches!(
1727 (&*a, &*b),
1728 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1729 if x.trim().is_empty() && y.trim().is_empty()
1730 )
1731 });
1732 }
1733
1734 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1739 let pos = self
1740 .elements
1741 .iter()
1742 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
1743 let element = self.elements.remove(pos);
1744 Some((element, pos))
1745 }
1746
1747 #[allow(dead_code)]
1749 fn find_group_header_position(&self, provider_name: &str) -> Option<usize> {
1750 let display = provider_group_display_name(provider_name);
1751 let header = format!("# purple:group {}", display);
1752 self.elements
1753 .iter()
1754 .position(|e| matches!(e, ConfigElement::GlobalLine(line) if *line == header))
1755 }
1756
1757 fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1760 if self.find_hosts_by_provider(provider_name).is_empty() {
1761 let display = provider_group_display_name(provider_name);
1762 let header = format!("# purple:group {}", display);
1763 self.elements
1764 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1765 }
1766 }
1767
1768 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1770 let pos = position.min(self.elements.len());
1771 self.elements.insert(pos, element);
1772 }
1773
1774 pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1778 let mut last_pos = None;
1779 for (i, element) in self.elements.iter().enumerate() {
1780 if let ConfigElement::HostBlock(block) = element {
1781 if let Some((name, _)) = block.provider() {
1782 if name == provider_name {
1783 last_pos = Some(i);
1784 }
1785 }
1786 }
1787 }
1788 last_pos.map(|p| p + 1)
1790 }
1791
1792 #[allow(dead_code)]
1794 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1795 let pos_a = self
1796 .elements
1797 .iter()
1798 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1799 let pos_b = self
1800 .elements
1801 .iter()
1802 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1803 if let (Some(a), Some(b)) = (pos_a, pos_b) {
1804 if a == b {
1805 return false;
1806 }
1807 let (first, second) = (a.min(b), a.max(b));
1808
1809 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1811 block.pop_trailing_blanks();
1812 }
1813 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1814 block.pop_trailing_blanks();
1815 }
1816
1817 self.elements.swap(first, second);
1819
1820 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1822 block.ensure_trailing_blank();
1823 }
1824
1825 if second < self.elements.len() - 1 {
1827 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1828 block.ensure_trailing_blank();
1829 }
1830 }
1831
1832 return true;
1833 }
1834 false
1835 }
1836
1837 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1839 debug_assert!(
1842 !entry.alias.contains('\n') && !entry.alias.contains('\r'),
1843 "entry_to_block: alias contains newline"
1844 );
1845 debug_assert!(
1846 !entry.hostname.contains('\n') && !entry.hostname.contains('\r'),
1847 "entry_to_block: hostname contains newline"
1848 );
1849 debug_assert!(
1850 !entry.user.contains('\n') && !entry.user.contains('\r'),
1851 "entry_to_block: user contains newline"
1852 );
1853
1854 let mut directives = Vec::new();
1855
1856 if !entry.hostname.is_empty() {
1857 directives.push(Directive {
1858 key: "HostName".to_string(),
1859 value: entry.hostname.clone(),
1860 raw_line: format!(" HostName {}", entry.hostname),
1861 is_non_directive: false,
1862 });
1863 }
1864 if !entry.user.is_empty() {
1865 directives.push(Directive {
1866 key: "User".to_string(),
1867 value: entry.user.clone(),
1868 raw_line: format!(" User {}", entry.user),
1869 is_non_directive: false,
1870 });
1871 }
1872 if entry.port != 22 {
1873 directives.push(Directive {
1874 key: "Port".to_string(),
1875 value: entry.port.to_string(),
1876 raw_line: format!(" Port {}", entry.port),
1877 is_non_directive: false,
1878 });
1879 }
1880 if !entry.identity_file.is_empty() {
1881 directives.push(Directive {
1882 key: "IdentityFile".to_string(),
1883 value: entry.identity_file.clone(),
1884 raw_line: format!(" IdentityFile {}", entry.identity_file),
1885 is_non_directive: false,
1886 });
1887 }
1888 if !entry.proxy_jump.is_empty() {
1889 directives.push(Directive {
1890 key: "ProxyJump".to_string(),
1891 value: entry.proxy_jump.clone(),
1892 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
1893 is_non_directive: false,
1894 });
1895 }
1896
1897 HostBlock {
1898 host_pattern: entry.alias.clone(),
1899 raw_host_line: format!("Host {}", entry.alias),
1900 directives,
1901 }
1902 }
1903}
1904
1905#[cfg(test)]
1906mod tests {
1907 use super::*;
1908
1909 fn parse_str(content: &str) -> SshConfigFile {
1910 SshConfigFile {
1911 elements: SshConfigFile::parse_content(content),
1912 path: PathBuf::from("/tmp/test_config"),
1913 crlf: false,
1914 bom: false,
1915 }
1916 }
1917
1918 #[test]
1919 fn tunnel_directives_extracts_forwards() {
1920 let config = parse_str(
1921 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
1922 );
1923 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1924 let rules = block.tunnel_directives();
1925 assert_eq!(rules.len(), 3);
1926 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1927 assert_eq!(rules[0].bind_port, 8080);
1928 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1929 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1930 } else {
1931 panic!("Expected HostBlock");
1932 }
1933 }
1934
1935 #[test]
1936 fn tunnel_count_counts_forwards() {
1937 let config = parse_str(
1938 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n",
1939 );
1940 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1941 assert_eq!(block.tunnel_count(), 2);
1942 } else {
1943 panic!("Expected HostBlock");
1944 }
1945 }
1946
1947 #[test]
1948 fn tunnel_count_zero_for_no_forwards() {
1949 let config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1950 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1951 assert_eq!(block.tunnel_count(), 0);
1952 assert!(!block.has_tunnels());
1953 } else {
1954 panic!("Expected HostBlock");
1955 }
1956 }
1957
1958 #[test]
1959 fn has_tunnels_true_with_forward() {
1960 let config = parse_str("Host myserver\n HostName 10.0.0.1\n DynamicForward 1080\n");
1961 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1962 assert!(block.has_tunnels());
1963 } else {
1964 panic!("Expected HostBlock");
1965 }
1966 }
1967
1968 #[test]
1969 fn add_forward_inserts_directive() {
1970 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
1971 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1972 let output = config.serialize();
1973 assert!(output.contains("LocalForward 8080 localhost:80"));
1974 assert!(output.contains("HostName 10.0.0.1"));
1976 assert!(output.contains("User admin"));
1977 }
1978
1979 #[test]
1980 fn add_forward_preserves_indentation() {
1981 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1982 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1983 let output = config.serialize();
1984 assert!(output.contains("\tLocalForward 8080 localhost:80"));
1985 }
1986
1987 #[test]
1988 fn add_multiple_forwards_same_type() {
1989 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
1990 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1991 config.add_forward("myserver", "LocalForward", "9090 localhost:90");
1992 let output = config.serialize();
1993 assert!(output.contains("LocalForward 8080 localhost:80"));
1994 assert!(output.contains("LocalForward 9090 localhost:90"));
1995 }
1996
1997 #[test]
1998 fn remove_forward_removes_exact_match() {
1999 let mut config = parse_str(
2000 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
2001 );
2002 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
2003 let output = config.serialize();
2004 assert!(!output.contains("8080 localhost:80"));
2005 assert!(output.contains("9090 localhost:90"));
2006 }
2007
2008 #[test]
2009 fn remove_forward_leaves_other_directives() {
2010 let mut config = parse_str(
2011 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n User admin\n",
2012 );
2013 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
2014 let output = config.serialize();
2015 assert!(!output.contains("LocalForward"));
2016 assert!(output.contains("HostName 10.0.0.1"));
2017 assert!(output.contains("User admin"));
2018 }
2019
2020 #[test]
2021 fn remove_forward_no_match_is_noop() {
2022 let original = "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n";
2023 let mut config = parse_str(original);
2024 config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
2025 assert_eq!(config.serialize(), original);
2026 }
2027
2028 #[test]
2029 fn host_entry_tunnel_count_populated() {
2030 let config = parse_str(
2031 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n DynamicForward 1080\n",
2032 );
2033 let entries = config.host_entries();
2034 assert_eq!(entries.len(), 1);
2035 assert_eq!(entries[0].tunnel_count, 2);
2036 }
2037
2038 #[test]
2039 fn remove_forward_returns_true_on_match() {
2040 let mut config =
2041 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
2042 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2043 }
2044
2045 #[test]
2046 fn remove_forward_returns_false_on_no_match() {
2047 let mut config =
2048 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
2049 assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
2050 }
2051
2052 #[test]
2053 fn remove_forward_returns_false_for_unknown_host() {
2054 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2055 assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
2056 }
2057
2058 #[test]
2059 fn has_forward_finds_match() {
2060 let config =
2061 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
2062 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2063 }
2064
2065 #[test]
2066 fn has_forward_no_match() {
2067 let config =
2068 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
2069 assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
2070 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
2071 }
2072
2073 #[test]
2074 fn has_forward_case_insensitive_key() {
2075 let config =
2076 parse_str("Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n");
2077 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2078 }
2079
2080 #[test]
2081 fn add_forward_to_empty_block() {
2082 let mut config = parse_str("Host myserver\n");
2083 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
2084 let output = config.serialize();
2085 assert!(output.contains("LocalForward 8080 localhost:80"));
2086 }
2087
2088 #[test]
2089 fn remove_forward_case_insensitive_key_match() {
2090 let mut config =
2091 parse_str("Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n");
2092 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2093 assert!(!config.serialize().contains("localforward"));
2094 }
2095
2096 #[test]
2097 fn tunnel_count_case_insensitive() {
2098 let config = parse_str(
2099 "Host myserver\n localforward 8080 localhost:80\n REMOTEFORWARD 9090 localhost:90\n dynamicforward 1080\n",
2100 );
2101 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2102 assert_eq!(block.tunnel_count(), 3);
2103 } else {
2104 panic!("Expected HostBlock");
2105 }
2106 }
2107
2108 #[test]
2109 fn tunnel_directives_extracts_all_types() {
2110 let config = parse_str(
2111 "Host myserver\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
2112 );
2113 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2114 let rules = block.tunnel_directives();
2115 assert_eq!(rules.len(), 3);
2116 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
2117 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
2118 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
2119 } else {
2120 panic!("Expected HostBlock");
2121 }
2122 }
2123
2124 #[test]
2125 fn tunnel_directives_skips_malformed() {
2126 let config = parse_str("Host myserver\n LocalForward not_valid\n DynamicForward 1080\n");
2127 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2128 let rules = block.tunnel_directives();
2129 assert_eq!(rules.len(), 1);
2130 assert_eq!(rules[0].bind_port, 1080);
2131 } else {
2132 panic!("Expected HostBlock");
2133 }
2134 }
2135
2136 #[test]
2137 fn find_tunnel_directives_multi_pattern_host() {
2138 let config =
2139 parse_str("Host prod staging\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
2140 let rules = config.find_tunnel_directives("prod");
2141 assert_eq!(rules.len(), 1);
2142 assert_eq!(rules[0].bind_port, 8080);
2143 let rules2 = config.find_tunnel_directives("staging");
2144 assert_eq!(rules2.len(), 1);
2145 }
2146
2147 #[test]
2148 fn find_tunnel_directives_no_match() {
2149 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
2150 let rules = config.find_tunnel_directives("nohost");
2151 assert!(rules.is_empty());
2152 }
2153
2154 #[test]
2155 fn has_forward_exact_match() {
2156 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
2157 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2158 assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
2159 assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
2160 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
2161 }
2162
2163 #[test]
2164 fn has_forward_whitespace_normalized() {
2165 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
2166 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2168 }
2169
2170 #[test]
2171 fn has_forward_multi_pattern_host() {
2172 let config = parse_str("Host prod staging\n LocalForward 8080 localhost:80\n");
2173 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
2174 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2175 }
2176
2177 #[test]
2178 fn add_forward_multi_pattern_host() {
2179 let mut config = parse_str("Host prod staging\n HostName 10.0.0.1\n");
2180 config.add_forward("prod", "LocalForward", "8080 localhost:80");
2181 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
2182 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2183 }
2184
2185 #[test]
2186 fn remove_forward_multi_pattern_host() {
2187 let mut config = parse_str(
2188 "Host prod staging\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
2189 );
2190 assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
2191 assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2192 assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
2194 }
2195
2196 #[test]
2197 fn edit_tunnel_detects_duplicate_after_remove() {
2198 let mut config = parse_str(
2200 "Host myserver\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
2201 );
2202 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2204 assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
2206 }
2207
2208 #[test]
2209 fn has_forward_tab_whitespace_normalized() {
2210 let config = parse_str("Host myserver\n LocalForward 8080\tlocalhost:80\n");
2211 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2213 }
2214
2215 #[test]
2216 fn remove_forward_tab_whitespace_normalized() {
2217 let mut config = parse_str("Host myserver\n LocalForward 8080\tlocalhost:80\n");
2218 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2220 assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2221 }
2222
2223 #[test]
2224 fn upsert_preserves_space_separator_when_value_contains_equals() {
2225 let mut config = parse_str("Host myserver\n IdentityFile ~/.ssh/id=prod\n");
2226 let entry = HostEntry {
2227 alias: "myserver".to_string(),
2228 hostname: "10.0.0.1".to_string(),
2229 identity_file: "~/.ssh/id=staging".to_string(),
2230 port: 22,
2231 ..Default::default()
2232 };
2233 config.update_host("myserver", &entry);
2234 let output = config.serialize();
2235 assert!(
2237 output.contains(" IdentityFile ~/.ssh/id=staging"),
2238 "got: {}",
2239 output
2240 );
2241 assert!(!output.contains("IdentityFile="), "got: {}", output);
2242 }
2243
2244 #[test]
2245 fn upsert_preserves_equals_separator() {
2246 let mut config = parse_str("Host myserver\n IdentityFile=~/.ssh/id_rsa\n");
2247 let entry = HostEntry {
2248 alias: "myserver".to_string(),
2249 hostname: "10.0.0.1".to_string(),
2250 identity_file: "~/.ssh/id_ed25519".to_string(),
2251 port: 22,
2252 ..Default::default()
2253 };
2254 config.update_host("myserver", &entry);
2255 let output = config.serialize();
2256 assert!(
2257 output.contains("IdentityFile=~/.ssh/id_ed25519"),
2258 "got: {}",
2259 output
2260 );
2261 }
2262
2263 #[test]
2264 fn upsert_preserves_spaced_equals_separator() {
2265 let mut config = parse_str("Host myserver\n IdentityFile = ~/.ssh/id_rsa\n");
2266 let entry = HostEntry {
2267 alias: "myserver".to_string(),
2268 hostname: "10.0.0.1".to_string(),
2269 identity_file: "~/.ssh/id_ed25519".to_string(),
2270 port: 22,
2271 ..Default::default()
2272 };
2273 config.update_host("myserver", &entry);
2274 let output = config.serialize();
2275 assert!(
2276 output.contains("IdentityFile = ~/.ssh/id_ed25519"),
2277 "got: {}",
2278 output
2279 );
2280 }
2281
2282 #[test]
2283 fn is_included_host_false_for_main_config() {
2284 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2285 assert!(!config.is_included_host("myserver"));
2286 }
2287
2288 #[test]
2289 fn is_included_host_false_for_nonexistent() {
2290 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2291 assert!(!config.is_included_host("nohost"));
2292 }
2293
2294 #[test]
2295 fn is_included_host_multi_pattern_main_config() {
2296 let config = parse_str("Host prod staging\n HostName 10.0.0.1\n");
2297 assert!(!config.is_included_host("prod"));
2298 assert!(!config.is_included_host("staging"));
2299 }
2300
2301 fn first_block(config: &SshConfigFile) -> &HostBlock {
2306 match config.elements.first().unwrap() {
2307 ConfigElement::HostBlock(b) => b,
2308 _ => panic!("Expected HostBlock"),
2309 }
2310 }
2311
2312 fn first_block_mut(config: &mut SshConfigFile) -> &mut HostBlock {
2313 match config.elements.first_mut().unwrap() {
2314 ConfigElement::HostBlock(b) => b,
2315 _ => panic!("Expected HostBlock"),
2316 }
2317 }
2318
2319 fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
2320 let mut count = 0;
2321 for el in &config.elements {
2322 if let ConfigElement::HostBlock(b) = el {
2323 if count == idx {
2324 return b;
2325 }
2326 count += 1;
2327 }
2328 }
2329 panic!("No HostBlock at index {}", idx);
2330 }
2331
2332 #[test]
2333 fn askpass_returns_none_when_absent() {
2334 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2335 assert_eq!(first_block(&config).askpass(), None);
2336 }
2337
2338 #[test]
2339 fn askpass_returns_keychain() {
2340 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2341 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2342 }
2343
2344 #[test]
2345 fn askpass_returns_op_uri() {
2346 let config = parse_str(
2347 "Host myserver\n HostName 10.0.0.1\n # purple:askpass op://Vault/Item/field\n",
2348 );
2349 assert_eq!(
2350 first_block(&config).askpass(),
2351 Some("op://Vault/Item/field".to_string())
2352 );
2353 }
2354
2355 #[test]
2356 fn askpass_returns_vault_with_field() {
2357 let config = parse_str(
2358 "Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:secret/ssh#password\n",
2359 );
2360 assert_eq!(
2361 first_block(&config).askpass(),
2362 Some("vault:secret/ssh#password".to_string())
2363 );
2364 }
2365
2366 #[test]
2367 fn askpass_returns_bw_source() {
2368 let config =
2369 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:my-item\n");
2370 assert_eq!(
2371 first_block(&config).askpass(),
2372 Some("bw:my-item".to_string())
2373 );
2374 }
2375
2376 #[test]
2377 fn askpass_returns_pass_source() {
2378 let config =
2379 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass pass:ssh/prod\n");
2380 assert_eq!(
2381 first_block(&config).askpass(),
2382 Some("pass:ssh/prod".to_string())
2383 );
2384 }
2385
2386 #[test]
2387 fn askpass_returns_custom_command() {
2388 let config =
2389 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass get-pass %a %h\n");
2390 assert_eq!(
2391 first_block(&config).askpass(),
2392 Some("get-pass %a %h".to_string())
2393 );
2394 }
2395
2396 #[test]
2397 fn askpass_ignores_empty_value() {
2398 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass \n");
2399 assert_eq!(first_block(&config).askpass(), None);
2400 }
2401
2402 #[test]
2403 fn askpass_ignores_non_askpass_purple_comments() {
2404 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod\n");
2405 assert_eq!(first_block(&config).askpass(), None);
2406 }
2407
2408 #[test]
2409 fn set_askpass_adds_comment() {
2410 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2411 config.set_host_askpass("myserver", "keychain");
2412 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2413 }
2414
2415 #[test]
2416 fn set_askpass_replaces_existing() {
2417 let mut config =
2418 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2419 config.set_host_askpass("myserver", "op://V/I/p");
2420 assert_eq!(
2421 first_block(&config).askpass(),
2422 Some("op://V/I/p".to_string())
2423 );
2424 }
2425
2426 #[test]
2427 fn set_askpass_empty_removes_comment() {
2428 let mut config =
2429 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2430 config.set_host_askpass("myserver", "");
2431 assert_eq!(first_block(&config).askpass(), None);
2432 }
2433
2434 #[test]
2435 fn set_askpass_preserves_other_directives() {
2436 let mut config =
2437 parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n # purple:tags prod\n");
2438 config.set_host_askpass("myserver", "vault:secret/ssh");
2439 assert_eq!(
2440 first_block(&config).askpass(),
2441 Some("vault:secret/ssh".to_string())
2442 );
2443 let entry = first_block(&config).to_host_entry();
2444 assert_eq!(entry.user, "admin");
2445 assert!(entry.tags.contains(&"prod".to_string()));
2446 }
2447
2448 #[test]
2449 fn set_askpass_preserves_indent() {
2450 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2451 config.set_host_askpass("myserver", "keychain");
2452 let raw = first_block(&config)
2453 .directives
2454 .iter()
2455 .find(|d| d.raw_line.contains("purple:askpass"))
2456 .unwrap();
2457 assert!(
2458 raw.raw_line.starts_with(" "),
2459 "Expected 4-space indent, got: {:?}",
2460 raw.raw_line
2461 );
2462 }
2463
2464 #[test]
2465 fn set_askpass_on_nonexistent_host() {
2466 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2467 config.set_host_askpass("nohost", "keychain");
2468 assert_eq!(first_block(&config).askpass(), None);
2469 }
2470
2471 #[test]
2472 fn to_entry_includes_askpass() {
2473 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:item\n");
2474 let entries = config.host_entries();
2475 assert_eq!(entries.len(), 1);
2476 assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
2477 }
2478
2479 #[test]
2480 fn to_entry_askpass_none_when_absent() {
2481 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2482 let entries = config.host_entries();
2483 assert_eq!(entries.len(), 1);
2484 assert_eq!(entries[0].askpass, None);
2485 }
2486
2487 #[test]
2488 fn set_askpass_vault_with_hash_field() {
2489 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2490 config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
2491 assert_eq!(
2492 first_block(&config).askpass(),
2493 Some("vault:secret/data/team#api_key".to_string())
2494 );
2495 }
2496
2497 #[test]
2498 fn set_askpass_custom_command_with_percent() {
2499 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2500 config.set_host_askpass("myserver", "get-pass %a %h");
2501 assert_eq!(
2502 first_block(&config).askpass(),
2503 Some("get-pass %a %h".to_string())
2504 );
2505 }
2506
2507 #[test]
2508 fn multiple_hosts_independent_askpass() {
2509 let mut config = parse_str("Host alpha\n HostName a.com\n\nHost beta\n HostName b.com\n");
2510 config.set_host_askpass("alpha", "keychain");
2511 config.set_host_askpass("beta", "vault:secret/ssh");
2512 assert_eq!(
2513 block_by_index(&config, 0).askpass(),
2514 Some("keychain".to_string())
2515 );
2516 assert_eq!(
2517 block_by_index(&config, 1).askpass(),
2518 Some("vault:secret/ssh".to_string())
2519 );
2520 }
2521
2522 #[test]
2523 fn set_askpass_then_clear_then_set_again() {
2524 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2525 config.set_host_askpass("myserver", "keychain");
2526 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2527 config.set_host_askpass("myserver", "");
2528 assert_eq!(first_block(&config).askpass(), None);
2529 config.set_host_askpass("myserver", "op://V/I/p");
2530 assert_eq!(
2531 first_block(&config).askpass(),
2532 Some("op://V/I/p".to_string())
2533 );
2534 }
2535
2536 #[test]
2537 fn askpass_tab_indent_preserved() {
2538 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
2539 config.set_host_askpass("myserver", "pass:ssh/prod");
2540 let raw = first_block(&config)
2541 .directives
2542 .iter()
2543 .find(|d| d.raw_line.contains("purple:askpass"))
2544 .unwrap();
2545 assert!(
2546 raw.raw_line.starts_with("\t"),
2547 "Expected tab indent, got: {:?}",
2548 raw.raw_line
2549 );
2550 }
2551
2552 #[test]
2553 fn askpass_coexists_with_provider_comment() {
2554 let config = parse_str(
2555 "Host myserver\n HostName 10.0.0.1\n # purple:provider do:123\n # purple:askpass keychain\n",
2556 );
2557 let block = first_block(&config);
2558 assert_eq!(block.askpass(), Some("keychain".to_string()));
2559 assert!(block.provider().is_some());
2560 }
2561
2562 #[test]
2563 fn set_askpass_does_not_remove_tags() {
2564 let mut config =
2565 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod,staging\n");
2566 config.set_host_askpass("myserver", "keychain");
2567 let entry = first_block(&config).to_host_entry();
2568 assert_eq!(entry.askpass, Some("keychain".to_string()));
2569 assert!(entry.tags.contains(&"prod".to_string()));
2570 assert!(entry.tags.contains(&"staging".to_string()));
2571 }
2572
2573 #[test]
2574 fn askpass_idempotent_set_same_value() {
2575 let mut config =
2576 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2577 config.set_host_askpass("myserver", "keychain");
2578 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2579 let serialized = config.serialize();
2580 assert_eq!(
2581 serialized.matches("purple:askpass").count(),
2582 1,
2583 "Should have exactly one askpass comment"
2584 );
2585 }
2586
2587 #[test]
2588 fn askpass_with_value_containing_equals() {
2589 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2590 config.set_host_askpass("myserver", "cmd --opt=val %h");
2591 assert_eq!(
2592 first_block(&config).askpass(),
2593 Some("cmd --opt=val %h".to_string())
2594 );
2595 }
2596
2597 #[test]
2598 fn askpass_with_value_containing_hash() {
2599 let config =
2600 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:a/b#c\n");
2601 assert_eq!(
2602 first_block(&config).askpass(),
2603 Some("vault:a/b#c".to_string())
2604 );
2605 }
2606
2607 #[test]
2608 fn askpass_with_long_op_uri() {
2609 let uri = "op://My Personal Vault/SSH Production Server/password";
2610 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2611 config.set_host_askpass("myserver", uri);
2612 assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
2613 }
2614
2615 #[test]
2616 fn askpass_does_not_interfere_with_host_matching() {
2617 let config = parse_str(
2619 "Host myserver\n HostName 10.0.0.1\n User root\n # purple:askpass keychain\n",
2620 );
2621 let entry = first_block(&config).to_host_entry();
2622 assert_eq!(entry.user, "root");
2623 assert_eq!(entry.hostname, "10.0.0.1");
2624 assert_eq!(entry.askpass, Some("keychain".to_string()));
2625 }
2626
2627 #[test]
2628 fn set_askpass_on_host_with_many_directives() {
2629 let config_str = "\
2630Host myserver
2631 HostName 10.0.0.1
2632 User admin
2633 Port 2222
2634 IdentityFile ~/.ssh/id_ed25519
2635 ProxyJump bastion
2636 # purple:tags prod,us-east
2637";
2638 let mut config = parse_str(config_str);
2639 config.set_host_askpass("myserver", "pass:ssh/prod");
2640 let entry = first_block(&config).to_host_entry();
2641 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2642 assert_eq!(entry.user, "admin");
2643 assert_eq!(entry.port, 2222);
2644 assert!(entry.tags.contains(&"prod".to_string()));
2645 }
2646
2647 #[test]
2648 fn askpass_with_crlf_line_endings() {
2649 let config =
2650 parse_str("Host myserver\r\n HostName 10.0.0.1\r\n # purple:askpass keychain\r\n");
2651 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2652 }
2653
2654 #[test]
2655 fn askpass_only_on_first_matching_host() {
2656 let config = parse_str(
2658 "Host dup\n HostName a.com\n # purple:askpass keychain\n\nHost dup\n HostName b.com\n # purple:askpass vault:x\n",
2659 );
2660 let entries = config.host_entries();
2661 assert_eq!(entries[0].askpass, Some("keychain".to_string()));
2663 }
2664
2665 #[test]
2666 fn set_askpass_preserves_other_non_directive_comments() {
2667 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";
2668 let mut config = parse_str(config_str);
2669 config.set_host_askpass("myserver", "new-source");
2670 let serialized = config.serialize();
2671 assert!(serialized.contains("# This is a user comment"));
2672 assert!(serialized.contains("# Another comment"));
2673 assert!(serialized.contains("# purple:askpass new-source"));
2674 assert!(!serialized.contains("# purple:askpass old"));
2675 }
2676
2677 #[test]
2678 fn askpass_mixed_with_tunnel_directives() {
2679 let config_str = "\
2680Host myserver
2681 HostName 10.0.0.1
2682 LocalForward 8080 localhost:80
2683 # purple:askpass bw:item
2684 RemoteForward 9090 localhost:9090
2685";
2686 let config = parse_str(config_str);
2687 let entry = first_block(&config).to_host_entry();
2688 assert_eq!(entry.askpass, Some("bw:item".to_string()));
2689 assert_eq!(entry.tunnel_count, 2);
2690 }
2691
2692 #[test]
2697 fn set_askpass_idempotent_same_value() {
2698 let config_str = "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n";
2699 let mut config = parse_str(config_str);
2700 config.set_host_askpass("myserver", "keychain");
2701 let output = config.serialize();
2702 assert_eq!(output.matches("purple:askpass").count(), 1);
2704 assert!(output.contains("# purple:askpass keychain"));
2705 }
2706
2707 #[test]
2708 fn set_askpass_with_equals_in_value() {
2709 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2710 config.set_host_askpass("myserver", "cmd --opt=val");
2711 let entries = config.host_entries();
2712 assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
2713 }
2714
2715 #[test]
2716 fn set_askpass_with_hash_in_value() {
2717 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2718 config.set_host_askpass("myserver", "vault:secret/data#field");
2719 let entries = config.host_entries();
2720 assert_eq!(
2721 entries[0].askpass,
2722 Some("vault:secret/data#field".to_string())
2723 );
2724 }
2725
2726 #[test]
2727 fn set_askpass_long_op_uri() {
2728 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2729 let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
2730 config.set_host_askpass("myserver", long_uri);
2731 assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
2732 }
2733
2734 #[test]
2735 fn askpass_host_with_multi_pattern_is_skipped() {
2736 let config_str = "Host prod staging\n HostName 10.0.0.1\n";
2739 let mut config = parse_str(config_str);
2740 config.set_host_askpass("prod", "keychain");
2741 assert!(config.host_entries().is_empty());
2743 }
2744
2745 #[test]
2746 fn askpass_survives_directive_reorder() {
2747 let config_str = "\
2749Host myserver
2750 # purple:askpass op://V/I/p
2751 HostName 10.0.0.1
2752 User root
2753";
2754 let config = parse_str(config_str);
2755 let entry = first_block(&config).to_host_entry();
2756 assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
2757 assert_eq!(entry.hostname, "10.0.0.1");
2758 }
2759
2760 #[test]
2761 fn askpass_among_many_purple_comments() {
2762 let config_str = "\
2763Host myserver
2764 HostName 10.0.0.1
2765 # purple:tags prod,us-east
2766 # purple:provider do:12345
2767 # purple:askpass pass:ssh/prod
2768";
2769 let config = parse_str(config_str);
2770 let entry = first_block(&config).to_host_entry();
2771 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2772 assert!(entry.tags.contains(&"prod".to_string()));
2773 }
2774
2775 #[test]
2776 fn meta_empty_when_no_comment() {
2777 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2778 let config = parse_str(config_str);
2779 let meta = first_block(&config).meta();
2780 assert!(meta.is_empty());
2781 }
2782
2783 #[test]
2784 fn meta_parses_key_value_pairs() {
2785 let config_str = "\
2786Host myhost
2787 HostName 1.2.3.4
2788 # purple:meta region=nyc3,plan=s-1vcpu-1gb
2789";
2790 let config = parse_str(config_str);
2791 let meta = first_block(&config).meta();
2792 assert_eq!(meta.len(), 2);
2793 assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
2794 assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
2795 }
2796
2797 #[test]
2798 fn meta_round_trip() {
2799 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2800 let mut config = parse_str(config_str);
2801 let meta = vec![
2802 ("region".to_string(), "fra1".to_string()),
2803 ("plan".to_string(), "cx11".to_string()),
2804 ];
2805 config.set_host_meta("myhost", &meta);
2806 let output = config.serialize();
2807 assert!(output.contains("# purple:meta region=fra1,plan=cx11"));
2808
2809 let config2 = parse_str(&output);
2810 let parsed = first_block(&config2).meta();
2811 assert_eq!(parsed, meta);
2812 }
2813
2814 #[test]
2815 fn meta_replaces_existing() {
2816 let config_str = "\
2817Host myhost
2818 HostName 1.2.3.4
2819 # purple:meta region=old
2820";
2821 let mut config = parse_str(config_str);
2822 config.set_host_meta("myhost", &[("region".to_string(), "new".to_string())]);
2823 let output = config.serialize();
2824 assert!(!output.contains("region=old"));
2825 assert!(output.contains("region=new"));
2826 }
2827
2828 #[test]
2829 fn meta_removed_when_empty() {
2830 let config_str = "\
2831Host myhost
2832 HostName 1.2.3.4
2833 # purple:meta region=nyc3
2834";
2835 let mut config = parse_str(config_str);
2836 config.set_host_meta("myhost", &[]);
2837 let output = config.serialize();
2838 assert!(!output.contains("purple:meta"));
2839 }
2840
2841 #[test]
2842 fn meta_sanitizes_commas_in_values() {
2843 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2844 let mut config = parse_str(config_str);
2845 let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
2846 config.set_host_meta("myhost", &meta);
2847 let output = config.serialize();
2848 assert!(output.contains("plan=s-1vcpu1gb"));
2850
2851 let config2 = parse_str(&output);
2852 let parsed = first_block(&config2).meta();
2853 assert_eq!(parsed[0].1, "s-1vcpu1gb");
2854 }
2855
2856 #[test]
2857 fn meta_in_host_entry() {
2858 let config_str = "\
2859Host myhost
2860 HostName 1.2.3.4
2861 # purple:meta region=nyc3,plan=s-1vcpu-1gb
2862";
2863 let config = parse_str(config_str);
2864 let entry = first_block(&config).to_host_entry();
2865 assert_eq!(entry.provider_meta.len(), 2);
2866 assert_eq!(entry.provider_meta[0].0, "region");
2867 assert_eq!(entry.provider_meta[1].0, "plan");
2868 }
2869
2870 #[test]
2871 fn repair_absorbed_group_comment() {
2872 let mut config = SshConfigFile {
2874 elements: vec![ConfigElement::HostBlock(HostBlock {
2875 host_pattern: "myserver".to_string(),
2876 raw_host_line: "Host myserver".to_string(),
2877 directives: vec![
2878 Directive {
2879 key: "HostName".to_string(),
2880 value: "10.0.0.1".to_string(),
2881 raw_line: " HostName 10.0.0.1".to_string(),
2882 is_non_directive: false,
2883 },
2884 Directive {
2885 key: String::new(),
2886 value: String::new(),
2887 raw_line: "# purple:group Production".to_string(),
2888 is_non_directive: true,
2889 },
2890 ],
2891 })],
2892 path: PathBuf::from("/tmp/test_config"),
2893 crlf: false,
2894 bom: false,
2895 };
2896 let count = config.repair_absorbed_group_comments();
2897 assert_eq!(count, 1);
2898 assert_eq!(config.elements.len(), 2);
2899 if let ConfigElement::HostBlock(block) = &config.elements[0] {
2901 assert_eq!(block.directives.len(), 1);
2902 assert_eq!(block.directives[0].key, "HostName");
2903 } else {
2904 panic!("Expected HostBlock");
2905 }
2906 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2908 assert_eq!(line, "# purple:group Production");
2909 } else {
2910 panic!("Expected GlobalLine for group comment");
2911 }
2912 }
2913
2914 #[test]
2915 fn repair_strips_trailing_blanks_before_group() {
2916 let mut config = SshConfigFile {
2917 elements: vec![ConfigElement::HostBlock(HostBlock {
2918 host_pattern: "myserver".to_string(),
2919 raw_host_line: "Host myserver".to_string(),
2920 directives: vec![
2921 Directive {
2922 key: "HostName".to_string(),
2923 value: "10.0.0.1".to_string(),
2924 raw_line: " HostName 10.0.0.1".to_string(),
2925 is_non_directive: false,
2926 },
2927 Directive {
2928 key: String::new(),
2929 value: String::new(),
2930 raw_line: "".to_string(),
2931 is_non_directive: true,
2932 },
2933 Directive {
2934 key: String::new(),
2935 value: String::new(),
2936 raw_line: "# purple:group Staging".to_string(),
2937 is_non_directive: true,
2938 },
2939 ],
2940 })],
2941 path: PathBuf::from("/tmp/test_config"),
2942 crlf: false,
2943 bom: false,
2944 };
2945 let count = config.repair_absorbed_group_comments();
2946 assert_eq!(count, 1);
2947 if let ConfigElement::HostBlock(block) = &config.elements[0] {
2949 assert_eq!(block.directives.len(), 1);
2950 } else {
2951 panic!("Expected HostBlock");
2952 }
2953 assert_eq!(config.elements.len(), 3);
2955 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2956 assert!(line.trim().is_empty());
2957 } else {
2958 panic!("Expected blank GlobalLine");
2959 }
2960 if let ConfigElement::GlobalLine(line) = &config.elements[2] {
2961 assert!(line.starts_with("# purple:group"));
2962 } else {
2963 panic!("Expected group GlobalLine");
2964 }
2965 }
2966
2967 #[test]
2968 fn repair_clean_config_returns_zero() {
2969 let mut config =
2970 parse_str("# purple:group Production\nHost myserver\n HostName 10.0.0.1\n");
2971 let count = config.repair_absorbed_group_comments();
2972 assert_eq!(count, 0);
2973 }
2974
2975 #[test]
2976 fn repair_roundtrip_serializes_correctly() {
2977 let mut config = SshConfigFile {
2979 elements: vec![
2980 ConfigElement::HostBlock(HostBlock {
2981 host_pattern: "server1".to_string(),
2982 raw_host_line: "Host server1".to_string(),
2983 directives: vec![
2984 Directive {
2985 key: "HostName".to_string(),
2986 value: "10.0.0.1".to_string(),
2987 raw_line: " HostName 10.0.0.1".to_string(),
2988 is_non_directive: false,
2989 },
2990 Directive {
2991 key: String::new(),
2992 value: String::new(),
2993 raw_line: "".to_string(),
2994 is_non_directive: true,
2995 },
2996 Directive {
2997 key: String::new(),
2998 value: String::new(),
2999 raw_line: "# purple:group Staging".to_string(),
3000 is_non_directive: true,
3001 },
3002 ],
3003 }),
3004 ConfigElement::HostBlock(HostBlock {
3005 host_pattern: "server2".to_string(),
3006 raw_host_line: "Host server2".to_string(),
3007 directives: vec![Directive {
3008 key: "HostName".to_string(),
3009 value: "10.0.0.2".to_string(),
3010 raw_line: " HostName 10.0.0.2".to_string(),
3011 is_non_directive: false,
3012 }],
3013 }),
3014 ],
3015 path: PathBuf::from("/tmp/test_config"),
3016 crlf: false,
3017 bom: false,
3018 };
3019 let count = config.repair_absorbed_group_comments();
3020 assert_eq!(count, 1);
3021 let output = config.serialize();
3022 let expected = "\
3024Host server1
3025 HostName 10.0.0.1
3026
3027# purple:group Staging
3028Host server2
3029 HostName 10.0.0.2
3030";
3031 assert_eq!(output, expected);
3032 }
3033
3034 #[test]
3039 fn delete_last_provider_host_removes_group_header() {
3040 let config_str = "\
3041# purple:group DigitalOcean
3042Host do-web
3043 HostName 1.2.3.4
3044 # purple:provider digitalocean:123
3045";
3046 let mut config = parse_str(config_str);
3047 config.delete_host("do-web");
3048 let has_header = config
3049 .elements
3050 .iter()
3051 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
3052 assert!(
3053 !has_header,
3054 "Group header should be removed when last provider host is deleted"
3055 );
3056 }
3057
3058 #[test]
3059 fn delete_one_of_multiple_provider_hosts_preserves_group_header() {
3060 let config_str = "\
3061# purple:group DigitalOcean
3062Host do-web
3063 HostName 1.2.3.4
3064 # purple:provider digitalocean:123
3065
3066Host do-db
3067 HostName 5.6.7.8
3068 # purple:provider digitalocean:456
3069";
3070 let mut config = parse_str(config_str);
3071 config.delete_host("do-web");
3072 let has_header = config.elements.iter().any(|e| {
3073 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
3074 });
3075 assert!(
3076 has_header,
3077 "Group header should be preserved when other provider hosts remain"
3078 );
3079 assert_eq!(config.host_entries().len(), 1);
3080 }
3081
3082 #[test]
3083 fn delete_non_provider_host_leaves_group_headers() {
3084 let config_str = "\
3085Host personal
3086 HostName 10.0.0.1
3087
3088# purple:group DigitalOcean
3089Host do-web
3090 HostName 1.2.3.4
3091 # purple:provider digitalocean:123
3092";
3093 let mut config = parse_str(config_str);
3094 config.delete_host("personal");
3095 let has_header = config.elements.iter().any(|e| {
3096 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
3097 });
3098 assert!(
3099 has_header,
3100 "Group header should not be affected by deleting a non-provider host"
3101 );
3102 assert_eq!(config.host_entries().len(), 1);
3103 }
3104
3105 #[test]
3106 fn delete_host_undoable_keeps_group_header_for_undo() {
3107 let config_str = "\
3111# purple:group Vultr
3112Host vultr-web
3113 HostName 2.3.4.5
3114 # purple:provider vultr:789
3115";
3116 let mut config = parse_str(config_str);
3117 let result = config.delete_host_undoable("vultr-web");
3118 assert!(result.is_some());
3119 let has_header = config
3120 .elements
3121 .iter()
3122 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
3123 assert!(has_header, "Group header should be kept for undo");
3124 }
3125
3126 #[test]
3127 fn delete_host_undoable_preserves_header_when_others_remain() {
3128 let config_str = "\
3129# purple:group AWS EC2
3130Host aws-web
3131 HostName 3.4.5.6
3132 # purple:provider aws:i-111
3133
3134Host aws-db
3135 HostName 7.8.9.0
3136 # purple:provider aws:i-222
3137";
3138 let mut config = parse_str(config_str);
3139 let result = config.delete_host_undoable("aws-web");
3140 assert!(result.is_some());
3141 let has_header = config.elements.iter().any(
3142 |e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group AWS EC2")),
3143 );
3144 assert!(
3145 has_header,
3146 "Group header preserved when other provider hosts remain (undoable)"
3147 );
3148 }
3149
3150 #[test]
3151 fn delete_host_undoable_returns_original_position_for_undo() {
3152 let config_str = "\
3155# purple:group Vultr
3156Host vultr-web
3157 HostName 2.3.4.5
3158 # purple:provider vultr:789
3159
3160Host manual
3161 HostName 10.0.0.1
3162";
3163 let mut config = parse_str(config_str);
3164 let (element, pos) = config.delete_host_undoable("vultr-web").unwrap();
3165 assert_eq!(pos, 1, "Position should be the original host index");
3167 config.insert_host_at(element, pos);
3169 let output = config.serialize();
3171 assert!(
3172 output.contains("# purple:group Vultr"),
3173 "Group header should be present"
3174 );
3175 assert!(output.contains("Host vultr-web"), "Host should be restored");
3176 assert!(output.contains("Host manual"), "Manual host should survive");
3177 assert_eq!(config_str, output);
3178 }
3179
3180 #[test]
3185 fn add_host_inserts_before_trailing_wildcard() {
3186 let config_str = "\
3187Host existing
3188 HostName 10.0.0.1
3189
3190Host *
3191 ServerAliveInterval 60
3192";
3193 let mut config = parse_str(config_str);
3194 let entry = HostEntry {
3195 alias: "newhost".to_string(),
3196 hostname: "10.0.0.2".to_string(),
3197 port: 22,
3198 ..Default::default()
3199 };
3200 config.add_host(&entry);
3201 let output = config.serialize();
3202 let new_pos = output.find("Host newhost").unwrap();
3203 let wildcard_pos = output.find("Host *").unwrap();
3204 assert!(
3205 new_pos < wildcard_pos,
3206 "New host should appear before Host *: {}",
3207 output
3208 );
3209 let existing_pos = output.find("Host existing").unwrap();
3210 assert!(existing_pos < new_pos);
3211 }
3212
3213 #[test]
3214 fn add_host_appends_when_no_wildcards() {
3215 let config_str = "\
3216Host existing
3217 HostName 10.0.0.1
3218";
3219 let mut config = parse_str(config_str);
3220 let entry = HostEntry {
3221 alias: "newhost".to_string(),
3222 hostname: "10.0.0.2".to_string(),
3223 port: 22,
3224 ..Default::default()
3225 };
3226 config.add_host(&entry);
3227 let output = config.serialize();
3228 let existing_pos = output.find("Host existing").unwrap();
3229 let new_pos = output.find("Host newhost").unwrap();
3230 assert!(existing_pos < new_pos, "New host should be appended at end");
3231 }
3232
3233 #[test]
3234 fn add_host_appends_when_wildcard_at_beginning() {
3235 let config_str = "\
3237Host *
3238 ServerAliveInterval 60
3239
3240Host existing
3241 HostName 10.0.0.1
3242";
3243 let mut config = parse_str(config_str);
3244 let entry = HostEntry {
3245 alias: "newhost".to_string(),
3246 hostname: "10.0.0.2".to_string(),
3247 port: 22,
3248 ..Default::default()
3249 };
3250 config.add_host(&entry);
3251 let output = config.serialize();
3252 let existing_pos = output.find("Host existing").unwrap();
3253 let new_pos = output.find("Host newhost").unwrap();
3254 assert!(
3255 existing_pos < new_pos,
3256 "New host should be appended at end when wildcard is at top: {}",
3257 output
3258 );
3259 }
3260
3261 #[test]
3262 fn add_host_inserts_before_trailing_pattern_host() {
3263 let config_str = "\
3264Host existing
3265 HostName 10.0.0.1
3266
3267Host *.example.com
3268 ProxyJump bastion
3269";
3270 let mut config = parse_str(config_str);
3271 let entry = HostEntry {
3272 alias: "newhost".to_string(),
3273 hostname: "10.0.0.2".to_string(),
3274 port: 22,
3275 ..Default::default()
3276 };
3277 config.add_host(&entry);
3278 let output = config.serialize();
3279 let new_pos = output.find("Host newhost").unwrap();
3280 let pattern_pos = output.find("Host *.example.com").unwrap();
3281 assert!(
3282 new_pos < pattern_pos,
3283 "New host should appear before pattern host: {}",
3284 output
3285 );
3286 }
3287
3288 #[test]
3289 fn add_host_no_triple_blank_lines() {
3290 let config_str = "\
3291Host existing
3292 HostName 10.0.0.1
3293
3294Host *
3295 ServerAliveInterval 60
3296";
3297 let mut config = parse_str(config_str);
3298 let entry = HostEntry {
3299 alias: "newhost".to_string(),
3300 hostname: "10.0.0.2".to_string(),
3301 port: 22,
3302 ..Default::default()
3303 };
3304 config.add_host(&entry);
3305 let output = config.serialize();
3306 assert!(
3307 !output.contains("\n\n\n"),
3308 "Should not have triple blank lines: {}",
3309 output
3310 );
3311 }
3312
3313 #[test]
3314 fn provider_group_display_name_matches_providers_mod() {
3315 let providers = [
3320 "digitalocean",
3321 "vultr",
3322 "linode",
3323 "hetzner",
3324 "upcloud",
3325 "proxmox",
3326 "aws",
3327 "scaleway",
3328 "gcp",
3329 "azure",
3330 "tailscale",
3331 "oracle",
3332 ];
3333 for name in &providers {
3334 assert_eq!(
3335 provider_group_display_name(name),
3336 crate::providers::provider_display_name(name),
3337 "Display name mismatch for provider '{}': model.rs has '{}' but providers/mod.rs has '{}'",
3338 name,
3339 provider_group_display_name(name),
3340 crate::providers::provider_display_name(name),
3341 );
3342 }
3343 }
3344
3345 #[test]
3346 fn test_sanitize_tag_strips_control_chars() {
3347 assert_eq!(HostBlock::sanitize_tag("prod"), "prod");
3348 assert_eq!(HostBlock::sanitize_tag("prod\n"), "prod");
3349 assert_eq!(HostBlock::sanitize_tag("pr\x00od"), "prod");
3350 assert_eq!(HostBlock::sanitize_tag("\t\r\n"), "");
3351 }
3352
3353 #[test]
3354 fn test_sanitize_tag_strips_commas() {
3355 assert_eq!(HostBlock::sanitize_tag("prod,staging"), "prodstaging");
3356 assert_eq!(HostBlock::sanitize_tag(",,,"), "");
3357 }
3358
3359 #[test]
3360 fn test_sanitize_tag_strips_bidi() {
3361 assert_eq!(HostBlock::sanitize_tag("prod\u{202E}tset"), "prodtset");
3362 assert_eq!(HostBlock::sanitize_tag("\u{200B}zero\u{FEFF}"), "zero");
3363 }
3364
3365 #[test]
3366 fn test_sanitize_tag_truncates_long() {
3367 let long = "a".repeat(200);
3368 assert_eq!(HostBlock::sanitize_tag(&long).len(), 128);
3369 }
3370
3371 #[test]
3372 fn test_sanitize_tag_preserves_unicode() {
3373 assert_eq!(HostBlock::sanitize_tag("日本語"), "日本語");
3374 assert_eq!(HostBlock::sanitize_tag("café"), "café");
3375 }
3376
3377 #[test]
3382 fn test_provider_tags_parsing() {
3383 let config =
3384 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags a,b,c\n");
3385 let entry = first_block(&config).to_host_entry();
3386 assert_eq!(entry.provider_tags, vec!["a", "b", "c"]);
3387 }
3388
3389 #[test]
3390 fn test_provider_tags_empty() {
3391 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
3392 let entry = first_block(&config).to_host_entry();
3393 assert!(entry.provider_tags.is_empty());
3394 }
3395
3396 #[test]
3397 fn test_has_provider_tags_comment_present() {
3398 let config =
3399 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags prod\n");
3400 assert!(first_block(&config).has_provider_tags_comment());
3401 assert!(first_block(&config).to_host_entry().has_provider_tags);
3402 }
3403
3404 #[test]
3405 fn test_has_provider_tags_comment_sentinel() {
3406 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags\n");
3408 assert!(first_block(&config).has_provider_tags_comment());
3409 assert!(first_block(&config).to_host_entry().has_provider_tags);
3410 assert!(
3411 first_block(&config)
3412 .to_host_entry()
3413 .provider_tags
3414 .is_empty()
3415 );
3416 }
3417
3418 #[test]
3419 fn test_has_provider_tags_comment_absent() {
3420 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
3421 assert!(!first_block(&config).has_provider_tags_comment());
3422 assert!(!first_block(&config).to_host_entry().has_provider_tags);
3423 }
3424
3425 #[test]
3426 fn test_set_tags_does_not_delete_provider_tags() {
3427 let mut config = parse_str(
3428 "Host myserver\n HostName 10.0.0.1\n # purple:tags user1\n # purple:provider_tags cloud1,cloud2\n",
3429 );
3430 config.set_host_tags("myserver", &["newuser".to_string()]);
3431 let entry = first_block(&config).to_host_entry();
3432 assert_eq!(entry.tags, vec!["newuser"]);
3433 assert_eq!(entry.provider_tags, vec!["cloud1", "cloud2"]);
3434 }
3435
3436 #[test]
3437 fn test_set_provider_tags_does_not_delete_user_tags() {
3438 let mut config = parse_str(
3439 "Host myserver\n HostName 10.0.0.1\n # purple:tags user1,user2\n # purple:provider_tags old\n",
3440 );
3441 config.set_host_provider_tags("myserver", &["new1".to_string(), "new2".to_string()]);
3442 let entry = first_block(&config).to_host_entry();
3443 assert_eq!(entry.tags, vec!["user1", "user2"]);
3444 assert_eq!(entry.provider_tags, vec!["new1", "new2"]);
3445 }
3446
3447 #[test]
3448 fn test_set_askpass_does_not_delete_similar_comments() {
3449 let mut config = parse_str(
3451 "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n # purple:askpass_backup test\n",
3452 );
3453 config.set_host_askpass("myserver", "op://vault/item/pass");
3454 let entry = first_block(&config).to_host_entry();
3455 assert_eq!(entry.askpass, Some("op://vault/item/pass".to_string()));
3456 let serialized = config.serialize();
3458 assert!(serialized.contains("purple:askpass_backup test"));
3459 }
3460
3461 #[test]
3462 fn test_set_meta_does_not_delete_similar_comments() {
3463 let mut config = parse_str(
3465 "Host myserver\n HostName 10.0.0.1\n # purple:meta region=us-east\n # purple:metadata foo\n",
3466 );
3467 config.set_host_meta("myserver", &[("region".to_string(), "eu-west".to_string())]);
3468 let serialized = config.serialize();
3469 assert!(serialized.contains("purple:meta region=eu-west"));
3470 assert!(serialized.contains("purple:metadata foo"));
3471 }
3472
3473 #[test]
3474 fn test_set_meta_sanitizes_control_chars() {
3475 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
3476 config.set_host_meta(
3477 "myserver",
3478 &[
3479 ("region".to_string(), "us\x00east".to_string()),
3480 ("zone".to_string(), "a\u{202E}b".to_string()),
3481 ],
3482 );
3483 let serialized = config.serialize();
3484 assert!(serialized.contains("region=useast"));
3486 assert!(serialized.contains("zone=ab"));
3487 assert!(!serialized.contains('\x00'));
3488 assert!(!serialized.contains('\u{202E}'));
3489 }
3490
3491 #[test]
3494 fn stale_returns_timestamp() {
3495 let config_str = "\
3496Host web
3497 HostName 1.2.3.4
3498 # purple:stale 1711900000
3499";
3500 let config = parse_str(config_str);
3501 assert_eq!(first_block(&config).stale(), Some(1711900000));
3502 }
3503
3504 #[test]
3505 fn stale_returns_none_when_absent() {
3506 let config_str = "Host web\n HostName 1.2.3.4\n";
3507 let config = parse_str(config_str);
3508 assert_eq!(first_block(&config).stale(), None);
3509 }
3510
3511 #[test]
3512 fn stale_returns_none_for_malformed() {
3513 for bad in &[
3514 "Host w\n HostName 1.2.3.4\n # purple:stale abc\n",
3515 "Host w\n HostName 1.2.3.4\n # purple:stale\n",
3516 "Host w\n HostName 1.2.3.4\n # purple:stale -1\n",
3517 ] {
3518 let config = parse_str(bad);
3519 assert_eq!(first_block(&config).stale(), None, "input: {bad}");
3520 }
3521 }
3522
3523 #[test]
3524 fn set_stale_adds_comment() {
3525 let config_str = "Host web\n HostName 1.2.3.4\n";
3526 let mut config = parse_str(config_str);
3527 first_block_mut(&mut config).set_stale(1711900000);
3528 assert_eq!(first_block(&config).stale(), Some(1711900000));
3529 assert!(config.serialize().contains("# purple:stale 1711900000"));
3530 }
3531
3532 #[test]
3533 fn set_stale_replaces_existing() {
3534 let config_str = "\
3535Host web
3536 HostName 1.2.3.4
3537 # purple:stale 1000
3538";
3539 let mut config = parse_str(config_str);
3540 first_block_mut(&mut config).set_stale(2000);
3541 assert_eq!(first_block(&config).stale(), Some(2000));
3542 let output = config.serialize();
3543 assert!(!output.contains("1000"));
3544 assert!(output.contains("# purple:stale 2000"));
3545 }
3546
3547 #[test]
3548 fn clear_stale_removes_comment() {
3549 let config_str = "\
3550Host web
3551 HostName 1.2.3.4
3552 # purple:stale 1711900000
3553";
3554 let mut config = parse_str(config_str);
3555 first_block_mut(&mut config).clear_stale();
3556 assert_eq!(first_block(&config).stale(), None);
3557 assert!(!config.serialize().contains("purple:stale"));
3558 }
3559
3560 #[test]
3561 fn clear_stale_when_absent_is_noop() {
3562 let config_str = "Host web\n HostName 1.2.3.4\n";
3563 let mut config = parse_str(config_str);
3564 let before = config.serialize();
3565 first_block_mut(&mut config).clear_stale();
3566 assert_eq!(config.serialize(), before);
3567 }
3568
3569 #[test]
3570 fn stale_roundtrip() {
3571 let config_str = "\
3572Host web
3573 HostName 1.2.3.4
3574 # purple:stale 1711900000
3575";
3576 let config = parse_str(config_str);
3577 let output = config.serialize();
3578 let config2 = parse_str(&output);
3579 assert_eq!(first_block(&config2).stale(), Some(1711900000));
3580 }
3581
3582 #[test]
3583 fn stale_in_host_entry() {
3584 let config_str = "\
3585Host web
3586 HostName 1.2.3.4
3587 # purple:stale 1711900000
3588";
3589 let config = parse_str(config_str);
3590 let entry = first_block(&config).to_host_entry();
3591 assert_eq!(entry.stale, Some(1711900000));
3592 }
3593
3594 #[test]
3595 fn stale_coexists_with_other_annotations() {
3596 let config_str = "\
3597Host web
3598 HostName 1.2.3.4
3599 # purple:tags prod
3600 # purple:provider do:12345
3601 # purple:askpass keychain
3602 # purple:meta region=nyc3
3603 # purple:stale 1711900000
3604";
3605 let config = parse_str(config_str);
3606 let entry = first_block(&config).to_host_entry();
3607 assert_eq!(entry.stale, Some(1711900000));
3608 assert!(entry.tags.contains(&"prod".to_string()));
3609 assert_eq!(entry.provider, Some("do".to_string()));
3610 assert_eq!(entry.askpass, Some("keychain".to_string()));
3611 assert_eq!(entry.provider_meta[0].0, "region");
3612 }
3613
3614 #[test]
3615 fn set_host_stale_delegates() {
3616 let config_str = "\
3617Host web
3618 HostName 1.2.3.4
3619
3620Host db
3621 HostName 5.6.7.8
3622";
3623 let mut config = parse_str(config_str);
3624 config.set_host_stale("db", 1234567890);
3625 assert_eq!(config.host_entries()[1].stale, Some(1234567890));
3626 assert_eq!(config.host_entries()[0].stale, None);
3627 }
3628
3629 #[test]
3630 fn clear_host_stale_delegates() {
3631 let config_str = "\
3632Host web
3633 HostName 1.2.3.4
3634 # purple:stale 1711900000
3635";
3636 let mut config = parse_str(config_str);
3637 config.clear_host_stale("web");
3638 assert_eq!(first_block(&config).stale(), None);
3639 }
3640
3641 #[test]
3642 fn stale_hosts_collects_all() {
3643 let config_str = "\
3644Host web
3645 HostName 1.2.3.4
3646 # purple:stale 1000
3647
3648Host db
3649 HostName 5.6.7.8
3650
3651Host app
3652 HostName 9.10.11.12
3653 # purple:stale 2000
3654";
3655 let config = parse_str(config_str);
3656 let stale = config.stale_hosts();
3657 assert_eq!(stale.len(), 2);
3658 assert_eq!(stale[0], ("web".to_string(), 1000));
3659 assert_eq!(stale[1], ("app".to_string(), 2000));
3660 }
3661
3662 #[test]
3663 fn set_stale_preserves_indent() {
3664 let config_str = "Host web\n\tHostName 1.2.3.4\n";
3665 let mut config = parse_str(config_str);
3666 first_block_mut(&mut config).set_stale(1711900000);
3667 assert!(config.serialize().contains("\t# purple:stale 1711900000"));
3668 }
3669
3670 #[test]
3671 fn stale_does_not_match_similar_comments() {
3672 let config_str = "\
3673Host web
3674 HostName 1.2.3.4
3675 # purple:stale_backup 999
3676";
3677 let config = parse_str(config_str);
3678 assert_eq!(first_block(&config).stale(), None);
3679 }
3680
3681 #[test]
3682 fn stale_with_whitespace_in_timestamp() {
3683 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 1711900000 \n";
3684 let config = parse_str(config_str);
3685 assert_eq!(first_block(&config).stale(), Some(1711900000));
3686 }
3687
3688 #[test]
3689 fn stale_with_u64_max() {
3690 let ts = u64::MAX;
3691 let config_str = format!("Host w\n HostName 1.2.3.4\n # purple:stale {}\n", ts);
3692 let config = parse_str(&config_str);
3693 assert_eq!(first_block(&config).stale(), Some(ts));
3694 let output = config.serialize();
3696 let config2 = parse_str(&output);
3697 assert_eq!(first_block(&config2).stale(), Some(ts));
3698 }
3699
3700 #[test]
3701 fn stale_with_u64_overflow() {
3702 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 18446744073709551616\n";
3703 let config = parse_str(config_str);
3704 assert_eq!(first_block(&config).stale(), None);
3705 }
3706
3707 #[test]
3708 fn stale_timestamp_zero() {
3709 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 0\n";
3710 let config = parse_str(config_str);
3711 assert_eq!(first_block(&config).stale(), Some(0));
3712 }
3713
3714 #[test]
3715 fn set_host_stale_nonexistent_alias_is_noop() {
3716 let config_str = "Host web\n HostName 1.2.3.4\n";
3717 let mut config = parse_str(config_str);
3718 let before = config.serialize();
3719 config.set_host_stale("nonexistent", 12345);
3720 assert_eq!(config.serialize(), before);
3721 }
3722
3723 #[test]
3724 fn clear_host_stale_nonexistent_alias_is_noop() {
3725 let config_str = "Host web\n HostName 1.2.3.4\n";
3726 let mut config = parse_str(config_str);
3727 let before = config.serialize();
3728 config.clear_host_stale("nonexistent");
3729 assert_eq!(config.serialize(), before);
3730 }
3731
3732 #[test]
3733 fn stale_hosts_empty_config() {
3734 let config_str = "";
3735 let config = parse_str(config_str);
3736 assert!(config.stale_hosts().is_empty());
3737 }
3738
3739 #[test]
3740 fn stale_hosts_no_stale() {
3741 let config_str = "Host web\n HostName 1.2.3.4\n\nHost db\n HostName 5.6.7.8\n";
3742 let config = parse_str(config_str);
3743 assert!(config.stale_hosts().is_empty());
3744 }
3745
3746 #[test]
3747 fn clear_stale_preserves_other_purple_comments() {
3748 let config_str = "\
3749Host web
3750 HostName 1.2.3.4
3751 # purple:tags prod
3752 # purple:provider do:123
3753 # purple:askpass keychain
3754 # purple:meta region=nyc3
3755 # purple:stale 1711900000
3756";
3757 let mut config = parse_str(config_str);
3758 config.clear_host_stale("web");
3759 let entry = first_block(&config).to_host_entry();
3760 assert_eq!(entry.stale, None);
3761 assert!(entry.tags.contains(&"prod".to_string()));
3762 assert_eq!(entry.provider, Some("do".to_string()));
3763 assert_eq!(entry.askpass, Some("keychain".to_string()));
3764 assert_eq!(entry.provider_meta[0].0, "region");
3765 }
3766
3767 #[test]
3768 fn set_stale_preserves_other_purple_comments() {
3769 let config_str = "\
3770Host web
3771 HostName 1.2.3.4
3772 # purple:tags prod
3773 # purple:provider do:123
3774 # purple:askpass keychain
3775 # purple:meta region=nyc3
3776";
3777 let mut config = parse_str(config_str);
3778 config.set_host_stale("web", 1711900000);
3779 let entry = first_block(&config).to_host_entry();
3780 assert_eq!(entry.stale, Some(1711900000));
3781 assert!(entry.tags.contains(&"prod".to_string()));
3782 assert_eq!(entry.provider, Some("do".to_string()));
3783 assert_eq!(entry.askpass, Some("keychain".to_string()));
3784 assert_eq!(entry.provider_meta[0].0, "region");
3785 }
3786
3787 #[test]
3788 fn stale_multiple_comments_first_wins() {
3789 let config_str = "\
3790Host web
3791 HostName 1.2.3.4
3792 # purple:stale 1000
3793 # purple:stale 2000
3794";
3795 let config = parse_str(config_str);
3796 assert_eq!(first_block(&config).stale(), Some(1000));
3797 }
3798
3799 #[test]
3800 fn set_stale_removes_multiple_stale_comments() {
3801 let config_str = "\
3802Host web
3803 HostName 1.2.3.4
3804 # purple:stale 1000
3805 # purple:stale 2000
3806";
3807 let mut config = parse_str(config_str);
3808 first_block_mut(&mut config).set_stale(3000);
3809 assert_eq!(first_block(&config).stale(), Some(3000));
3810 let output = config.serialize();
3811 assert_eq!(output.matches("purple:stale").count(), 1);
3812 }
3813
3814 #[test]
3815 fn stale_absent_in_host_entry() {
3816 let config_str = "Host web\n HostName 1.2.3.4\n";
3817 let config = parse_str(config_str);
3818 assert_eq!(first_block(&config).to_host_entry().stale, None);
3819 }
3820
3821 #[test]
3822 fn set_stale_four_space_indent() {
3823 let config_str = "Host web\n HostName 1.2.3.4\n";
3824 let mut config = parse_str(config_str);
3825 first_block_mut(&mut config).set_stale(1711900000);
3826 assert!(config.serialize().contains(" # purple:stale 1711900000"));
3827 }
3828
3829 #[test]
3830 fn clear_stale_removes_bare_comment() {
3831 let config_str = "Host web\n HostName 1.2.3.4\n # purple:stale\n";
3832 let mut config = parse_str(config_str);
3833 first_block_mut(&mut config).clear_stale();
3834 assert!(!config.serialize().contains("purple:stale"));
3835 }
3836
3837 #[test]
3840 fn stale_preserves_blank_line_between_hosts() {
3841 let config_str = "\
3842Host web
3843 HostName 1.2.3.4
3844
3845Host db
3846 HostName 5.6.7.8
3847";
3848 let mut config = parse_str(config_str);
3849 config.set_host_stale("web", 1711900000);
3850 let output = config.serialize();
3851 assert!(
3853 output.contains("# purple:stale 1711900000\n\nHost db"),
3854 "blank line between hosts lost after set_stale:\n{}",
3855 output
3856 );
3857 }
3858
3859 #[test]
3860 fn stale_preserves_blank_line_before_group_header() {
3861 let config_str = "\
3862Host do-web
3863 HostName 1.2.3.4
3864 # purple:provider digitalocean:111
3865
3866# purple:group Hetzner
3867
3868Host hz-cache
3869 HostName 9.10.11.12
3870 # purple:provider hetzner:333
3871";
3872 let mut config = parse_str(config_str);
3873 config.set_host_stale("do-web", 1711900000);
3874 let output = config.serialize();
3875 assert!(
3877 output.contains("\n\n# purple:group Hetzner"),
3878 "blank line before group header lost after set_stale:\n{}",
3879 output
3880 );
3881 }
3882
3883 #[test]
3884 fn stale_set_and_clear_is_byte_identical() {
3885 let config_str = "\
3886Host manual
3887 HostName 10.0.0.1
3888 User admin
3889
3890# purple:group DigitalOcean
3891
3892Host do-web
3893 HostName 1.2.3.4
3894 User root
3895 # purple:provider digitalocean:111
3896 # purple:tags prod
3897
3898Host do-db
3899 HostName 5.6.7.8
3900 User root
3901 # purple:provider digitalocean:222
3902 # purple:meta region=nyc3
3903
3904# purple:group Hetzner
3905
3906Host hz-cache
3907 HostName 9.10.11.12
3908 User root
3909 # purple:provider hetzner:333
3910";
3911 let original = config_str.to_string();
3912 let mut config = parse_str(config_str);
3913
3914 config.set_host_stale("do-db", 1711900000);
3916 let after_stale = config.serialize();
3917 assert_ne!(after_stale, original, "stale should change the config");
3918
3919 config.clear_host_stale("do-db");
3921 let after_clear = config.serialize();
3922 assert_eq!(
3923 after_clear, original,
3924 "clearing stale must restore byte-identical config"
3925 );
3926 }
3927
3928 #[test]
3929 fn stale_does_not_accumulate_blank_lines() {
3930 let config_str = "Host web\n HostName 1.2.3.4\n\nHost db\n HostName 5.6.7.8\n";
3931 let mut config = parse_str(config_str);
3932
3933 for _ in 0..10 {
3935 config.set_host_stale("web", 1711900000);
3936 config.clear_host_stale("web");
3937 }
3938
3939 let output = config.serialize();
3940 assert_eq!(
3941 output, config_str,
3942 "repeated set/clear must not accumulate blank lines"
3943 );
3944 }
3945
3946 #[test]
3947 fn stale_preserves_all_directives_and_comments() {
3948 let config_str = "\
3949Host complex
3950 HostName 1.2.3.4
3951 User deploy
3952 Port 2222
3953 IdentityFile ~/.ssh/id_ed25519
3954 ProxyJump bastion
3955 LocalForward 8080 localhost:80
3956 # purple:provider digitalocean:999
3957 # purple:tags prod,us-east
3958 # purple:provider_tags web-tier
3959 # purple:askpass keychain
3960 # purple:meta region=nyc3,plan=s-1vcpu-1gb
3961 # This is a user comment
3962";
3963 let mut config = parse_str(config_str);
3964 let entry_before = first_block(&config).to_host_entry();
3965
3966 config.set_host_stale("complex", 1711900000);
3967 let entry_after = first_block(&config).to_host_entry();
3968
3969 assert_eq!(entry_after.hostname, entry_before.hostname);
3971 assert_eq!(entry_after.user, entry_before.user);
3972 assert_eq!(entry_after.port, entry_before.port);
3973 assert_eq!(entry_after.identity_file, entry_before.identity_file);
3974 assert_eq!(entry_after.proxy_jump, entry_before.proxy_jump);
3975 assert_eq!(entry_after.tags, entry_before.tags);
3976 assert_eq!(entry_after.provider_tags, entry_before.provider_tags);
3977 assert_eq!(entry_after.provider, entry_before.provider);
3978 assert_eq!(entry_after.askpass, entry_before.askpass);
3979 assert_eq!(entry_after.provider_meta, entry_before.provider_meta);
3980 assert_eq!(entry_after.tunnel_count, entry_before.tunnel_count);
3981 assert_eq!(entry_after.stale, Some(1711900000));
3982
3983 config.clear_host_stale("complex");
3985 let entry_cleared = first_block(&config).to_host_entry();
3986 assert_eq!(entry_cleared.stale, None);
3987 assert_eq!(entry_cleared.hostname, entry_before.hostname);
3988 assert_eq!(entry_cleared.tags, entry_before.tags);
3989 assert_eq!(entry_cleared.provider, entry_before.provider);
3990 assert_eq!(entry_cleared.askpass, entry_before.askpass);
3991 assert_eq!(entry_cleared.provider_meta, entry_before.provider_meta);
3992
3993 assert!(config.serialize().contains("# This is a user comment"));
3995 }
3996
3997 #[test]
3998 fn stale_on_last_host_preserves_trailing_newline() {
3999 let config_str = "Host web\n HostName 1.2.3.4\n";
4000 let mut config = parse_str(config_str);
4001 config.set_host_stale("web", 1711900000);
4002 let output = config.serialize();
4003 assert!(output.ends_with('\n'), "config must end with newline");
4004
4005 config.clear_host_stale("web");
4006 let output2 = config.serialize();
4007 assert_eq!(output2, config_str);
4008 }
4009
4010 #[test]
4011 fn stale_with_crlf_preserves_line_endings() {
4012 let config_str = "Host web\r\n HostName 1.2.3.4\r\n";
4013 let config = SshConfigFile {
4014 elements: SshConfigFile::parse_content(config_str),
4015 path: std::path::PathBuf::from("/tmp/test"),
4016 crlf: true,
4017 bom: false,
4018 };
4019 let mut config = config;
4020 config.set_host_stale("web", 1711900000);
4021 let output = config.serialize();
4022 for line in output.split('\n') {
4024 if !line.is_empty() {
4025 assert!(
4026 line.ends_with('\r'),
4027 "CRLF lost after set_stale. Line: {:?}",
4028 line
4029 );
4030 }
4031 }
4032
4033 config.clear_host_stale("web");
4034 assert_eq!(config.serialize(), config_str);
4035 }
4036
4037 #[test]
4038 fn pattern_match_star_wildcard() {
4039 assert!(ssh_pattern_match("*", "anything"));
4040 assert!(ssh_pattern_match("10.30.0.*", "10.30.0.5"));
4041 assert!(ssh_pattern_match("10.30.0.*", "10.30.0.100"));
4042 assert!(!ssh_pattern_match("10.30.0.*", "10.30.1.5"));
4043 assert!(ssh_pattern_match("*.example.com", "web.example.com"));
4044 assert!(!ssh_pattern_match("*.example.com", "example.com"));
4045 assert!(ssh_pattern_match("prod-*-web", "prod-us-web"));
4046 assert!(!ssh_pattern_match("prod-*-web", "prod-us-api"));
4047 }
4048
4049 #[test]
4050 fn pattern_match_question_mark() {
4051 assert!(ssh_pattern_match("server-?", "server-1"));
4052 assert!(ssh_pattern_match("server-?", "server-a"));
4053 assert!(!ssh_pattern_match("server-?", "server-10"));
4054 assert!(!ssh_pattern_match("server-?", "server-"));
4055 }
4056
4057 #[test]
4058 fn pattern_match_character_class() {
4059 assert!(ssh_pattern_match("server-[abc]", "server-a"));
4060 assert!(ssh_pattern_match("server-[abc]", "server-c"));
4061 assert!(!ssh_pattern_match("server-[abc]", "server-d"));
4062 assert!(ssh_pattern_match("server-[0-9]", "server-5"));
4063 assert!(!ssh_pattern_match("server-[0-9]", "server-a"));
4064 assert!(ssh_pattern_match("server-[!abc]", "server-d"));
4065 assert!(!ssh_pattern_match("server-[!abc]", "server-a"));
4066 assert!(ssh_pattern_match("server-[^abc]", "server-d"));
4067 assert!(!ssh_pattern_match("server-[^abc]", "server-a"));
4068 }
4069
4070 #[test]
4071 fn pattern_match_negation() {
4072 assert!(!ssh_pattern_match("!prod-*", "prod-web"));
4073 assert!(ssh_pattern_match("!prod-*", "staging-web"));
4074 }
4075
4076 #[test]
4077 fn pattern_match_exact() {
4078 assert!(ssh_pattern_match("myserver", "myserver"));
4079 assert!(!ssh_pattern_match("myserver", "myserver2"));
4080 assert!(!ssh_pattern_match("myserver", "other"));
4081 }
4082
4083 #[test]
4084 fn pattern_match_empty() {
4085 assert!(!ssh_pattern_match("", "anything"));
4086 assert!(!ssh_pattern_match("*", ""));
4087 assert!(ssh_pattern_match("", ""));
4088 }
4089
4090 #[test]
4091 fn host_pattern_matches_multi_pattern() {
4092 assert!(host_pattern_matches("prod staging", "prod"));
4093 assert!(host_pattern_matches("prod staging", "staging"));
4094 assert!(!host_pattern_matches("prod staging", "dev"));
4095 }
4096
4097 #[test]
4098 fn host_pattern_matches_with_negation() {
4099 assert!(host_pattern_matches(
4100 "*.example.com !internal.example.com",
4101 "web.example.com",
4102 ));
4103 assert!(!host_pattern_matches(
4104 "*.example.com !internal.example.com",
4105 "internal.example.com",
4106 ));
4107 }
4108
4109 #[test]
4110 fn host_pattern_matches_alias_only() {
4111 assert!(!host_pattern_matches("10.30.0.*", "production"));
4113 assert!(host_pattern_matches("prod*", "production"));
4114 assert!(!host_pattern_matches("staging*", "production"));
4115 }
4116
4117 #[test]
4118 fn pattern_entries_collects_wildcards() {
4119 let config = parse_str(
4120 "Host myserver\n Hostname 10.0.0.1\n\nHost 10.30.0.*\n User debian\n ProxyJump bastion\n\nHost *\n ServerAliveInterval 60\n",
4121 );
4122 let patterns = config.pattern_entries();
4123 assert_eq!(patterns.len(), 2);
4124 assert_eq!(patterns[0].pattern, "10.30.0.*");
4125 assert_eq!(patterns[0].user, "debian");
4126 assert_eq!(patterns[0].proxy_jump, "bastion");
4127 assert_eq!(patterns[1].pattern, "*");
4128 assert!(
4129 patterns[1]
4130 .directives
4131 .iter()
4132 .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
4133 );
4134 }
4135
4136 #[test]
4137 fn pattern_entries_empty_when_no_patterns() {
4138 let config = parse_str("Host myserver\n Hostname 10.0.0.1\n");
4139 let patterns = config.pattern_entries();
4140 assert!(patterns.is_empty());
4141 }
4142
4143 #[test]
4144 fn matching_patterns_returns_in_config_order() {
4145 let config = parse_str(
4146 "Host 10.30.0.*\n User debian\n\nHost myserver\n Hostname 10.30.0.5\n\nHost *\n ServerAliveInterval 60\n",
4147 );
4148 let matches = config.matching_patterns("myserver");
4150 assert_eq!(matches.len(), 1);
4151 assert_eq!(matches[0].pattern, "*");
4152 }
4153
4154 #[test]
4155 fn matching_patterns_negation_excludes() {
4156 let config = parse_str(
4157 "Host * !bastion\n ServerAliveInterval 60\n\nHost bastion\n Hostname 10.0.0.1\n",
4158 );
4159 let matches = config.matching_patterns("bastion");
4160 assert!(matches.is_empty());
4161 }
4162
4163 #[test]
4164 fn pattern_entries_and_host_entries_are_disjoint() {
4165 let config = parse_str(
4166 "Host myserver\n Hostname 10.0.0.1\n\nHost 10.30.0.*\n User debian\n\nHost *\n ServerAliveInterval 60\n",
4167 );
4168 let hosts = config.host_entries();
4169 let patterns = config.pattern_entries();
4170 assert_eq!(hosts.len(), 1);
4171 assert_eq!(hosts[0].alias, "myserver");
4172 assert_eq!(patterns.len(), 2);
4173 assert_eq!(patterns[0].pattern, "10.30.0.*");
4174 assert_eq!(patterns[1].pattern, "*");
4175 }
4176
4177 #[test]
4178 fn pattern_crud_round_trip() {
4179 let mut config = parse_str("Host myserver\n Hostname 10.0.0.1\n");
4180 let entry = HostEntry {
4182 alias: "10.30.0.*".to_string(),
4183 user: "debian".to_string(),
4184 ..Default::default()
4185 };
4186 config.add_host(&entry);
4187 let output = config.serialize();
4188 assert!(output.contains("Host 10.30.0.*"));
4189 assert!(output.contains("User debian"));
4190 let reparsed = parse_str(&output);
4192 assert_eq!(reparsed.host_entries().len(), 1);
4193 assert_eq!(reparsed.pattern_entries().len(), 1);
4194 assert_eq!(reparsed.pattern_entries()[0].pattern, "10.30.0.*");
4195 }
4196
4197 #[test]
4198 fn host_entries_inherit_proxy_jump_from_wildcard_pattern() {
4199 let config =
4201 parse_str("Host web-*\n ProxyJump bastion\n\nHost web-prod\n Hostname 10.0.0.1\n");
4202 let hosts = config.host_entries();
4203 assert_eq!(hosts.len(), 1);
4204 assert_eq!(hosts[0].alias, "web-prod");
4205 assert_eq!(hosts[0].proxy_jump, "bastion");
4206 }
4207
4208 #[test]
4209 fn host_entries_inherit_proxy_jump_from_star_pattern() {
4210 let config = parse_str(
4212 "Host myserver\n Hostname 10.0.0.1\n\nHost *\n ProxyJump gateway\n User admin\n",
4213 );
4214 let hosts = config.host_entries();
4215 assert_eq!(hosts.len(), 1);
4216 assert_eq!(hosts[0].proxy_jump, "gateway");
4217 assert_eq!(hosts[0].user, "admin");
4218 }
4219
4220 #[test]
4221 fn host_entries_own_proxy_jump_takes_precedence() {
4222 let config = parse_str(
4224 "Host web-*\n ProxyJump gateway\n\nHost web-prod\n Hostname 10.0.0.1\n ProxyJump bastion\n",
4225 );
4226 let hosts = config.host_entries();
4227 assert_eq!(hosts.len(), 1);
4228 assert_eq!(hosts[0].proxy_jump, "bastion"); }
4230
4231 #[test]
4232 fn host_entries_hostname_pattern_does_not_match_by_hostname() {
4233 let config = parse_str(
4236 "Host 10.30.0.*\n ProxyJump bastion\n User debian\n\nHost myserver\n Hostname 10.30.0.5\n",
4237 );
4238 let hosts = config.host_entries();
4239 assert_eq!(hosts.len(), 1);
4240 assert_eq!(hosts[0].alias, "myserver");
4241 assert_eq!(hosts[0].proxy_jump, ""); assert_eq!(hosts[0].user, ""); }
4244
4245 #[test]
4246 fn host_entries_first_match_wins() {
4247 let config = parse_str(
4249 "Host web-*\n User team\n\nHost *\n User fallback\n\nHost web-prod\n Hostname 10.0.0.1\n",
4250 );
4251 let hosts = config.host_entries();
4252 assert_eq!(hosts.len(), 1);
4253 assert_eq!(hosts[0].user, "team"); }
4255
4256 #[test]
4257 fn host_entries_no_inheritance_when_all_set() {
4258 let config = parse_str(
4260 "Host *\n User fallback\n ProxyJump gw\n IdentityFile ~/.ssh/other\n\n\
4261 Host myserver\n Hostname 10.0.0.1\n User root\n ProxyJump bastion\n IdentityFile ~/.ssh/mine\n",
4262 );
4263 let hosts = config.host_entries();
4264 assert_eq!(hosts.len(), 1);
4265 assert_eq!(hosts[0].user, "root");
4266 assert_eq!(hosts[0].proxy_jump, "bastion");
4267 assert_eq!(hosts[0].identity_file, "~/.ssh/mine");
4268 }
4269
4270 #[test]
4271 fn host_entries_negation_excludes_from_inheritance() {
4272 let config = parse_str(
4274 "Host * !bastion\n ProxyJump gateway\n\nHost bastion\n Hostname 10.0.0.1\n",
4275 );
4276 let hosts = config.host_entries();
4277 assert_eq!(hosts.len(), 1);
4278 assert_eq!(hosts[0].alias, "bastion");
4279 assert_eq!(hosts[0].proxy_jump, ""); }
4281
4282 #[test]
4283 fn host_entries_inherit_identity_file_from_pattern() {
4284 let config = parse_str(
4286 "Host *\n IdentityFile ~/.ssh/default_key\n\nHost myserver\n Hostname 10.0.0.1\n",
4287 );
4288 let hosts = config.host_entries();
4289 assert_eq!(hosts.len(), 1);
4290 assert_eq!(hosts[0].identity_file, "~/.ssh/default_key");
4291 }
4292
4293 #[test]
4294 fn host_entries_multiple_hosts_mixed_inheritance() {
4295 let config = parse_str(
4297 "Host web-*\n ProxyJump bastion\n\n\
4298 Host web-prod\n Hostname 10.0.0.1\n\n\
4299 Host web-staging\n Hostname 10.0.0.2\n ProxyJump gateway\n\n\
4300 Host bastion\n Hostname 10.0.0.99\n",
4301 );
4302 let hosts = config.host_entries();
4303 assert_eq!(hosts.len(), 3);
4304 let prod = hosts.iter().find(|h| h.alias == "web-prod").unwrap();
4305 let staging = hosts.iter().find(|h| h.alias == "web-staging").unwrap();
4306 let bastion = hosts.iter().find(|h| h.alias == "bastion").unwrap();
4307 assert_eq!(prod.proxy_jump, "bastion"); assert_eq!(staging.proxy_jump, "gateway"); assert_eq!(bastion.proxy_jump, ""); }
4311
4312 #[test]
4313 fn host_entries_partial_inheritance() {
4314 let config = parse_str(
4316 "Host *\n User fallback\n ProxyJump gw\n IdentityFile ~/.ssh/default\n\n\
4317 Host myserver\n Hostname 10.0.0.1\n User root\n ProxyJump bastion\n",
4318 );
4319 let hosts = config.host_entries();
4320 assert_eq!(hosts.len(), 1);
4321 assert_eq!(hosts[0].user, "root"); assert_eq!(hosts[0].proxy_jump, "bastion"); assert_eq!(hosts[0].identity_file, "~/.ssh/default"); }
4325
4326 #[test]
4327 fn host_entries_alias_is_ip_matches_ip_pattern() {
4328 let config =
4330 parse_str("Host 10.0.0.*\n ProxyJump bastion\n\nHost 10.0.0.5\n User root\n");
4331 let hosts = config.host_entries();
4332 assert_eq!(hosts.len(), 1);
4333 assert_eq!(hosts[0].alias, "10.0.0.5");
4334 assert_eq!(hosts[0].proxy_jump, "bastion");
4335 }
4336
4337 #[test]
4338 fn host_entries_no_hostname_still_inherits_by_alias() {
4339 let config = parse_str("Host *\n User admin\n\nHost myserver\n Port 2222\n");
4341 let hosts = config.host_entries();
4342 assert_eq!(hosts.len(), 1);
4343 assert_eq!(hosts[0].user, "admin"); assert!(hosts[0].hostname.is_empty()); }
4346
4347 #[test]
4348 fn host_entries_self_referencing_proxy_jump_assigned() {
4349 let config = parse_str(
4352 "Host *\n ProxyJump gateway\n\n\
4353 Host gateway\n Hostname 10.0.0.1\n\n\
4354 Host backend\n Hostname 10.0.0.2\n",
4355 );
4356 let hosts = config.host_entries();
4357 let gateway = hosts.iter().find(|h| h.alias == "gateway").unwrap();
4358 let backend = hosts.iter().find(|h| h.alias == "backend").unwrap();
4359 assert_eq!(gateway.proxy_jump, "gateway"); assert_eq!(backend.proxy_jump, "gateway");
4361 assert!(proxy_jump_contains_self(
4363 &gateway.proxy_jump,
4364 &gateway.alias
4365 ));
4366 assert!(!proxy_jump_contains_self(
4367 &backend.proxy_jump,
4368 &backend.alias
4369 ));
4370 }
4371
4372 #[test]
4373 fn proxy_jump_contains_self_comma_separated() {
4374 assert!(proxy_jump_contains_self("hop1,gateway", "gateway"));
4375 assert!(proxy_jump_contains_self("gateway,hop2", "gateway"));
4376 assert!(proxy_jump_contains_self("hop1, gateway", "gateway"));
4377 assert!(proxy_jump_contains_self("gateway", "gateway"));
4378 assert!(!proxy_jump_contains_self("hop1,hop2", "gateway"));
4379 assert!(!proxy_jump_contains_self("", "gateway"));
4380 assert!(!proxy_jump_contains_self("gateway-2", "gateway"));
4381 assert!(proxy_jump_contains_self("admin@gateway", "gateway"));
4383 assert!(proxy_jump_contains_self("gateway:2222", "gateway"));
4384 assert!(proxy_jump_contains_self("admin@gateway:2222", "gateway"));
4385 assert!(proxy_jump_contains_self(
4386 "hop1,admin@gateway:2222",
4387 "gateway"
4388 ));
4389 assert!(!proxy_jump_contains_self("admin@gateway-2", "gateway"));
4390 assert!(!proxy_jump_contains_self("admin@other:2222", "gateway"));
4391 assert!(proxy_jump_contains_self("[::1]:2222", "::1"));
4393 assert!(proxy_jump_contains_self("user@[::1]:2222", "::1"));
4394 assert!(!proxy_jump_contains_self("[::2]:2222", "::1"));
4395 assert!(proxy_jump_contains_self("hop1,[::1]:2222", "::1"));
4396 }
4397
4398 #[test]
4403 fn raw_host_entry_returns_without_inheritance() {
4404 let config = parse_str(
4405 "Host *\n ProxyJump gw\n User admin\n\nHost myserver\n Hostname 10.0.0.1\n",
4406 );
4407 let raw = config.raw_host_entry("myserver").unwrap();
4408 assert_eq!(raw.alias, "myserver");
4409 assert_eq!(raw.hostname, "10.0.0.1");
4410 assert_eq!(raw.proxy_jump, ""); assert_eq!(raw.user, ""); let enriched = config.host_entries();
4414 assert_eq!(enriched[0].proxy_jump, "gw");
4415 assert_eq!(enriched[0].user, "admin");
4416 }
4417
4418 #[test]
4419 fn raw_host_entry_preserves_own_values() {
4420 let config = parse_str(
4421 "Host *\n ProxyJump gw\n\nHost myserver\n Hostname 10.0.0.1\n ProxyJump bastion\n",
4422 );
4423 let raw = config.raw_host_entry("myserver").unwrap();
4424 assert_eq!(raw.proxy_jump, "bastion"); }
4426
4427 #[test]
4428 fn raw_host_entry_returns_none_for_missing() {
4429 let config = parse_str("Host myserver\n Hostname 10.0.0.1\n");
4430 assert!(config.raw_host_entry("nonexistent").is_none());
4431 }
4432
4433 #[test]
4434 fn raw_host_entry_returns_none_for_pattern() {
4435 let config = parse_str("Host 10.30.0.*\n ProxyJump bastion\n");
4436 assert!(config.raw_host_entry("10.30.0.*").is_none());
4437 }
4438
4439 #[test]
4444 fn inherited_hints_returns_value_and_source() {
4445 let config = parse_str(
4446 "Host web-*\n ProxyJump bastion\n User team\n\nHost web-prod\n Hostname 10.0.0.1\n",
4447 );
4448 let hints = config.inherited_hints("web-prod");
4449 let (val, src) = hints.proxy_jump.unwrap();
4450 assert_eq!(val, "bastion");
4451 assert_eq!(src, "web-*");
4452 let (val, src) = hints.user.unwrap();
4453 assert_eq!(val, "team");
4454 assert_eq!(src, "web-*");
4455 assert!(hints.identity_file.is_none());
4456 }
4457
4458 #[test]
4459 fn inherited_hints_first_match_wins_with_source() {
4460 let config = parse_str(
4461 "Host web-*\n User team\n\nHost *\n User fallback\n ProxyJump gw\n\nHost web-prod\n Hostname 10.0.0.1\n",
4462 );
4463 let hints = config.inherited_hints("web-prod");
4464 let (val, src) = hints.user.unwrap();
4466 assert_eq!(val, "team");
4467 assert_eq!(src, "web-*");
4468 let (val, src) = hints.proxy_jump.unwrap();
4470 assert_eq!(val, "gw");
4471 assert_eq!(src, "*");
4472 }
4473
4474 #[test]
4475 fn inherited_hints_no_match_returns_default() {
4476 let config =
4477 parse_str("Host web-*\n ProxyJump bastion\n\nHost myserver\n Hostname 10.0.0.1\n");
4478 let hints = config.inherited_hints("myserver");
4479 assert!(hints.proxy_jump.is_none());
4481 assert!(hints.user.is_none());
4482 assert!(hints.identity_file.is_none());
4483 }
4484
4485 #[test]
4486 fn inherited_hints_partial_fields_from_different_patterns() {
4487 let config = parse_str(
4488 "Host web-*\n ProxyJump bastion\n\nHost *\n IdentityFile ~/.ssh/default\n\nHost web-prod\n Hostname 10.0.0.1\n",
4489 );
4490 let hints = config.inherited_hints("web-prod");
4491 let (val, src) = hints.proxy_jump.unwrap();
4492 assert_eq!(val, "bastion");
4493 assert_eq!(src, "web-*");
4494 let (val, src) = hints.identity_file.unwrap();
4495 assert_eq!(val, "~/.ssh/default");
4496 assert_eq!(src, "*");
4497 assert!(hints.user.is_none());
4498 }
4499
4500 #[test]
4501 fn inherited_hints_negation_excludes() {
4502 let config = parse_str(
4504 "Host * !bastion\n ProxyJump gateway\n User admin\n\n\
4505 Host bastion\n Hostname 10.0.0.1\n",
4506 );
4507 let hints = config.inherited_hints("bastion");
4508 assert!(hints.proxy_jump.is_none());
4509 assert!(hints.user.is_none());
4510 }
4511
4512 #[test]
4513 fn inherited_hints_returned_even_when_host_has_own_values() {
4514 let config = parse_str(
4517 "Host *\n ProxyJump gateway\n User admin\n\n\
4518 Host myserver\n Hostname 10.0.0.1\n ProxyJump bastion\n User root\n",
4519 );
4520 let hints = config.inherited_hints("myserver");
4521 let (val, _) = hints.proxy_jump.unwrap();
4523 assert_eq!(val, "gateway");
4524 let (val, _) = hints.user.unwrap();
4525 assert_eq!(val, "admin");
4526 }
4527
4528 #[test]
4529 fn inheritance_across_include_boundary() {
4530 let included_elements =
4532 SshConfigFile::parse_content("Host web-*\n ProxyJump bastion\n User team\n");
4533 let main_elements = vec![
4534 ConfigElement::Include(IncludeDirective {
4535 raw_line: "Include conf.d/*".to_string(),
4536 pattern: "conf.d/*".to_string(),
4537 resolved_files: vec![IncludedFile {
4538 path: PathBuf::from("/etc/ssh/conf.d/patterns.conf"),
4539 elements: included_elements,
4540 }],
4541 }),
4542 ConfigElement::HostBlock(HostBlock {
4544 host_pattern: "web-prod".to_string(),
4545 raw_host_line: "Host web-prod".to_string(),
4546 directives: vec![Directive {
4547 key: "HostName".to_string(),
4548 value: "10.0.0.1".to_string(),
4549 raw_line: " HostName 10.0.0.1".to_string(),
4550 is_non_directive: false,
4551 }],
4552 }),
4553 ];
4554 let config = SshConfigFile {
4555 elements: main_elements,
4556 path: PathBuf::from("/tmp/test_config"),
4557 crlf: false,
4558 bom: false,
4559 };
4560 let hosts = config.host_entries();
4562 assert_eq!(hosts.len(), 1);
4563 assert_eq!(hosts[0].alias, "web-prod");
4564 assert_eq!(hosts[0].proxy_jump, "bastion");
4565 assert_eq!(hosts[0].user, "team");
4566 let hints = config.inherited_hints("web-prod");
4568 let (val, src) = hints.proxy_jump.unwrap();
4569 assert_eq!(val, "bastion");
4570 assert_eq!(src, "web-*");
4571 }
4572
4573 #[test]
4574 fn inheritance_host_in_include_pattern_in_main() {
4575 let included_elements =
4577 SshConfigFile::parse_content("Host web-prod\n HostName 10.0.0.1\n");
4578 let mut main_elements = SshConfigFile::parse_content("Host web-*\n ProxyJump bastion\n");
4579 main_elements.push(ConfigElement::Include(IncludeDirective {
4580 raw_line: "Include conf.d/*".to_string(),
4581 pattern: "conf.d/*".to_string(),
4582 resolved_files: vec![IncludedFile {
4583 path: PathBuf::from("/etc/ssh/conf.d/hosts.conf"),
4584 elements: included_elements,
4585 }],
4586 }));
4587 let config = SshConfigFile {
4588 elements: main_elements,
4589 path: PathBuf::from("/tmp/test_config"),
4590 crlf: false,
4591 bom: false,
4592 };
4593 let hosts = config.host_entries();
4594 assert_eq!(hosts.len(), 1);
4595 assert_eq!(hosts[0].alias, "web-prod");
4596 assert_eq!(hosts[0].proxy_jump, "bastion");
4597 }
4598
4599 #[test]
4600 fn matching_patterns_full_ssh_semantics() {
4601 let config = parse_str(
4602 "Host 10.30.0.*\n User debian\n IdentityFile ~/.ssh/id_bootstrap\n ProxyJump bastion\n\n\
4603 Host *.internal !secret.internal\n ForwardAgent yes\n\n\
4604 Host myserver\n Hostname 10.30.0.5\n\n\
4605 Host *\n ServerAliveInterval 60\n",
4606 );
4607 let matches = config.matching_patterns("myserver");
4609 assert_eq!(matches.len(), 1);
4610 assert_eq!(matches[0].pattern, "*");
4611 assert!(
4612 matches[0]
4613 .directives
4614 .iter()
4615 .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
4616 );
4617 }
4618
4619 #[test]
4620 fn pattern_entries_preserve_all_directives() {
4621 let config = parse_str(
4622 "Host *.example.com\n User admin\n Port 2222\n IdentityFile ~/.ssh/id_example\n ProxyJump gateway\n ServerAliveInterval 30\n ForwardAgent yes\n",
4623 );
4624 let patterns = config.pattern_entries();
4625 assert_eq!(patterns.len(), 1);
4626 let p = &patterns[0];
4627 assert_eq!(p.pattern, "*.example.com");
4628 assert_eq!(p.user, "admin");
4629 assert_eq!(p.port, 2222);
4630 assert_eq!(p.identity_file, "~/.ssh/id_example");
4631 assert_eq!(p.proxy_jump, "gateway");
4632 assert_eq!(p.directives.len(), 6);
4634 assert!(
4635 p.directives
4636 .iter()
4637 .any(|(k, v)| k == "ForwardAgent" && v == "yes")
4638 );
4639 assert!(
4640 p.directives
4641 .iter()
4642 .any(|(k, v)| k == "ServerAliveInterval" && v == "30")
4643 );
4644 }
4645
4646 #[test]
4649 fn roundtrip_pattern_blocks_preserved() {
4650 let input = "Host myserver\n Hostname 10.0.0.1\n User root\n\nHost 10.30.0.*\n User debian\n IdentityFile ~/.ssh/id_bootstrap\n ProxyJump bastion\n\nHost *\n ServerAliveInterval 60\n AddKeysToAgent yes\n";
4651 let config = parse_str(input);
4652 let output = config.serialize();
4653 assert_eq!(
4654 input, output,
4655 "Pattern blocks must survive round-trip exactly"
4656 );
4657 }
4658
4659 #[test]
4660 fn add_pattern_preserves_existing_config() {
4661 let input = "Host myserver\n Hostname 10.0.0.1\n\nHost otherserver\n Hostname 10.0.0.2\n\nHost *\n ServerAliveInterval 60\n";
4662 let mut config = parse_str(input);
4663 let entry = HostEntry {
4664 alias: "10.30.0.*".to_string(),
4665 user: "debian".to_string(),
4666 ..Default::default()
4667 };
4668 config.add_host(&entry);
4669 let output = config.serialize();
4670 assert!(output.contains("Host myserver"));
4672 assert!(output.contains("Hostname 10.0.0.1"));
4673 assert!(output.contains("Host otherserver"));
4674 assert!(output.contains("Hostname 10.0.0.2"));
4675 assert!(output.contains("Host 10.30.0.*"));
4677 assert!(output.contains("User debian"));
4678 assert!(output.contains("Host *"));
4680 let new_pos = output.find("Host 10.30.0.*").unwrap();
4682 let star_pos = output.find("Host *").unwrap();
4683 assert!(new_pos < star_pos, "New pattern must be before Host *");
4684 let reparsed = parse_str(&output);
4686 assert_eq!(reparsed.host_entries().len(), 2);
4687 assert_eq!(reparsed.pattern_entries().len(), 2); }
4689
4690 #[test]
4691 fn update_pattern_preserves_other_blocks() {
4692 let input = "Host myserver\n Hostname 10.0.0.1\n\nHost 10.30.0.*\n User debian\n\nHost *\n ServerAliveInterval 60\n";
4693 let mut config = parse_str(input);
4694 let updated = HostEntry {
4695 alias: "10.30.0.*".to_string(),
4696 user: "admin".to_string(),
4697 ..Default::default()
4698 };
4699 config.update_host("10.30.0.*", &updated);
4700 let output = config.serialize();
4701 assert!(output.contains("User admin"));
4703 assert!(!output.contains("User debian"));
4704 assert!(output.contains("Host myserver"));
4706 assert!(output.contains("Hostname 10.0.0.1"));
4707 assert!(output.contains("Host *"));
4708 assert!(output.contains("ServerAliveInterval 60"));
4709 }
4710
4711 #[test]
4712 fn delete_pattern_preserves_other_blocks() {
4713 let input = "Host myserver\n Hostname 10.0.0.1\n\nHost 10.30.0.*\n User debian\n\nHost *\n ServerAliveInterval 60\n";
4714 let mut config = parse_str(input);
4715 config.delete_host("10.30.0.*");
4716 let output = config.serialize();
4717 assert!(!output.contains("Host 10.30.0.*"));
4718 assert!(!output.contains("User debian"));
4719 assert!(output.contains("Host myserver"));
4720 assert!(output.contains("Hostname 10.0.0.1"));
4721 assert!(output.contains("Host *"));
4722 assert!(output.contains("ServerAliveInterval 60"));
4723 let reparsed = parse_str(&output);
4724 assert_eq!(reparsed.host_entries().len(), 1);
4725 assert_eq!(reparsed.pattern_entries().len(), 1); }
4727
4728 #[test]
4729 fn update_pattern_rename() {
4730 let input = "Host *.example.com\n User admin\n\nHost myserver\n Hostname 10.0.0.1\n";
4731 let mut config = parse_str(input);
4732 let renamed = HostEntry {
4733 alias: "*.prod.example.com".to_string(),
4734 user: "admin".to_string(),
4735 ..Default::default()
4736 };
4737 config.update_host("*.example.com", &renamed);
4738 let output = config.serialize();
4739 assert!(
4740 !output.contains("Host *.example.com\n"),
4741 "Old pattern removed"
4742 );
4743 assert!(
4744 output.contains("Host *.prod.example.com"),
4745 "New pattern present"
4746 );
4747 assert!(output.contains("Host myserver"), "Other host preserved");
4748 }
4749
4750 #[test]
4751 fn config_with_only_patterns() {
4752 let input = "Host *.example.com\n User admin\n\nHost *\n ServerAliveInterval 60\n";
4753 let config = parse_str(input);
4754 assert!(config.host_entries().is_empty());
4755 assert_eq!(config.pattern_entries().len(), 2);
4756 let output = config.serialize();
4758 assert_eq!(input, output);
4759 }
4760
4761 #[test]
4762 fn host_pattern_matches_all_negative_returns_false() {
4763 assert!(!host_pattern_matches("!prod !staging", "anything"));
4764 assert!(!host_pattern_matches("!prod !staging", "dev"));
4765 }
4766
4767 #[test]
4768 fn host_pattern_matches_negation_only_checks_alias() {
4769 assert!(host_pattern_matches("* !10.0.0.1", "myserver"));
4771 assert!(!host_pattern_matches("* !myserver", "myserver"));
4772 }
4773
4774 #[test]
4775 fn pattern_match_malformed_char_class() {
4776 assert!(!ssh_pattern_match("[abc", "a"));
4778 assert!(!ssh_pattern_match("[", "a"));
4779 assert!(!ssh_pattern_match("[]", "a"));
4781 }
4782
4783 #[test]
4784 fn host_pattern_matches_whitespace_edge_cases() {
4785 assert!(host_pattern_matches("prod staging", "prod"));
4786 assert!(host_pattern_matches(" prod ", "prod"));
4787 assert!(host_pattern_matches("prod\tstaging", "prod"));
4788 assert!(!host_pattern_matches(" ", "anything"));
4789 assert!(!host_pattern_matches("", "anything"));
4790 }
4791
4792 #[test]
4793 fn pattern_with_metadata_roundtrip() {
4794 let input = "Host 10.30.0.*\n User debian\n # purple:tags internal,vpn\n # purple:askpass keychain\n\nHost myserver\n Hostname 10.0.0.1\n";
4795 let config = parse_str(input);
4796 let patterns = config.pattern_entries();
4797 assert_eq!(patterns.len(), 1);
4798 assert_eq!(patterns[0].tags, vec!["internal", "vpn"]);
4799 assert_eq!(patterns[0].askpass.as_deref(), Some("keychain"));
4800 let output = config.serialize();
4802 assert_eq!(input, output);
4803 }
4804
4805 #[test]
4806 fn matching_patterns_multiple_in_config_order() {
4807 let input = "Host my-*\n User fallback\n\nHost my-10*\n User team\n\nHost my-10-*\n User specific\n\nHost other\n Hostname 10.30.0.5\n\nHost *\n ServerAliveInterval 60\n";
4809 let config = parse_str(input);
4810 let matches = config.matching_patterns("my-10-server");
4811 assert_eq!(matches.len(), 4);
4812 assert_eq!(matches[0].pattern, "my-*");
4813 assert_eq!(matches[1].pattern, "my-10*");
4814 assert_eq!(matches[2].pattern, "my-10-*");
4815 assert_eq!(matches[3].pattern, "*");
4816 }
4817
4818 #[test]
4819 fn add_pattern_to_empty_config() {
4820 let mut config = parse_str("");
4821 let entry = HostEntry {
4822 alias: "*.example.com".to_string(),
4823 user: "admin".to_string(),
4824 ..Default::default()
4825 };
4826 config.add_host(&entry);
4827 let output = config.serialize();
4828 assert!(output.contains("Host *.example.com"));
4829 assert!(output.contains("User admin"));
4830 let reparsed = parse_str(&output);
4831 assert!(reparsed.host_entries().is_empty());
4832 assert_eq!(reparsed.pattern_entries().len(), 1);
4833 }
4834}