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 vault_ssh: Option<String>,
111 pub vault_addr: Option<String>,
115 pub certificate_file: String,
117 pub provider_meta: Vec<(String, String)>,
119 pub stale: Option<u64>,
121}
122
123impl Default for HostEntry {
124 fn default() -> Self {
125 Self {
126 alias: String::new(),
127 hostname: String::new(),
128 user: String::new(),
129 port: 22,
130 identity_file: String::new(),
131 proxy_jump: String::new(),
132 source_file: None,
133 tags: Vec::new(),
134 provider_tags: Vec::new(),
135 has_provider_tags: false,
136 provider: None,
137 tunnel_count: 0,
138 askpass: None,
139 vault_ssh: None,
140 vault_addr: None,
141 certificate_file: String::new(),
142 provider_meta: Vec::new(),
143 stale: None,
144 }
145 }
146}
147
148impl HostEntry {
149 pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
154 let escaped = self.alias.replace('\'', "'\\''");
155 let default = dirs::home_dir()
156 .map(|h| h.join(".ssh/config"))
157 .unwrap_or_default();
158 if config_path == default {
159 format!("ssh -- '{}'", escaped)
160 } else {
161 let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
162 format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
163 }
164 }
165}
166
167#[derive(Debug, Clone, Default)]
169pub struct PatternEntry {
170 pub pattern: String,
171 pub hostname: String,
172 pub user: String,
173 pub port: u16,
174 pub identity_file: String,
175 pub proxy_jump: String,
176 pub tags: Vec<String>,
177 pub askpass: Option<String>,
178 pub source_file: Option<PathBuf>,
179 pub directives: Vec<(String, String)>,
181}
182
183#[derive(Debug, Clone, Default)]
186pub struct InheritedHints {
187 pub proxy_jump: Option<(String, String)>,
188 pub user: Option<(String, String)>,
189 pub identity_file: Option<(String, String)>,
190}
191
192pub fn is_host_pattern(pattern: &str) -> bool {
196 pattern.contains('*')
197 || pattern.contains('?')
198 || pattern.contains('[')
199 || pattern.starts_with('!')
200 || pattern.contains(' ')
201 || pattern.contains('\t')
202}
203
204pub fn ssh_pattern_match(pattern: &str, text: &str) -> bool {
208 if let Some(rest) = pattern.strip_prefix('!') {
209 return !match_glob(rest, text);
210 }
211 match_glob(pattern, text)
212}
213
214fn match_glob(pattern: &str, text: &str) -> bool {
217 if text.is_empty() {
218 return pattern.is_empty();
219 }
220 if pattern.is_empty() {
221 return false;
222 }
223 let pat: Vec<char> = pattern.chars().collect();
224 let txt: Vec<char> = text.chars().collect();
225 glob_match(&pat, &txt)
226}
227
228fn glob_match(pat: &[char], txt: &[char]) -> bool {
230 let mut pi = 0;
231 let mut ti = 0;
232 let mut star: Option<(usize, usize)> = None; while ti < txt.len() {
235 if pi < pat.len() && pat[pi] == '?' {
236 pi += 1;
237 ti += 1;
238 } else if pi < pat.len() && pat[pi] == '*' {
239 star = Some((pi + 1, ti));
240 pi += 1;
241 } else if pi < pat.len() && pat[pi] == '[' {
242 if let Some((matches, end)) = match_char_class(pat, pi, txt[ti]) {
243 if matches {
244 pi = end;
245 ti += 1;
246 } else if let Some((spi, sti)) = star {
247 let sti = sti + 1;
248 star = Some((spi, sti));
249 pi = spi;
250 ti = sti;
251 } else {
252 return false;
253 }
254 } else if let Some((spi, sti)) = star {
255 let sti = sti + 1;
257 star = Some((spi, sti));
258 pi = spi;
259 ti = sti;
260 } else {
261 return false;
262 }
263 } else if pi < pat.len() && pat[pi] == txt[ti] {
264 pi += 1;
265 ti += 1;
266 } else if let Some((spi, sti)) = star {
267 let sti = sti + 1;
268 star = Some((spi, sti));
269 pi = spi;
270 ti = sti;
271 } else {
272 return false;
273 }
274 }
275
276 while pi < pat.len() && pat[pi] == '*' {
277 pi += 1;
278 }
279 pi == pat.len()
280}
281
282fn match_char_class(pat: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
286 let mut i = start + 1;
287 if i >= pat.len() {
288 return None;
289 }
290
291 let negate = pat[i] == '!' || pat[i] == '^';
292 if negate {
293 i += 1;
294 }
295
296 let mut matched = false;
297 while i < pat.len() && pat[i] != ']' {
298 if i + 2 < pat.len() && pat[i + 1] == '-' && pat[i + 2] != ']' {
299 let lo = pat[i];
300 let hi = pat[i + 2];
301 if ch >= lo && ch <= hi {
302 matched = true;
303 }
304 i += 3;
305 } else {
306 matched |= pat[i] == ch;
307 i += 1;
308 }
309 }
310
311 if i >= pat.len() {
312 return None;
313 }
314
315 let result = if negate { !matched } else { matched };
316 Some((result, i + 1))
317}
318
319pub fn host_pattern_matches(host_pattern: &str, alias: &str) -> bool {
323 let patterns: Vec<&str> = host_pattern.split_whitespace().collect();
324 if patterns.is_empty() {
325 return false;
326 }
327
328 let mut any_positive_match = false;
329 for pat in &patterns {
330 if let Some(neg) = pat.strip_prefix('!') {
331 if match_glob(neg, alias) {
332 return false;
333 }
334 } else if ssh_pattern_match(pat, alias) {
335 any_positive_match = true;
336 }
337 }
338
339 any_positive_match
340}
341
342pub fn proxy_jump_contains_self(proxy_jump: &str, alias: &str) -> bool {
347 proxy_jump.split(',').any(|hop| {
348 let h = hop.trim();
349 let h = h.split_once('@').map_or(h, |(_, host)| host);
351 let h = if let Some(bracketed) = h.strip_prefix('[') {
353 bracketed.split_once(']').map_or(h, |(host, _)| host)
354 } else {
355 h.rsplit_once(':').map_or(h, |(host, _)| host)
356 };
357 h == alias
358 })
359}
360
361fn apply_first_match_fields(
365 proxy_jump: &mut String,
366 user: &mut String,
367 identity_file: &mut String,
368 p: &PatternEntry,
369) {
370 if proxy_jump.is_empty() && !p.proxy_jump.is_empty() {
371 proxy_jump.clone_from(&p.proxy_jump);
372 }
373 if user.is_empty() && !p.user.is_empty() {
374 user.clone_from(&p.user);
375 }
376 if identity_file.is_empty() && !p.identity_file.is_empty() {
377 identity_file.clone_from(&p.identity_file);
378 }
379}
380
381impl HostBlock {
382 fn content_end(&self) -> usize {
384 let mut pos = self.directives.len();
385 while pos > 0 {
386 if self.directives[pos - 1].is_non_directive
387 && self.directives[pos - 1].raw_line.trim().is_empty()
388 {
389 pos -= 1;
390 } else {
391 break;
392 }
393 }
394 pos
395 }
396
397 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
399 let end = self.content_end();
400 self.directives.drain(end..).collect()
401 }
402
403 fn ensure_trailing_blank(&mut self) {
405 self.pop_trailing_blanks();
406 self.directives.push(Directive {
407 key: String::new(),
408 value: String::new(),
409 raw_line: String::new(),
410 is_non_directive: true,
411 });
412 }
413
414 fn detect_indent(&self) -> String {
416 for d in &self.directives {
417 if !d.is_non_directive && !d.raw_line.is_empty() {
418 let trimmed = d.raw_line.trim_start();
419 let indent_len = d.raw_line.len() - trimmed.len();
420 if indent_len > 0 {
421 return d.raw_line[..indent_len].to_string();
422 }
423 }
424 }
425 " ".to_string()
426 }
427
428 pub fn tags(&self) -> Vec<String> {
430 for d in &self.directives {
431 if d.is_non_directive {
432 let trimmed = d.raw_line.trim();
433 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
434 return rest
435 .split(',')
436 .map(|t| t.trim().to_string())
437 .filter(|t| !t.is_empty())
438 .collect();
439 }
440 }
441 }
442 Vec::new()
443 }
444
445 pub fn provider_tags(&self) -> Vec<String> {
447 for d in &self.directives {
448 if d.is_non_directive {
449 let trimmed = d.raw_line.trim();
450 if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
451 return rest
452 .split(',')
453 .map(|t| t.trim().to_string())
454 .filter(|t| !t.is_empty())
455 .collect();
456 }
457 }
458 }
459 Vec::new()
460 }
461
462 pub fn has_provider_tags_comment(&self) -> bool {
465 self.directives.iter().any(|d| {
466 d.is_non_directive && {
467 let t = d.raw_line.trim();
468 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
469 }
470 })
471 }
472
473 pub fn provider(&self) -> Option<(String, String)> {
476 for d in &self.directives {
477 if d.is_non_directive {
478 let trimmed = d.raw_line.trim();
479 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
480 if let Some((name, id)) = rest.split_once(':') {
481 return Some((name.trim().to_string(), id.trim().to_string()));
482 }
483 }
484 }
485 }
486 None
487 }
488
489 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
491 let indent = self.detect_indent();
492 self.directives.retain(|d| {
493 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
494 });
495 let pos = self.content_end();
496 self.directives.insert(
497 pos,
498 Directive {
499 key: String::new(),
500 value: String::new(),
501 raw_line: format!(
502 "{}# purple:provider {}:{}",
503 indent, provider_name, server_id
504 ),
505 is_non_directive: true,
506 },
507 );
508 }
509
510 pub fn askpass(&self) -> Option<String> {
512 for d in &self.directives {
513 if d.is_non_directive {
514 let trimmed = d.raw_line.trim();
515 if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
516 let val = rest.trim();
517 if !val.is_empty() {
518 return Some(val.to_string());
519 }
520 }
521 }
522 }
523 None
524 }
525
526 pub fn vault_ssh(&self) -> Option<String> {
528 for d in &self.directives {
529 if d.is_non_directive {
530 let trimmed = d.raw_line.trim();
531 if let Some(rest) = trimmed.strip_prefix("# purple:vault-ssh ") {
532 let val = rest.trim();
533 if !val.is_empty() && crate::vault_ssh::is_valid_role(val) {
534 return Some(val.to_string());
535 }
536 }
537 }
538 }
539 None
540 }
541
542 pub fn set_vault_ssh(&mut self, role: &str) {
544 let indent = self.detect_indent();
545 self.directives.retain(|d| {
546 !(d.is_non_directive && {
547 let t = d.raw_line.trim();
548 t == "# purple:vault-ssh" || t.starts_with("# purple:vault-ssh ")
549 })
550 });
551 if !role.is_empty() {
552 let pos = self.content_end();
553 self.directives.insert(
554 pos,
555 Directive {
556 key: String::new(),
557 value: String::new(),
558 raw_line: format!("{}# purple:vault-ssh {}", indent, role),
559 is_non_directive: true,
560 },
561 );
562 }
563 }
564
565 pub fn vault_addr(&self) -> Option<String> {
571 for d in &self.directives {
572 if d.is_non_directive {
573 let trimmed = d.raw_line.trim();
574 if let Some(rest) = trimmed.strip_prefix("# purple:vault-addr ") {
575 let val = rest.trim();
576 if !val.is_empty() && crate::vault_ssh::is_valid_vault_addr(val) {
577 return Some(val.to_string());
578 }
579 }
580 }
581 }
582 None
583 }
584
585 pub fn set_vault_addr(&mut self, url: &str) {
589 let indent = self.detect_indent();
590 self.directives.retain(|d| {
591 !(d.is_non_directive && {
592 let t = d.raw_line.trim();
593 t == "# purple:vault-addr" || t.starts_with("# purple:vault-addr ")
594 })
595 });
596 if !url.is_empty() {
597 let pos = self.content_end();
598 self.directives.insert(
599 pos,
600 Directive {
601 key: String::new(),
602 value: String::new(),
603 raw_line: format!("{}# purple:vault-addr {}", indent, url),
604 is_non_directive: true,
605 },
606 );
607 }
608 }
609
610 pub fn set_askpass(&mut self, source: &str) {
613 let indent = self.detect_indent();
614 self.directives.retain(|d| {
615 !(d.is_non_directive && {
616 let t = d.raw_line.trim();
617 t == "# purple:askpass" || t.starts_with("# purple:askpass ")
618 })
619 });
620 if !source.is_empty() {
621 let pos = self.content_end();
622 self.directives.insert(
623 pos,
624 Directive {
625 key: String::new(),
626 value: String::new(),
627 raw_line: format!("{}# purple:askpass {}", indent, source),
628 is_non_directive: true,
629 },
630 );
631 }
632 }
633
634 pub fn meta(&self) -> Vec<(String, String)> {
637 for d in &self.directives {
638 if d.is_non_directive {
639 let trimmed = d.raw_line.trim();
640 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
641 return rest
642 .split(',')
643 .filter_map(|pair| {
644 let (k, v) = pair.split_once('=')?;
645 let k = k.trim();
646 let v = v.trim();
647 if k.is_empty() {
648 None
649 } else {
650 Some((k.to_string(), v.to_string()))
651 }
652 })
653 .collect();
654 }
655 }
656 }
657 Vec::new()
658 }
659
660 pub fn set_meta(&mut self, meta: &[(String, String)]) {
663 let indent = self.detect_indent();
664 self.directives.retain(|d| {
665 !(d.is_non_directive && {
666 let t = d.raw_line.trim();
667 t == "# purple:meta" || t.starts_with("# purple:meta ")
668 })
669 });
670 if !meta.is_empty() {
671 let encoded: Vec<String> = meta
672 .iter()
673 .map(|(k, v)| {
674 let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
675 let clean_v = Self::sanitize_tag(&v.replace(',', ""));
676 format!("{}={}", clean_k, clean_v)
677 })
678 .collect();
679 let pos = self.content_end();
680 self.directives.insert(
681 pos,
682 Directive {
683 key: String::new(),
684 value: String::new(),
685 raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
686 is_non_directive: true,
687 },
688 );
689 }
690 }
691
692 pub fn stale(&self) -> Option<u64> {
695 for d in &self.directives {
696 if d.is_non_directive {
697 let trimmed = d.raw_line.trim();
698 if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
699 return rest.trim().parse::<u64>().ok();
700 }
701 }
702 }
703 None
704 }
705
706 pub fn set_stale(&mut self, timestamp: u64) {
709 let indent = self.detect_indent();
710 self.clear_stale();
711 let pos = self.content_end();
712 self.directives.insert(
713 pos,
714 Directive {
715 key: String::new(),
716 value: String::new(),
717 raw_line: format!("{}# purple:stale {}", indent, timestamp),
718 is_non_directive: true,
719 },
720 );
721 }
722
723 pub fn clear_stale(&mut self) {
725 self.directives.retain(|d| {
726 !(d.is_non_directive && {
727 let t = d.raw_line.trim();
728 t == "# purple:stale" || t.starts_with("# purple:stale ")
729 })
730 });
731 }
732
733 fn sanitize_tag(tag: &str) -> String {
736 tag.chars()
737 .filter(|c| {
738 !c.is_control()
739 && *c != ','
740 && !('\u{200B}'..='\u{200F}').contains(c) && !('\u{202A}'..='\u{202E}').contains(c) && !('\u{2066}'..='\u{2069}').contains(c) && *c != '\u{FEFF}' })
745 .take(128)
746 .collect()
747 }
748
749 pub fn set_tags(&mut self, tags: &[String]) {
751 let indent = self.detect_indent();
752 self.directives.retain(|d| {
753 !(d.is_non_directive && {
754 let t = d.raw_line.trim();
755 t == "# purple:tags" || t.starts_with("# purple:tags ")
756 })
757 });
758 let sanitized: Vec<String> = tags
759 .iter()
760 .map(|t| Self::sanitize_tag(t))
761 .filter(|t| !t.is_empty())
762 .collect();
763 if !sanitized.is_empty() {
764 let pos = self.content_end();
765 self.directives.insert(
766 pos,
767 Directive {
768 key: String::new(),
769 value: String::new(),
770 raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
771 is_non_directive: true,
772 },
773 );
774 }
775 }
776
777 pub fn set_provider_tags(&mut self, tags: &[String]) {
780 let indent = self.detect_indent();
781 self.directives.retain(|d| {
782 !(d.is_non_directive && {
783 let t = d.raw_line.trim();
784 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
785 })
786 });
787 let sanitized: Vec<String> = tags
788 .iter()
789 .map(|t| Self::sanitize_tag(t))
790 .filter(|t| !t.is_empty())
791 .collect();
792 let raw = if sanitized.is_empty() {
793 format!("{}# purple:provider_tags", indent)
794 } else {
795 format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
796 };
797 let pos = self.content_end();
798 self.directives.insert(
799 pos,
800 Directive {
801 key: String::new(),
802 value: String::new(),
803 raw_line: raw,
804 is_non_directive: true,
805 },
806 );
807 }
808
809 pub fn to_host_entry(&self) -> HostEntry {
811 let mut entry = HostEntry {
812 alias: self.host_pattern.clone(),
813 port: 22,
814 ..Default::default()
815 };
816 for d in &self.directives {
817 if d.is_non_directive {
818 continue;
819 }
820 if d.key.eq_ignore_ascii_case("hostname") {
821 entry.hostname = d.value.clone();
822 } else if d.key.eq_ignore_ascii_case("user") {
823 entry.user = d.value.clone();
824 } else if d.key.eq_ignore_ascii_case("port") {
825 entry.port = d.value.parse().unwrap_or(22);
826 } else if d.key.eq_ignore_ascii_case("identityfile") {
827 if entry.identity_file.is_empty() {
828 entry.identity_file = d.value.clone();
829 }
830 } else if d.key.eq_ignore_ascii_case("proxyjump") {
831 entry.proxy_jump = d.value.clone();
832 } else if d.key.eq_ignore_ascii_case("certificatefile")
833 && entry.certificate_file.is_empty()
834 {
835 entry.certificate_file = d.value.clone();
836 }
837 }
838 entry.tags = self.tags();
839 entry.provider_tags = self.provider_tags();
840 entry.has_provider_tags = self.has_provider_tags_comment();
841 entry.provider = self.provider().map(|(name, _)| name);
842 entry.tunnel_count = self.tunnel_count();
843 entry.askpass = self.askpass();
844 entry.vault_ssh = self.vault_ssh();
845 entry.vault_addr = self.vault_addr();
846 entry.provider_meta = self.meta();
847 entry.stale = self.stale();
848 entry
849 }
850
851 pub fn to_pattern_entry(&self) -> PatternEntry {
853 let mut entry = PatternEntry {
854 pattern: self.host_pattern.clone(),
855 hostname: String::new(),
856 user: String::new(),
857 port: 22,
858 identity_file: String::new(),
859 proxy_jump: String::new(),
860 tags: self.tags(),
861 askpass: self.askpass(),
862 source_file: None,
863 directives: Vec::new(),
864 };
865 for d in &self.directives {
866 if d.is_non_directive {
867 continue;
868 }
869 match d.key.to_ascii_lowercase().as_str() {
870 "hostname" => entry.hostname = d.value.clone(),
871 "user" => entry.user = d.value.clone(),
872 "port" => entry.port = d.value.parse().unwrap_or(22),
873 "identityfile" => {
874 if entry.identity_file.is_empty() {
875 entry.identity_file = d.value.clone();
876 }
877 }
878 "proxyjump" => entry.proxy_jump = d.value.clone(),
879 _ => {}
880 }
881 entry.directives.push((d.key.clone(), d.value.clone()));
882 }
883 entry
884 }
885
886 pub fn tunnel_count(&self) -> u16 {
888 let count = self
889 .directives
890 .iter()
891 .filter(|d| {
892 !d.is_non_directive
893 && (d.key.eq_ignore_ascii_case("localforward")
894 || d.key.eq_ignore_ascii_case("remoteforward")
895 || d.key.eq_ignore_ascii_case("dynamicforward"))
896 })
897 .count();
898 count.min(u16::MAX as usize) as u16
899 }
900
901 #[allow(dead_code)]
903 pub fn has_tunnels(&self) -> bool {
904 self.directives.iter().any(|d| {
905 !d.is_non_directive
906 && (d.key.eq_ignore_ascii_case("localforward")
907 || d.key.eq_ignore_ascii_case("remoteforward")
908 || d.key.eq_ignore_ascii_case("dynamicforward"))
909 })
910 }
911
912 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
914 self.directives
915 .iter()
916 .filter(|d| !d.is_non_directive)
917 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
918 .collect()
919 }
920}
921
922impl SshConfigFile {
923 pub fn host_entries(&self) -> Vec<HostEntry> {
928 let mut entries = Vec::new();
929 Self::collect_host_entries(&self.elements, &mut entries);
930 self.apply_pattern_inheritance(&mut entries);
931 entries
932 }
933
934 pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
938 Self::find_raw_host_entry(&self.elements, alias)
939 }
940
941 fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
942 for e in elements {
943 match e {
944 ConfigElement::HostBlock(block)
945 if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
946 {
947 return Some(block.to_host_entry());
948 }
949 ConfigElement::Include(inc) => {
950 for file in &inc.resolved_files {
951 if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
952 if found.source_file.is_none() {
953 found.source_file = Some(file.path.clone());
954 }
955 return Some(found);
956 }
957 }
958 }
959 _ => {}
960 }
961 }
962 None
963 }
964
965 fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
969 let all_patterns = self.pattern_entries();
972 for entry in entries.iter_mut() {
973 if !entry.proxy_jump.is_empty()
974 && !entry.user.is_empty()
975 && !entry.identity_file.is_empty()
976 {
977 continue;
978 }
979 for p in &all_patterns {
980 if !host_pattern_matches(&p.pattern, &entry.alias) {
981 continue;
982 }
983 apply_first_match_fields(
984 &mut entry.proxy_jump,
985 &mut entry.user,
986 &mut entry.identity_file,
987 p,
988 );
989 if !entry.proxy_jump.is_empty()
990 && !entry.user.is_empty()
991 && !entry.identity_file.is_empty()
992 {
993 break;
994 }
995 }
996 }
997 }
998
999 pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
1005 let patterns = self.matching_patterns(alias);
1006 let mut hints = InheritedHints::default();
1007 for p in &patterns {
1008 if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
1009 hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
1010 }
1011 if hints.user.is_none() && !p.user.is_empty() {
1012 hints.user = Some((p.user.clone(), p.pattern.clone()));
1013 }
1014 if hints.identity_file.is_none() && !p.identity_file.is_empty() {
1015 hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
1016 }
1017 if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
1018 break;
1019 }
1020 }
1021 hints
1022 }
1023
1024 pub fn pattern_entries(&self) -> Vec<PatternEntry> {
1026 let mut entries = Vec::new();
1027 Self::collect_pattern_entries(&self.elements, &mut entries);
1028 entries
1029 }
1030
1031 fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
1032 for e in elements {
1033 match e {
1034 ConfigElement::HostBlock(block) => {
1035 if !is_host_pattern(&block.host_pattern) {
1036 continue;
1037 }
1038 entries.push(block.to_pattern_entry());
1039 }
1040 ConfigElement::Include(include) => {
1041 for file in &include.resolved_files {
1042 let start = entries.len();
1043 Self::collect_pattern_entries(&file.elements, entries);
1044 for entry in &mut entries[start..] {
1045 if entry.source_file.is_none() {
1046 entry.source_file = Some(file.path.clone());
1047 }
1048 }
1049 }
1050 }
1051 ConfigElement::GlobalLine(_) => {}
1052 }
1053 }
1054 }
1055
1056 pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
1059 let mut matches = Vec::new();
1060 Self::collect_matching_patterns(&self.elements, alias, &mut matches);
1061 matches
1062 }
1063
1064 fn collect_matching_patterns(
1065 elements: &[ConfigElement],
1066 alias: &str,
1067 matches: &mut Vec<PatternEntry>,
1068 ) {
1069 for e in elements {
1070 match e {
1071 ConfigElement::HostBlock(block) => {
1072 if !is_host_pattern(&block.host_pattern) {
1073 continue;
1074 }
1075 if host_pattern_matches(&block.host_pattern, alias) {
1076 matches.push(block.to_pattern_entry());
1077 }
1078 }
1079 ConfigElement::Include(include) => {
1080 for file in &include.resolved_files {
1081 let start = matches.len();
1082 Self::collect_matching_patterns(&file.elements, alias, matches);
1083 for entry in &mut matches[start..] {
1084 if entry.source_file.is_none() {
1085 entry.source_file = Some(file.path.clone());
1086 }
1087 }
1088 }
1089 }
1090 ConfigElement::GlobalLine(_) => {}
1091 }
1092 }
1093 }
1094
1095 pub fn include_paths(&self) -> Vec<PathBuf> {
1097 let mut paths = Vec::new();
1098 Self::collect_include_paths(&self.elements, &mut paths);
1099 paths
1100 }
1101
1102 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
1103 for e in elements {
1104 if let ConfigElement::Include(include) = e {
1105 for file in &include.resolved_files {
1106 paths.push(file.path.clone());
1107 Self::collect_include_paths(&file.elements, paths);
1108 }
1109 }
1110 }
1111 }
1112
1113 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
1116 let config_dir = self.path.parent();
1117 let mut seen = std::collections::HashSet::new();
1118 let mut dirs = Vec::new();
1119 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
1120 dirs
1121 }
1122
1123 fn collect_include_glob_dirs(
1124 elements: &[ConfigElement],
1125 config_dir: Option<&std::path::Path>,
1126 seen: &mut std::collections::HashSet<PathBuf>,
1127 dirs: &mut Vec<PathBuf>,
1128 ) {
1129 for e in elements {
1130 if let ConfigElement::Include(include) = e {
1131 for single in Self::split_include_patterns(&include.pattern) {
1133 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
1134 let resolved = if expanded.starts_with('/') {
1135 PathBuf::from(&expanded)
1136 } else if let Some(dir) = config_dir {
1137 dir.join(&expanded)
1138 } else {
1139 continue;
1140 };
1141 if let Some(parent) = resolved.parent() {
1142 let parent = parent.to_path_buf();
1143 if seen.insert(parent.clone()) {
1144 dirs.push(parent);
1145 }
1146 }
1147 }
1148 for file in &include.resolved_files {
1150 Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
1151 }
1152 }
1153 }
1154 }
1155
1156 pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
1159 let active_providers: std::collections::HashSet<String> = self
1161 .elements
1162 .iter()
1163 .filter_map(|e| {
1164 if let ConfigElement::HostBlock(block) = e {
1165 block
1166 .provider()
1167 .map(|(name, _)| provider_group_display_name(&name).to_string())
1168 } else {
1169 None
1170 }
1171 })
1172 .collect();
1173
1174 let mut removed = 0;
1175 self.elements.retain(|e| {
1176 if let ConfigElement::GlobalLine(line) = e {
1177 if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
1178 if !active_providers.contains(rest.trim()) {
1179 removed += 1;
1180 return false;
1181 }
1182 }
1183 }
1184 true
1185 });
1186 removed
1187 }
1188
1189 pub fn repair_absorbed_group_comments(&mut self) -> usize {
1193 let mut repaired = 0;
1194 let mut idx = 0;
1195 while idx < self.elements.len() {
1196 let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
1197 block
1198 .directives
1199 .iter()
1200 .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
1201 } else {
1202 false
1203 };
1204
1205 if !needs_repair {
1206 idx += 1;
1207 continue;
1208 }
1209
1210 let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
1212 block
1213 } else {
1214 unreachable!()
1215 };
1216
1217 let group_idx = block
1218 .directives
1219 .iter()
1220 .position(|d| {
1221 d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
1222 })
1223 .unwrap();
1224
1225 let mut keep_end = group_idx;
1227 while keep_end > 0
1228 && block.directives[keep_end - 1].is_non_directive
1229 && block.directives[keep_end - 1].raw_line.trim().is_empty()
1230 {
1231 keep_end -= 1;
1232 }
1233
1234 let extracted: Vec<ConfigElement> = block
1236 .directives
1237 .drain(keep_end..)
1238 .map(|d| ConfigElement::GlobalLine(d.raw_line))
1239 .collect();
1240
1241 let insert_at = idx + 1;
1243 for (i, elem) in extracted.into_iter().enumerate() {
1244 self.elements.insert(insert_at + i, elem);
1245 }
1246
1247 repaired += 1;
1248 idx = insert_at;
1250 while idx < self.elements.len() {
1252 if let ConfigElement::HostBlock(_) = &self.elements[idx] {
1253 break;
1254 }
1255 idx += 1;
1256 }
1257 }
1258 repaired
1259 }
1260
1261 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
1263 for e in elements {
1264 match e {
1265 ConfigElement::HostBlock(block) => {
1266 if is_host_pattern(&block.host_pattern) {
1267 continue;
1268 }
1269 entries.push(block.to_host_entry());
1270 }
1271 ConfigElement::Include(include) => {
1272 for file in &include.resolved_files {
1273 let start = entries.len();
1274 Self::collect_host_entries(&file.elements, entries);
1275 for entry in &mut entries[start..] {
1276 if entry.source_file.is_none() {
1277 entry.source_file = Some(file.path.clone());
1278 }
1279 }
1280 }
1281 }
1282 ConfigElement::GlobalLine(_) => {}
1283 }
1284 }
1285 }
1286
1287 pub fn has_host(&self, alias: &str) -> bool {
1290 Self::has_host_in_elements(&self.elements, alias)
1291 }
1292
1293 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
1294 for e in elements {
1295 match e {
1296 ConfigElement::HostBlock(block) => {
1297 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1298 return true;
1299 }
1300 }
1301 ConfigElement::Include(include) => {
1302 for file in &include.resolved_files {
1303 if Self::has_host_in_elements(&file.elements, alias) {
1304 return true;
1305 }
1306 }
1307 }
1308 ConfigElement::GlobalLine(_) => {}
1309 }
1310 }
1311 false
1312 }
1313
1314 pub fn has_host_block(&self, pattern: &str) -> bool {
1319 self.elements
1320 .iter()
1321 .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
1322 }
1323
1324 pub fn is_included_host(&self, alias: &str) -> bool {
1327 for e in &self.elements {
1329 match e {
1330 ConfigElement::HostBlock(block) => {
1331 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1332 return false;
1333 }
1334 }
1335 ConfigElement::Include(include) => {
1336 for file in &include.resolved_files {
1337 if Self::has_host_in_elements(&file.elements, alias) {
1338 return true;
1339 }
1340 }
1341 }
1342 ConfigElement::GlobalLine(_) => {}
1343 }
1344 }
1345 false
1346 }
1347
1348 pub fn add_host(&mut self, entry: &HostEntry) {
1353 let block = Self::entry_to_block(entry);
1354 let insert_pos = self.find_trailing_pattern_start();
1355
1356 if let Some(pos) = insert_pos {
1357 let needs_blank_before = pos > 0
1359 && !matches!(
1360 self.elements.get(pos - 1),
1361 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1362 );
1363 let mut idx = pos;
1364 if needs_blank_before {
1365 self.elements
1366 .insert(idx, ConfigElement::GlobalLine(String::new()));
1367 idx += 1;
1368 }
1369 self.elements.insert(idx, ConfigElement::HostBlock(block));
1370 let after = idx + 1;
1372 if after < self.elements.len()
1373 && !matches!(
1374 self.elements.get(after),
1375 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1376 )
1377 {
1378 self.elements
1379 .insert(after, ConfigElement::GlobalLine(String::new()));
1380 }
1381 } else {
1382 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
1384 self.elements.push(ConfigElement::GlobalLine(String::new()));
1385 }
1386 self.elements.push(ConfigElement::HostBlock(block));
1387 }
1388 }
1389
1390 fn find_trailing_pattern_start(&self) -> Option<usize> {
1395 let mut first_pattern_pos = None;
1396 for i in (0..self.elements.len()).rev() {
1397 match &self.elements[i] {
1398 ConfigElement::HostBlock(block) => {
1399 if is_host_pattern(&block.host_pattern) {
1400 first_pattern_pos = Some(i);
1401 } else {
1402 break;
1404 }
1405 }
1406 ConfigElement::GlobalLine(_) => {
1407 if first_pattern_pos.is_some() {
1409 first_pattern_pos = Some(i);
1410 }
1411 }
1412 ConfigElement::Include(_) => break,
1413 }
1414 }
1415 first_pattern_pos.filter(|&pos| pos > 0)
1417 }
1418
1419 pub fn last_element_has_trailing_blank(&self) -> bool {
1421 match self.elements.last() {
1422 Some(ConfigElement::HostBlock(block)) => block
1423 .directives
1424 .last()
1425 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
1426 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
1427 _ => false,
1428 }
1429 }
1430
1431 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
1434 for element in &mut self.elements {
1435 if let ConfigElement::HostBlock(block) = element {
1436 if block.host_pattern == old_alias {
1437 if entry.alias != block.host_pattern {
1439 block.host_pattern = entry.alias.clone();
1440 block.raw_host_line = format!("Host {}", entry.alias);
1441 }
1442
1443 Self::upsert_directive(block, "HostName", &entry.hostname);
1445 Self::upsert_directive(block, "User", &entry.user);
1446 if entry.port != 22 {
1447 Self::upsert_directive(block, "Port", &entry.port.to_string());
1448 } else {
1449 block
1451 .directives
1452 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
1453 }
1454 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
1455 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
1456 return;
1457 }
1458 }
1459 }
1460 }
1461
1462 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
1464 if value.is_empty() {
1465 block
1466 .directives
1467 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
1468 return;
1469 }
1470 let indent = block.detect_indent();
1471 for d in &mut block.directives {
1472 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
1473 if d.value != value {
1475 d.value = value.to_string();
1476 let trimmed = d.raw_line.trim_start();
1482 let after_key = &trimmed[d.key.len()..];
1483 let sep = if after_key.trim_start().starts_with('=') {
1484 let eq_pos = after_key.find('=').unwrap();
1485 let after_eq = &after_key[eq_pos + 1..];
1486 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
1487 after_key[..eq_pos + 1 + trailing_ws].to_string()
1488 } else {
1489 " ".to_string()
1490 };
1491 let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
1493 d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
1494 }
1495 return;
1496 }
1497 }
1498 let pos = block.content_end();
1500 block.directives.insert(
1501 pos,
1502 Directive {
1503 key: key.to_string(),
1504 value: value.to_string(),
1505 raw_line: format!("{}{} {}", indent, key, value),
1506 is_non_directive: false,
1507 },
1508 );
1509 }
1510
1511 fn extract_inline_comment(raw_line: &str, key: &str) -> String {
1515 let trimmed = raw_line.trim_start();
1516 if trimmed.len() <= key.len() {
1517 return String::new();
1518 }
1519 let after_key = &trimmed[key.len()..];
1521 let rest = after_key.trim_start();
1522 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
1523 let bytes = rest.as_bytes();
1525 let mut in_quote = false;
1526 for i in 0..bytes.len() {
1527 if bytes[i] == b'"' {
1528 in_quote = !in_quote;
1529 } else if !in_quote
1530 && bytes[i] == b'#'
1531 && i > 0
1532 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
1533 {
1534 let clean_end = rest[..i].trim_end().len();
1536 return rest[clean_end..].to_string();
1537 }
1538 }
1539 String::new()
1540 }
1541
1542 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
1544 for element in &mut self.elements {
1545 if let ConfigElement::HostBlock(block) = element {
1546 if block.host_pattern == alias {
1547 block.set_provider(provider_name, server_id);
1548 return;
1549 }
1550 }
1551 }
1552 }
1553
1554 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
1558 let mut results = Vec::new();
1559 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1560 results
1561 }
1562
1563 fn collect_provider_hosts(
1564 elements: &[ConfigElement],
1565 provider_name: &str,
1566 results: &mut Vec<(String, String)>,
1567 ) {
1568 for element in elements {
1569 match element {
1570 ConfigElement::HostBlock(block) => {
1571 if let Some((name, id)) = block.provider() {
1572 if name == provider_name {
1573 results.push((block.host_pattern.clone(), id));
1574 }
1575 }
1576 }
1577 ConfigElement::Include(include) => {
1578 for file in &include.resolved_files {
1579 Self::collect_provider_hosts(&file.elements, provider_name, results);
1580 }
1581 }
1582 ConfigElement::GlobalLine(_) => {}
1583 }
1584 }
1585 }
1586
1587 fn values_match(a: &str, b: &str) -> bool {
1590 a.split_whitespace().eq(b.split_whitespace())
1591 }
1592
1593 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1597 for element in &mut self.elements {
1598 if let ConfigElement::HostBlock(block) = element {
1599 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1600 let indent = block.detect_indent();
1601 let pos = block.content_end();
1602 block.directives.insert(
1603 pos,
1604 Directive {
1605 key: directive_key.to_string(),
1606 value: value.to_string(),
1607 raw_line: format!("{}{} {}", indent, directive_key, value),
1608 is_non_directive: false,
1609 },
1610 );
1611 return;
1612 }
1613 }
1614 }
1615 }
1616
1617 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1622 for element in &mut self.elements {
1623 if let ConfigElement::HostBlock(block) = element {
1624 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1625 if let Some(pos) = block.directives.iter().position(|d| {
1626 !d.is_non_directive
1627 && d.key.eq_ignore_ascii_case(directive_key)
1628 && Self::values_match(&d.value, value)
1629 }) {
1630 block.directives.remove(pos);
1631 return true;
1632 }
1633 return false;
1634 }
1635 }
1636 }
1637 false
1638 }
1639
1640 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1643 for element in &self.elements {
1644 if let ConfigElement::HostBlock(block) = element {
1645 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1646 return block.directives.iter().any(|d| {
1647 !d.is_non_directive
1648 && d.key.eq_ignore_ascii_case(directive_key)
1649 && Self::values_match(&d.value, value)
1650 });
1651 }
1652 }
1653 }
1654 false
1655 }
1656
1657 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1661 Self::find_tunnel_directives_in(&self.elements, alias)
1662 }
1663
1664 fn find_tunnel_directives_in(
1665 elements: &[ConfigElement],
1666 alias: &str,
1667 ) -> Vec<crate::tunnel::TunnelRule> {
1668 for element in elements {
1669 match element {
1670 ConfigElement::HostBlock(block) => {
1671 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1672 return block.tunnel_directives();
1673 }
1674 }
1675 ConfigElement::Include(include) => {
1676 for file in &include.resolved_files {
1677 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1678 if !rules.is_empty() {
1679 return rules;
1680 }
1681 }
1682 }
1683 ConfigElement::GlobalLine(_) => {}
1684 }
1685 }
1686 Vec::new()
1687 }
1688
1689 pub fn deduplicate_alias(&self, base: &str) -> String {
1691 self.deduplicate_alias_excluding(base, None)
1692 }
1693
1694 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1697 let is_taken = |alias: &str| {
1698 if exclude == Some(alias) {
1699 return false;
1700 }
1701 self.has_host(alias)
1702 };
1703 if !is_taken(base) {
1704 return base.to_string();
1705 }
1706 for n in 2..=9999 {
1707 let candidate = format!("{}-{}", base, n);
1708 if !is_taken(&candidate) {
1709 return candidate;
1710 }
1711 }
1712 format!("{}-{}", base, std::process::id())
1714 }
1715
1716 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1718 for element in &mut self.elements {
1719 if let ConfigElement::HostBlock(block) = element {
1720 if block.host_pattern == alias {
1721 block.set_tags(tags);
1722 return;
1723 }
1724 }
1725 }
1726 }
1727
1728 pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1730 for element in &mut self.elements {
1731 if let ConfigElement::HostBlock(block) = element {
1732 if block.host_pattern == alias {
1733 block.set_provider_tags(tags);
1734 return;
1735 }
1736 }
1737 }
1738 }
1739
1740 pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1742 for element in &mut self.elements {
1743 if let ConfigElement::HostBlock(block) = element {
1744 if block.host_pattern == alias {
1745 block.set_askpass(source);
1746 return;
1747 }
1748 }
1749 }
1750 }
1751
1752 pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) {
1754 for element in &mut self.elements {
1755 if let ConfigElement::HostBlock(block) = element {
1756 if block.host_pattern == alias {
1757 block.set_vault_ssh(role);
1758 return;
1759 }
1760 }
1761 }
1762 }
1763
1764 #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1779 pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
1780 if alias.is_empty() || is_host_pattern(alias) {
1784 return false;
1785 }
1786 for element in &mut self.elements {
1787 if let ConfigElement::HostBlock(block) = element {
1788 if block.host_pattern == alias {
1789 block.set_vault_addr(url);
1790 return true;
1791 }
1792 }
1793 }
1794 false
1795 }
1796
1797 #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1808 pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
1809 if alias.is_empty() || is_host_pattern(alias) {
1817 return false;
1818 }
1819 for element in &mut self.elements {
1820 if let ConfigElement::HostBlock(block) = element {
1821 if block.host_pattern == alias {
1822 Self::upsert_directive(block, "CertificateFile", path);
1823 return true;
1824 }
1825 }
1826 }
1827 false
1828 }
1829
1830 pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1832 for element in &mut self.elements {
1833 if let ConfigElement::HostBlock(block) = element {
1834 if block.host_pattern == alias {
1835 block.set_meta(meta);
1836 return;
1837 }
1838 }
1839 }
1840 }
1841
1842 pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1844 for element in &mut self.elements {
1845 if let ConfigElement::HostBlock(block) = element {
1846 if block.host_pattern == alias {
1847 block.set_stale(timestamp);
1848 return;
1849 }
1850 }
1851 }
1852 }
1853
1854 pub fn clear_host_stale(&mut self, alias: &str) {
1856 for element in &mut self.elements {
1857 if let ConfigElement::HostBlock(block) = element {
1858 if block.host_pattern == alias {
1859 block.clear_stale();
1860 return;
1861 }
1862 }
1863 }
1864 }
1865
1866 pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1868 let mut result = Vec::new();
1869 for element in &self.elements {
1870 if let ConfigElement::HostBlock(block) = element {
1871 if let Some(ts) = block.stale() {
1872 result.push((block.host_pattern.clone(), ts));
1873 }
1874 }
1875 }
1876 result
1877 }
1878
1879 #[allow(dead_code)]
1881 pub fn delete_host(&mut self, alias: &str) {
1882 let provider_name = self.elements.iter().find_map(|e| {
1885 if let ConfigElement::HostBlock(b) = e {
1886 if b.host_pattern == alias {
1887 return b.provider().map(|(name, _)| name);
1888 }
1889 }
1890 None
1891 });
1892
1893 self.elements.retain(|e| match e {
1894 ConfigElement::HostBlock(block) => block.host_pattern != alias,
1895 _ => true,
1896 });
1897
1898 if let Some(name) = provider_name {
1900 self.remove_orphaned_group_header(&name);
1901 }
1902
1903 self.elements.dedup_by(|a, b| {
1905 matches!(
1906 (&*a, &*b),
1907 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1908 if x.trim().is_empty() && y.trim().is_empty()
1909 )
1910 });
1911 }
1912
1913 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1918 let pos = self
1919 .elements
1920 .iter()
1921 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
1922 let element = self.elements.remove(pos);
1923 Some((element, pos))
1924 }
1925
1926 #[allow(dead_code)]
1928 fn find_group_header_position(&self, provider_name: &str) -> Option<usize> {
1929 let display = provider_group_display_name(provider_name);
1930 let header = format!("# purple:group {}", display);
1931 self.elements
1932 .iter()
1933 .position(|e| matches!(e, ConfigElement::GlobalLine(line) if *line == header))
1934 }
1935
1936 fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1939 if self.find_hosts_by_provider(provider_name).is_empty() {
1940 let display = provider_group_display_name(provider_name);
1941 let header = format!("# purple:group {}", display);
1942 self.elements
1943 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1944 }
1945 }
1946
1947 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1949 let pos = position.min(self.elements.len());
1950 self.elements.insert(pos, element);
1951 }
1952
1953 pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1957 let mut last_pos = None;
1958 for (i, element) in self.elements.iter().enumerate() {
1959 if let ConfigElement::HostBlock(block) = element {
1960 if let Some((name, _)) = block.provider() {
1961 if name == provider_name {
1962 last_pos = Some(i);
1963 }
1964 }
1965 }
1966 }
1967 last_pos.map(|p| p + 1)
1969 }
1970
1971 #[allow(dead_code)]
1973 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1974 let pos_a = self
1975 .elements
1976 .iter()
1977 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1978 let pos_b = self
1979 .elements
1980 .iter()
1981 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1982 if let (Some(a), Some(b)) = (pos_a, pos_b) {
1983 if a == b {
1984 return false;
1985 }
1986 let (first, second) = (a.min(b), a.max(b));
1987
1988 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1990 block.pop_trailing_blanks();
1991 }
1992 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1993 block.pop_trailing_blanks();
1994 }
1995
1996 self.elements.swap(first, second);
1998
1999 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
2001 block.ensure_trailing_blank();
2002 }
2003
2004 if second < self.elements.len() - 1 {
2006 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
2007 block.ensure_trailing_blank();
2008 }
2009 }
2010
2011 return true;
2012 }
2013 false
2014 }
2015
2016 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
2018 debug_assert!(
2021 !entry.alias.contains('\n') && !entry.alias.contains('\r'),
2022 "entry_to_block: alias contains newline"
2023 );
2024 debug_assert!(
2025 !entry.hostname.contains('\n') && !entry.hostname.contains('\r'),
2026 "entry_to_block: hostname contains newline"
2027 );
2028 debug_assert!(
2029 !entry.user.contains('\n') && !entry.user.contains('\r'),
2030 "entry_to_block: user contains newline"
2031 );
2032
2033 let mut directives = Vec::new();
2034
2035 if !entry.hostname.is_empty() {
2036 directives.push(Directive {
2037 key: "HostName".to_string(),
2038 value: entry.hostname.clone(),
2039 raw_line: format!(" HostName {}", entry.hostname),
2040 is_non_directive: false,
2041 });
2042 }
2043 if !entry.user.is_empty() {
2044 directives.push(Directive {
2045 key: "User".to_string(),
2046 value: entry.user.clone(),
2047 raw_line: format!(" User {}", entry.user),
2048 is_non_directive: false,
2049 });
2050 }
2051 if entry.port != 22 {
2052 directives.push(Directive {
2053 key: "Port".to_string(),
2054 value: entry.port.to_string(),
2055 raw_line: format!(" Port {}", entry.port),
2056 is_non_directive: false,
2057 });
2058 }
2059 if !entry.identity_file.is_empty() {
2060 directives.push(Directive {
2061 key: "IdentityFile".to_string(),
2062 value: entry.identity_file.clone(),
2063 raw_line: format!(" IdentityFile {}", entry.identity_file),
2064 is_non_directive: false,
2065 });
2066 }
2067 if !entry.proxy_jump.is_empty() {
2068 directives.push(Directive {
2069 key: "ProxyJump".to_string(),
2070 value: entry.proxy_jump.clone(),
2071 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
2072 is_non_directive: false,
2073 });
2074 }
2075
2076 HostBlock {
2077 host_pattern: entry.alias.clone(),
2078 raw_host_line: format!("Host {}", entry.alias),
2079 directives,
2080 }
2081 }
2082}
2083
2084#[cfg(test)]
2085mod tests {
2086 use super::*;
2087
2088 fn parse_str(content: &str) -> SshConfigFile {
2089 SshConfigFile {
2090 elements: SshConfigFile::parse_content(content),
2091 path: PathBuf::from("/tmp/test_config"),
2092 crlf: false,
2093 bom: false,
2094 }
2095 }
2096
2097 #[test]
2098 fn tunnel_directives_extracts_forwards() {
2099 let config = parse_str(
2100 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
2101 );
2102 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2103 let rules = block.tunnel_directives();
2104 assert_eq!(rules.len(), 3);
2105 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
2106 assert_eq!(rules[0].bind_port, 8080);
2107 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
2108 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
2109 } else {
2110 panic!("Expected HostBlock");
2111 }
2112 }
2113
2114 #[test]
2115 fn tunnel_count_counts_forwards() {
2116 let config = parse_str(
2117 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n",
2118 );
2119 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2120 assert_eq!(block.tunnel_count(), 2);
2121 } else {
2122 panic!("Expected HostBlock");
2123 }
2124 }
2125
2126 #[test]
2127 fn tunnel_count_zero_for_no_forwards() {
2128 let config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
2129 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2130 assert_eq!(block.tunnel_count(), 0);
2131 assert!(!block.has_tunnels());
2132 } else {
2133 panic!("Expected HostBlock");
2134 }
2135 }
2136
2137 #[test]
2138 fn has_tunnels_true_with_forward() {
2139 let config = parse_str("Host myserver\n HostName 10.0.0.1\n DynamicForward 1080\n");
2140 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2141 assert!(block.has_tunnels());
2142 } else {
2143 panic!("Expected HostBlock");
2144 }
2145 }
2146
2147 #[test]
2148 fn add_forward_inserts_directive() {
2149 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n");
2150 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
2151 let output = config.serialize();
2152 assert!(output.contains("LocalForward 8080 localhost:80"));
2153 assert!(output.contains("HostName 10.0.0.1"));
2155 assert!(output.contains("User admin"));
2156 }
2157
2158 #[test]
2159 fn add_forward_preserves_indentation() {
2160 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
2161 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
2162 let output = config.serialize();
2163 assert!(output.contains("\tLocalForward 8080 localhost:80"));
2164 }
2165
2166 #[test]
2167 fn add_multiple_forwards_same_type() {
2168 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2169 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
2170 config.add_forward("myserver", "LocalForward", "9090 localhost:90");
2171 let output = config.serialize();
2172 assert!(output.contains("LocalForward 8080 localhost:80"));
2173 assert!(output.contains("LocalForward 9090 localhost:90"));
2174 }
2175
2176 #[test]
2177 fn remove_forward_removes_exact_match() {
2178 let mut config = parse_str(
2179 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
2180 );
2181 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
2182 let output = config.serialize();
2183 assert!(!output.contains("8080 localhost:80"));
2184 assert!(output.contains("9090 localhost:90"));
2185 }
2186
2187 #[test]
2188 fn remove_forward_leaves_other_directives() {
2189 let mut config = parse_str(
2190 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n User admin\n",
2191 );
2192 config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
2193 let output = config.serialize();
2194 assert!(!output.contains("LocalForward"));
2195 assert!(output.contains("HostName 10.0.0.1"));
2196 assert!(output.contains("User admin"));
2197 }
2198
2199 #[test]
2200 fn remove_forward_no_match_is_noop() {
2201 let original = "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n";
2202 let mut config = parse_str(original);
2203 config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
2204 assert_eq!(config.serialize(), original);
2205 }
2206
2207 #[test]
2208 fn host_entry_tunnel_count_populated() {
2209 let config = parse_str(
2210 "Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n DynamicForward 1080\n",
2211 );
2212 let entries = config.host_entries();
2213 assert_eq!(entries.len(), 1);
2214 assert_eq!(entries[0].tunnel_count, 2);
2215 }
2216
2217 #[test]
2218 fn remove_forward_returns_true_on_match() {
2219 let mut config =
2220 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
2221 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2222 }
2223
2224 #[test]
2225 fn remove_forward_returns_false_on_no_match() {
2226 let mut config =
2227 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
2228 assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
2229 }
2230
2231 #[test]
2232 fn remove_forward_returns_false_for_unknown_host() {
2233 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2234 assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
2235 }
2236
2237 #[test]
2238 fn has_forward_finds_match() {
2239 let config =
2240 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
2241 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2242 }
2243
2244 #[test]
2245 fn has_forward_no_match() {
2246 let config =
2247 parse_str("Host myserver\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
2248 assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
2249 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
2250 }
2251
2252 #[test]
2253 fn has_forward_case_insensitive_key() {
2254 let config =
2255 parse_str("Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n");
2256 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2257 }
2258
2259 #[test]
2260 fn add_forward_to_empty_block() {
2261 let mut config = parse_str("Host myserver\n");
2262 config.add_forward("myserver", "LocalForward", "8080 localhost:80");
2263 let output = config.serialize();
2264 assert!(output.contains("LocalForward 8080 localhost:80"));
2265 }
2266
2267 #[test]
2268 fn remove_forward_case_insensitive_key_match() {
2269 let mut config =
2270 parse_str("Host myserver\n HostName 10.0.0.1\n localforward 8080 localhost:80\n");
2271 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2272 assert!(!config.serialize().contains("localforward"));
2273 }
2274
2275 #[test]
2276 fn tunnel_count_case_insensitive() {
2277 let config = parse_str(
2278 "Host myserver\n localforward 8080 localhost:80\n REMOTEFORWARD 9090 localhost:90\n dynamicforward 1080\n",
2279 );
2280 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2281 assert_eq!(block.tunnel_count(), 3);
2282 } else {
2283 panic!("Expected HostBlock");
2284 }
2285 }
2286
2287 #[test]
2288 fn tunnel_directives_extracts_all_types() {
2289 let config = parse_str(
2290 "Host myserver\n LocalForward 8080 localhost:80\n RemoteForward 9090 localhost:3000\n DynamicForward 1080\n",
2291 );
2292 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2293 let rules = block.tunnel_directives();
2294 assert_eq!(rules.len(), 3);
2295 assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
2296 assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
2297 assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
2298 } else {
2299 panic!("Expected HostBlock");
2300 }
2301 }
2302
2303 #[test]
2304 fn tunnel_directives_skips_malformed() {
2305 let config = parse_str("Host myserver\n LocalForward not_valid\n DynamicForward 1080\n");
2306 if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2307 let rules = block.tunnel_directives();
2308 assert_eq!(rules.len(), 1);
2309 assert_eq!(rules[0].bind_port, 1080);
2310 } else {
2311 panic!("Expected HostBlock");
2312 }
2313 }
2314
2315 #[test]
2316 fn find_tunnel_directives_multi_pattern_host() {
2317 let config =
2318 parse_str("Host prod staging\n HostName 10.0.0.1\n LocalForward 8080 localhost:80\n");
2319 let rules = config.find_tunnel_directives("prod");
2320 assert_eq!(rules.len(), 1);
2321 assert_eq!(rules[0].bind_port, 8080);
2322 let rules2 = config.find_tunnel_directives("staging");
2323 assert_eq!(rules2.len(), 1);
2324 }
2325
2326 #[test]
2327 fn find_tunnel_directives_no_match() {
2328 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
2329 let rules = config.find_tunnel_directives("nohost");
2330 assert!(rules.is_empty());
2331 }
2332
2333 #[test]
2334 fn has_forward_exact_match() {
2335 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
2336 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2337 assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
2338 assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
2339 assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
2340 }
2341
2342 #[test]
2343 fn has_forward_whitespace_normalized() {
2344 let config = parse_str("Host myserver\n LocalForward 8080 localhost:80\n");
2345 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2347 }
2348
2349 #[test]
2350 fn has_forward_multi_pattern_host() {
2351 let config = parse_str("Host prod staging\n LocalForward 8080 localhost:80\n");
2352 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
2353 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2354 }
2355
2356 #[test]
2357 fn add_forward_multi_pattern_host() {
2358 let mut config = parse_str("Host prod staging\n HostName 10.0.0.1\n");
2359 config.add_forward("prod", "LocalForward", "8080 localhost:80");
2360 assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
2361 assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2362 }
2363
2364 #[test]
2365 fn remove_forward_multi_pattern_host() {
2366 let mut config = parse_str(
2367 "Host prod staging\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
2368 );
2369 assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
2370 assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2371 assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
2373 }
2374
2375 #[test]
2376 fn edit_tunnel_detects_duplicate_after_remove() {
2377 let mut config = parse_str(
2379 "Host myserver\n LocalForward 8080 localhost:80\n LocalForward 9090 localhost:90\n",
2380 );
2381 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2383 assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
2385 }
2386
2387 #[test]
2388 fn has_forward_tab_whitespace_normalized() {
2389 let config = parse_str("Host myserver\n LocalForward 8080\tlocalhost:80\n");
2390 assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2392 }
2393
2394 #[test]
2395 fn remove_forward_tab_whitespace_normalized() {
2396 let mut config = parse_str("Host myserver\n LocalForward 8080\tlocalhost:80\n");
2397 assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2399 assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2400 }
2401
2402 #[test]
2403 fn upsert_preserves_space_separator_when_value_contains_equals() {
2404 let mut config = parse_str("Host myserver\n IdentityFile ~/.ssh/id=prod\n");
2405 let entry = HostEntry {
2406 alias: "myserver".to_string(),
2407 hostname: "10.0.0.1".to_string(),
2408 identity_file: "~/.ssh/id=staging".to_string(),
2409 port: 22,
2410 ..Default::default()
2411 };
2412 config.update_host("myserver", &entry);
2413 let output = config.serialize();
2414 assert!(
2416 output.contains(" IdentityFile ~/.ssh/id=staging"),
2417 "got: {}",
2418 output
2419 );
2420 assert!(!output.contains("IdentityFile="), "got: {}", output);
2421 }
2422
2423 #[test]
2424 fn upsert_preserves_equals_separator() {
2425 let mut config = parse_str("Host myserver\n IdentityFile=~/.ssh/id_rsa\n");
2426 let entry = HostEntry {
2427 alias: "myserver".to_string(),
2428 hostname: "10.0.0.1".to_string(),
2429 identity_file: "~/.ssh/id_ed25519".to_string(),
2430 port: 22,
2431 ..Default::default()
2432 };
2433 config.update_host("myserver", &entry);
2434 let output = config.serialize();
2435 assert!(
2436 output.contains("IdentityFile=~/.ssh/id_ed25519"),
2437 "got: {}",
2438 output
2439 );
2440 }
2441
2442 #[test]
2443 fn upsert_preserves_spaced_equals_separator() {
2444 let mut config = parse_str("Host myserver\n IdentityFile = ~/.ssh/id_rsa\n");
2445 let entry = HostEntry {
2446 alias: "myserver".to_string(),
2447 hostname: "10.0.0.1".to_string(),
2448 identity_file: "~/.ssh/id_ed25519".to_string(),
2449 port: 22,
2450 ..Default::default()
2451 };
2452 config.update_host("myserver", &entry);
2453 let output = config.serialize();
2454 assert!(
2455 output.contains("IdentityFile = ~/.ssh/id_ed25519"),
2456 "got: {}",
2457 output
2458 );
2459 }
2460
2461 #[test]
2462 fn is_included_host_false_for_main_config() {
2463 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2464 assert!(!config.is_included_host("myserver"));
2465 }
2466
2467 #[test]
2468 fn is_included_host_false_for_nonexistent() {
2469 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2470 assert!(!config.is_included_host("nohost"));
2471 }
2472
2473 #[test]
2474 fn is_included_host_multi_pattern_main_config() {
2475 let config = parse_str("Host prod staging\n HostName 10.0.0.1\n");
2476 assert!(!config.is_included_host("prod"));
2477 assert!(!config.is_included_host("staging"));
2478 }
2479
2480 fn first_block(config: &SshConfigFile) -> &HostBlock {
2485 match config.elements.first().unwrap() {
2486 ConfigElement::HostBlock(b) => b,
2487 _ => panic!("Expected HostBlock"),
2488 }
2489 }
2490
2491 fn first_block_mut(config: &mut SshConfigFile) -> &mut HostBlock {
2492 match config.elements.first_mut().unwrap() {
2493 ConfigElement::HostBlock(b) => b,
2494 _ => panic!("Expected HostBlock"),
2495 }
2496 }
2497
2498 fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
2499 let mut count = 0;
2500 for el in &config.elements {
2501 if let ConfigElement::HostBlock(b) = el {
2502 if count == idx {
2503 return b;
2504 }
2505 count += 1;
2506 }
2507 }
2508 panic!("No HostBlock at index {}", idx);
2509 }
2510
2511 #[test]
2512 fn askpass_returns_none_when_absent() {
2513 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2514 assert_eq!(first_block(&config).askpass(), None);
2515 }
2516
2517 #[test]
2518 fn askpass_returns_keychain() {
2519 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2520 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2521 }
2522
2523 #[test]
2524 fn askpass_returns_op_uri() {
2525 let config = parse_str(
2526 "Host myserver\n HostName 10.0.0.1\n # purple:askpass op://Vault/Item/field\n",
2527 );
2528 assert_eq!(
2529 first_block(&config).askpass(),
2530 Some("op://Vault/Item/field".to_string())
2531 );
2532 }
2533
2534 #[test]
2535 fn askpass_returns_vault_with_field() {
2536 let config = parse_str(
2537 "Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:secret/ssh#password\n",
2538 );
2539 assert_eq!(
2540 first_block(&config).askpass(),
2541 Some("vault:secret/ssh#password".to_string())
2542 );
2543 }
2544
2545 #[test]
2546 fn askpass_returns_bw_source() {
2547 let config =
2548 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:my-item\n");
2549 assert_eq!(
2550 first_block(&config).askpass(),
2551 Some("bw:my-item".to_string())
2552 );
2553 }
2554
2555 #[test]
2556 fn askpass_returns_pass_source() {
2557 let config =
2558 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass pass:ssh/prod\n");
2559 assert_eq!(
2560 first_block(&config).askpass(),
2561 Some("pass:ssh/prod".to_string())
2562 );
2563 }
2564
2565 #[test]
2566 fn askpass_returns_custom_command() {
2567 let config =
2568 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass get-pass %a %h\n");
2569 assert_eq!(
2570 first_block(&config).askpass(),
2571 Some("get-pass %a %h".to_string())
2572 );
2573 }
2574
2575 #[test]
2576 fn askpass_ignores_empty_value() {
2577 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass \n");
2578 assert_eq!(first_block(&config).askpass(), None);
2579 }
2580
2581 #[test]
2582 fn askpass_ignores_non_askpass_purple_comments() {
2583 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod\n");
2584 assert_eq!(first_block(&config).askpass(), None);
2585 }
2586
2587 #[test]
2588 fn set_askpass_adds_comment() {
2589 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2590 config.set_host_askpass("myserver", "keychain");
2591 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2592 }
2593
2594 #[test]
2595 fn set_askpass_replaces_existing() {
2596 let mut config =
2597 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2598 config.set_host_askpass("myserver", "op://V/I/p");
2599 assert_eq!(
2600 first_block(&config).askpass(),
2601 Some("op://V/I/p".to_string())
2602 );
2603 }
2604
2605 #[test]
2606 fn set_askpass_empty_removes_comment() {
2607 let mut config =
2608 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2609 config.set_host_askpass("myserver", "");
2610 assert_eq!(first_block(&config).askpass(), None);
2611 }
2612
2613 #[test]
2614 fn set_askpass_preserves_other_directives() {
2615 let mut config =
2616 parse_str("Host myserver\n HostName 10.0.0.1\n User admin\n # purple:tags prod\n");
2617 config.set_host_askpass("myserver", "vault:secret/ssh");
2618 assert_eq!(
2619 first_block(&config).askpass(),
2620 Some("vault:secret/ssh".to_string())
2621 );
2622 let entry = first_block(&config).to_host_entry();
2623 assert_eq!(entry.user, "admin");
2624 assert!(entry.tags.contains(&"prod".to_string()));
2625 }
2626
2627 #[test]
2628 fn set_askpass_preserves_indent() {
2629 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2630 config.set_host_askpass("myserver", "keychain");
2631 let raw = first_block(&config)
2632 .directives
2633 .iter()
2634 .find(|d| d.raw_line.contains("purple:askpass"))
2635 .unwrap();
2636 assert!(
2637 raw.raw_line.starts_with(" "),
2638 "Expected 4-space indent, got: {:?}",
2639 raw.raw_line
2640 );
2641 }
2642
2643 #[test]
2644 fn set_askpass_on_nonexistent_host() {
2645 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2646 config.set_host_askpass("nohost", "keychain");
2647 assert_eq!(first_block(&config).askpass(), None);
2648 }
2649
2650 #[test]
2651 fn to_entry_includes_askpass() {
2652 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass bw:item\n");
2653 let entries = config.host_entries();
2654 assert_eq!(entries.len(), 1);
2655 assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
2656 }
2657
2658 #[test]
2659 fn to_entry_askpass_none_when_absent() {
2660 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2661 let entries = config.host_entries();
2662 assert_eq!(entries.len(), 1);
2663 assert_eq!(entries[0].askpass, None);
2664 }
2665
2666 #[test]
2667 fn set_askpass_vault_with_hash_field() {
2668 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2669 config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
2670 assert_eq!(
2671 first_block(&config).askpass(),
2672 Some("vault:secret/data/team#api_key".to_string())
2673 );
2674 }
2675
2676 #[test]
2677 fn set_askpass_custom_command_with_percent() {
2678 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2679 config.set_host_askpass("myserver", "get-pass %a %h");
2680 assert_eq!(
2681 first_block(&config).askpass(),
2682 Some("get-pass %a %h".to_string())
2683 );
2684 }
2685
2686 #[test]
2687 fn multiple_hosts_independent_askpass() {
2688 let mut config = parse_str("Host alpha\n HostName a.com\n\nHost beta\n HostName b.com\n");
2689 config.set_host_askpass("alpha", "keychain");
2690 config.set_host_askpass("beta", "vault:secret/ssh");
2691 assert_eq!(
2692 block_by_index(&config, 0).askpass(),
2693 Some("keychain".to_string())
2694 );
2695 assert_eq!(
2696 block_by_index(&config, 1).askpass(),
2697 Some("vault:secret/ssh".to_string())
2698 );
2699 }
2700
2701 #[test]
2702 fn set_askpass_then_clear_then_set_again() {
2703 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2704 config.set_host_askpass("myserver", "keychain");
2705 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2706 config.set_host_askpass("myserver", "");
2707 assert_eq!(first_block(&config).askpass(), None);
2708 config.set_host_askpass("myserver", "op://V/I/p");
2709 assert_eq!(
2710 first_block(&config).askpass(),
2711 Some("op://V/I/p".to_string())
2712 );
2713 }
2714
2715 #[test]
2716 fn askpass_tab_indent_preserved() {
2717 let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
2718 config.set_host_askpass("myserver", "pass:ssh/prod");
2719 let raw = first_block(&config)
2720 .directives
2721 .iter()
2722 .find(|d| d.raw_line.contains("purple:askpass"))
2723 .unwrap();
2724 assert!(
2725 raw.raw_line.starts_with("\t"),
2726 "Expected tab indent, got: {:?}",
2727 raw.raw_line
2728 );
2729 }
2730
2731 #[test]
2732 fn askpass_coexists_with_provider_comment() {
2733 let config = parse_str(
2734 "Host myserver\n HostName 10.0.0.1\n # purple:provider do:123\n # purple:askpass keychain\n",
2735 );
2736 let block = first_block(&config);
2737 assert_eq!(block.askpass(), Some("keychain".to_string()));
2738 assert!(block.provider().is_some());
2739 }
2740
2741 #[test]
2742 fn set_askpass_does_not_remove_tags() {
2743 let mut config =
2744 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:tags prod,staging\n");
2745 config.set_host_askpass("myserver", "keychain");
2746 let entry = first_block(&config).to_host_entry();
2747 assert_eq!(entry.askpass, Some("keychain".to_string()));
2748 assert!(entry.tags.contains(&"prod".to_string()));
2749 assert!(entry.tags.contains(&"staging".to_string()));
2750 }
2751
2752 #[test]
2753 fn askpass_idempotent_set_same_value() {
2754 let mut config =
2755 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n");
2756 config.set_host_askpass("myserver", "keychain");
2757 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2758 let serialized = config.serialize();
2759 assert_eq!(
2760 serialized.matches("purple:askpass").count(),
2761 1,
2762 "Should have exactly one askpass comment"
2763 );
2764 }
2765
2766 #[test]
2767 fn askpass_with_value_containing_equals() {
2768 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2769 config.set_host_askpass("myserver", "cmd --opt=val %h");
2770 assert_eq!(
2771 first_block(&config).askpass(),
2772 Some("cmd --opt=val %h".to_string())
2773 );
2774 }
2775
2776 #[test]
2777 fn askpass_with_value_containing_hash() {
2778 let config =
2779 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:askpass vault:a/b#c\n");
2780 assert_eq!(
2781 first_block(&config).askpass(),
2782 Some("vault:a/b#c".to_string())
2783 );
2784 }
2785
2786 #[test]
2787 fn askpass_with_long_op_uri() {
2788 let uri = "op://My Personal Vault/SSH Production Server/password";
2789 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2790 config.set_host_askpass("myserver", uri);
2791 assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
2792 }
2793
2794 #[test]
2795 fn askpass_does_not_interfere_with_host_matching() {
2796 let config = parse_str(
2798 "Host myserver\n HostName 10.0.0.1\n User root\n # purple:askpass keychain\n",
2799 );
2800 let entry = first_block(&config).to_host_entry();
2801 assert_eq!(entry.user, "root");
2802 assert_eq!(entry.hostname, "10.0.0.1");
2803 assert_eq!(entry.askpass, Some("keychain".to_string()));
2804 }
2805
2806 #[test]
2807 fn set_askpass_on_host_with_many_directives() {
2808 let config_str = "\
2809Host myserver
2810 HostName 10.0.0.1
2811 User admin
2812 Port 2222
2813 IdentityFile ~/.ssh/id_ed25519
2814 ProxyJump bastion
2815 # purple:tags prod,us-east
2816";
2817 let mut config = parse_str(config_str);
2818 config.set_host_askpass("myserver", "pass:ssh/prod");
2819 let entry = first_block(&config).to_host_entry();
2820 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2821 assert_eq!(entry.user, "admin");
2822 assert_eq!(entry.port, 2222);
2823 assert!(entry.tags.contains(&"prod".to_string()));
2824 }
2825
2826 #[test]
2827 fn askpass_with_crlf_line_endings() {
2828 let config =
2829 parse_str("Host myserver\r\n HostName 10.0.0.1\r\n # purple:askpass keychain\r\n");
2830 assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2831 }
2832
2833 #[test]
2834 fn askpass_only_on_first_matching_host() {
2835 let config = parse_str(
2837 "Host dup\n HostName a.com\n # purple:askpass keychain\n\nHost dup\n HostName b.com\n # purple:askpass vault:x\n",
2838 );
2839 let entries = config.host_entries();
2840 assert_eq!(entries[0].askpass, Some("keychain".to_string()));
2842 }
2843
2844 #[test]
2845 fn set_askpass_preserves_other_non_directive_comments() {
2846 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";
2847 let mut config = parse_str(config_str);
2848 config.set_host_askpass("myserver", "new-source");
2849 let serialized = config.serialize();
2850 assert!(serialized.contains("# This is a user comment"));
2851 assert!(serialized.contains("# Another comment"));
2852 assert!(serialized.contains("# purple:askpass new-source"));
2853 assert!(!serialized.contains("# purple:askpass old"));
2854 }
2855
2856 #[test]
2857 fn askpass_mixed_with_tunnel_directives() {
2858 let config_str = "\
2859Host myserver
2860 HostName 10.0.0.1
2861 LocalForward 8080 localhost:80
2862 # purple:askpass bw:item
2863 RemoteForward 9090 localhost:9090
2864";
2865 let config = parse_str(config_str);
2866 let entry = first_block(&config).to_host_entry();
2867 assert_eq!(entry.askpass, Some("bw:item".to_string()));
2868 assert_eq!(entry.tunnel_count, 2);
2869 }
2870
2871 #[test]
2876 fn set_askpass_idempotent_same_value() {
2877 let config_str = "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n";
2878 let mut config = parse_str(config_str);
2879 config.set_host_askpass("myserver", "keychain");
2880 let output = config.serialize();
2881 assert_eq!(output.matches("purple:askpass").count(), 1);
2883 assert!(output.contains("# purple:askpass keychain"));
2884 }
2885
2886 #[test]
2887 fn set_askpass_with_equals_in_value() {
2888 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2889 config.set_host_askpass("myserver", "cmd --opt=val");
2890 let entries = config.host_entries();
2891 assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
2892 }
2893
2894 #[test]
2895 fn set_askpass_with_hash_in_value() {
2896 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2897 config.set_host_askpass("myserver", "vault:secret/data#field");
2898 let entries = config.host_entries();
2899 assert_eq!(
2900 entries[0].askpass,
2901 Some("vault:secret/data#field".to_string())
2902 );
2903 }
2904
2905 #[test]
2906 fn set_askpass_long_op_uri() {
2907 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
2908 let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
2909 config.set_host_askpass("myserver", long_uri);
2910 assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
2911 }
2912
2913 #[test]
2914 fn askpass_host_with_multi_pattern_is_skipped() {
2915 let config_str = "Host prod staging\n HostName 10.0.0.1\n";
2918 let mut config = parse_str(config_str);
2919 config.set_host_askpass("prod", "keychain");
2920 assert!(config.host_entries().is_empty());
2922 }
2923
2924 #[test]
2925 fn askpass_survives_directive_reorder() {
2926 let config_str = "\
2928Host myserver
2929 # purple:askpass op://V/I/p
2930 HostName 10.0.0.1
2931 User root
2932";
2933 let config = parse_str(config_str);
2934 let entry = first_block(&config).to_host_entry();
2935 assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
2936 assert_eq!(entry.hostname, "10.0.0.1");
2937 }
2938
2939 #[test]
2940 fn askpass_among_many_purple_comments() {
2941 let config_str = "\
2942Host myserver
2943 HostName 10.0.0.1
2944 # purple:tags prod,us-east
2945 # purple:provider do:12345
2946 # purple:askpass pass:ssh/prod
2947";
2948 let config = parse_str(config_str);
2949 let entry = first_block(&config).to_host_entry();
2950 assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2951 assert!(entry.tags.contains(&"prod".to_string()));
2952 }
2953
2954 #[test]
2955 fn meta_empty_when_no_comment() {
2956 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2957 let config = parse_str(config_str);
2958 let meta = first_block(&config).meta();
2959 assert!(meta.is_empty());
2960 }
2961
2962 #[test]
2963 fn meta_parses_key_value_pairs() {
2964 let config_str = "\
2965Host myhost
2966 HostName 1.2.3.4
2967 # purple:meta region=nyc3,plan=s-1vcpu-1gb
2968";
2969 let config = parse_str(config_str);
2970 let meta = first_block(&config).meta();
2971 assert_eq!(meta.len(), 2);
2972 assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
2973 assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
2974 }
2975
2976 #[test]
2977 fn meta_round_trip() {
2978 let config_str = "Host myhost\n HostName 1.2.3.4\n";
2979 let mut config = parse_str(config_str);
2980 let meta = vec![
2981 ("region".to_string(), "fra1".to_string()),
2982 ("plan".to_string(), "cx11".to_string()),
2983 ];
2984 config.set_host_meta("myhost", &meta);
2985 let output = config.serialize();
2986 assert!(output.contains("# purple:meta region=fra1,plan=cx11"));
2987
2988 let config2 = parse_str(&output);
2989 let parsed = first_block(&config2).meta();
2990 assert_eq!(parsed, meta);
2991 }
2992
2993 #[test]
2994 fn meta_replaces_existing() {
2995 let config_str = "\
2996Host myhost
2997 HostName 1.2.3.4
2998 # purple:meta region=old
2999";
3000 let mut config = parse_str(config_str);
3001 config.set_host_meta("myhost", &[("region".to_string(), "new".to_string())]);
3002 let output = config.serialize();
3003 assert!(!output.contains("region=old"));
3004 assert!(output.contains("region=new"));
3005 }
3006
3007 #[test]
3008 fn meta_removed_when_empty() {
3009 let config_str = "\
3010Host myhost
3011 HostName 1.2.3.4
3012 # purple:meta region=nyc3
3013";
3014 let mut config = parse_str(config_str);
3015 config.set_host_meta("myhost", &[]);
3016 let output = config.serialize();
3017 assert!(!output.contains("purple:meta"));
3018 }
3019
3020 #[test]
3021 fn meta_sanitizes_commas_in_values() {
3022 let config_str = "Host myhost\n HostName 1.2.3.4\n";
3023 let mut config = parse_str(config_str);
3024 let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
3025 config.set_host_meta("myhost", &meta);
3026 let output = config.serialize();
3027 assert!(output.contains("plan=s-1vcpu1gb"));
3029
3030 let config2 = parse_str(&output);
3031 let parsed = first_block(&config2).meta();
3032 assert_eq!(parsed[0].1, "s-1vcpu1gb");
3033 }
3034
3035 #[test]
3036 fn meta_in_host_entry() {
3037 let config_str = "\
3038Host myhost
3039 HostName 1.2.3.4
3040 # purple:meta region=nyc3,plan=s-1vcpu-1gb
3041";
3042 let config = parse_str(config_str);
3043 let entry = first_block(&config).to_host_entry();
3044 assert_eq!(entry.provider_meta.len(), 2);
3045 assert_eq!(entry.provider_meta[0].0, "region");
3046 assert_eq!(entry.provider_meta[1].0, "plan");
3047 }
3048
3049 #[test]
3050 fn repair_absorbed_group_comment() {
3051 let mut config = SshConfigFile {
3053 elements: vec![ConfigElement::HostBlock(HostBlock {
3054 host_pattern: "myserver".to_string(),
3055 raw_host_line: "Host myserver".to_string(),
3056 directives: vec![
3057 Directive {
3058 key: "HostName".to_string(),
3059 value: "10.0.0.1".to_string(),
3060 raw_line: " HostName 10.0.0.1".to_string(),
3061 is_non_directive: false,
3062 },
3063 Directive {
3064 key: String::new(),
3065 value: String::new(),
3066 raw_line: "# purple:group Production".to_string(),
3067 is_non_directive: true,
3068 },
3069 ],
3070 })],
3071 path: PathBuf::from("/tmp/test_config"),
3072 crlf: false,
3073 bom: false,
3074 };
3075 let count = config.repair_absorbed_group_comments();
3076 assert_eq!(count, 1);
3077 assert_eq!(config.elements.len(), 2);
3078 if let ConfigElement::HostBlock(block) = &config.elements[0] {
3080 assert_eq!(block.directives.len(), 1);
3081 assert_eq!(block.directives[0].key, "HostName");
3082 } else {
3083 panic!("Expected HostBlock");
3084 }
3085 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
3087 assert_eq!(line, "# purple:group Production");
3088 } else {
3089 panic!("Expected GlobalLine for group comment");
3090 }
3091 }
3092
3093 #[test]
3094 fn repair_strips_trailing_blanks_before_group() {
3095 let mut config = SshConfigFile {
3096 elements: vec![ConfigElement::HostBlock(HostBlock {
3097 host_pattern: "myserver".to_string(),
3098 raw_host_line: "Host myserver".to_string(),
3099 directives: vec![
3100 Directive {
3101 key: "HostName".to_string(),
3102 value: "10.0.0.1".to_string(),
3103 raw_line: " HostName 10.0.0.1".to_string(),
3104 is_non_directive: false,
3105 },
3106 Directive {
3107 key: String::new(),
3108 value: String::new(),
3109 raw_line: "".to_string(),
3110 is_non_directive: true,
3111 },
3112 Directive {
3113 key: String::new(),
3114 value: String::new(),
3115 raw_line: "# purple:group Staging".to_string(),
3116 is_non_directive: true,
3117 },
3118 ],
3119 })],
3120 path: PathBuf::from("/tmp/test_config"),
3121 crlf: false,
3122 bom: false,
3123 };
3124 let count = config.repair_absorbed_group_comments();
3125 assert_eq!(count, 1);
3126 if let ConfigElement::HostBlock(block) = &config.elements[0] {
3128 assert_eq!(block.directives.len(), 1);
3129 } else {
3130 panic!("Expected HostBlock");
3131 }
3132 assert_eq!(config.elements.len(), 3);
3134 if let ConfigElement::GlobalLine(line) = &config.elements[1] {
3135 assert!(line.trim().is_empty());
3136 } else {
3137 panic!("Expected blank GlobalLine");
3138 }
3139 if let ConfigElement::GlobalLine(line) = &config.elements[2] {
3140 assert!(line.starts_with("# purple:group"));
3141 } else {
3142 panic!("Expected group GlobalLine");
3143 }
3144 }
3145
3146 #[test]
3147 fn repair_clean_config_returns_zero() {
3148 let mut config =
3149 parse_str("# purple:group Production\nHost myserver\n HostName 10.0.0.1\n");
3150 let count = config.repair_absorbed_group_comments();
3151 assert_eq!(count, 0);
3152 }
3153
3154 #[test]
3155 fn repair_roundtrip_serializes_correctly() {
3156 let mut config = SshConfigFile {
3158 elements: vec![
3159 ConfigElement::HostBlock(HostBlock {
3160 host_pattern: "server1".to_string(),
3161 raw_host_line: "Host server1".to_string(),
3162 directives: vec![
3163 Directive {
3164 key: "HostName".to_string(),
3165 value: "10.0.0.1".to_string(),
3166 raw_line: " HostName 10.0.0.1".to_string(),
3167 is_non_directive: false,
3168 },
3169 Directive {
3170 key: String::new(),
3171 value: String::new(),
3172 raw_line: "".to_string(),
3173 is_non_directive: true,
3174 },
3175 Directive {
3176 key: String::new(),
3177 value: String::new(),
3178 raw_line: "# purple:group Staging".to_string(),
3179 is_non_directive: true,
3180 },
3181 ],
3182 }),
3183 ConfigElement::HostBlock(HostBlock {
3184 host_pattern: "server2".to_string(),
3185 raw_host_line: "Host server2".to_string(),
3186 directives: vec![Directive {
3187 key: "HostName".to_string(),
3188 value: "10.0.0.2".to_string(),
3189 raw_line: " HostName 10.0.0.2".to_string(),
3190 is_non_directive: false,
3191 }],
3192 }),
3193 ],
3194 path: PathBuf::from("/tmp/test_config"),
3195 crlf: false,
3196 bom: false,
3197 };
3198 let count = config.repair_absorbed_group_comments();
3199 assert_eq!(count, 1);
3200 let output = config.serialize();
3201 let expected = "\
3203Host server1
3204 HostName 10.0.0.1
3205
3206# purple:group Staging
3207Host server2
3208 HostName 10.0.0.2
3209";
3210 assert_eq!(output, expected);
3211 }
3212
3213 #[test]
3218 fn delete_last_provider_host_removes_group_header() {
3219 let config_str = "\
3220# purple:group DigitalOcean
3221Host do-web
3222 HostName 1.2.3.4
3223 # purple:provider digitalocean:123
3224";
3225 let mut config = parse_str(config_str);
3226 config.delete_host("do-web");
3227 let has_header = config
3228 .elements
3229 .iter()
3230 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
3231 assert!(
3232 !has_header,
3233 "Group header should be removed when last provider host is deleted"
3234 );
3235 }
3236
3237 #[test]
3238 fn delete_one_of_multiple_provider_hosts_preserves_group_header() {
3239 let config_str = "\
3240# purple:group DigitalOcean
3241Host do-web
3242 HostName 1.2.3.4
3243 # purple:provider digitalocean:123
3244
3245Host do-db
3246 HostName 5.6.7.8
3247 # purple:provider digitalocean:456
3248";
3249 let mut config = parse_str(config_str);
3250 config.delete_host("do-web");
3251 let has_header = config.elements.iter().any(|e| {
3252 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
3253 });
3254 assert!(
3255 has_header,
3256 "Group header should be preserved when other provider hosts remain"
3257 );
3258 assert_eq!(config.host_entries().len(), 1);
3259 }
3260
3261 #[test]
3262 fn delete_non_provider_host_leaves_group_headers() {
3263 let config_str = "\
3264Host personal
3265 HostName 10.0.0.1
3266
3267# purple:group DigitalOcean
3268Host do-web
3269 HostName 1.2.3.4
3270 # purple:provider digitalocean:123
3271";
3272 let mut config = parse_str(config_str);
3273 config.delete_host("personal");
3274 let has_header = config.elements.iter().any(|e| {
3275 matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
3276 });
3277 assert!(
3278 has_header,
3279 "Group header should not be affected by deleting a non-provider host"
3280 );
3281 assert_eq!(config.host_entries().len(), 1);
3282 }
3283
3284 #[test]
3285 fn delete_host_undoable_keeps_group_header_for_undo() {
3286 let config_str = "\
3290# purple:group Vultr
3291Host vultr-web
3292 HostName 2.3.4.5
3293 # purple:provider vultr:789
3294";
3295 let mut config = parse_str(config_str);
3296 let result = config.delete_host_undoable("vultr-web");
3297 assert!(result.is_some());
3298 let has_header = config
3299 .elements
3300 .iter()
3301 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
3302 assert!(has_header, "Group header should be kept for undo");
3303 }
3304
3305 #[test]
3306 fn delete_host_undoable_preserves_header_when_others_remain() {
3307 let config_str = "\
3308# purple:group AWS EC2
3309Host aws-web
3310 HostName 3.4.5.6
3311 # purple:provider aws:i-111
3312
3313Host aws-db
3314 HostName 7.8.9.0
3315 # purple:provider aws:i-222
3316";
3317 let mut config = parse_str(config_str);
3318 let result = config.delete_host_undoable("aws-web");
3319 assert!(result.is_some());
3320 let has_header = config.elements.iter().any(
3321 |e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group AWS EC2")),
3322 );
3323 assert!(
3324 has_header,
3325 "Group header preserved when other provider hosts remain (undoable)"
3326 );
3327 }
3328
3329 #[test]
3330 fn delete_host_undoable_returns_original_position_for_undo() {
3331 let config_str = "\
3334# purple:group Vultr
3335Host vultr-web
3336 HostName 2.3.4.5
3337 # purple:provider vultr:789
3338
3339Host manual
3340 HostName 10.0.0.1
3341";
3342 let mut config = parse_str(config_str);
3343 let (element, pos) = config.delete_host_undoable("vultr-web").unwrap();
3344 assert_eq!(pos, 1, "Position should be the original host index");
3346 config.insert_host_at(element, pos);
3348 let output = config.serialize();
3350 assert!(
3351 output.contains("# purple:group Vultr"),
3352 "Group header should be present"
3353 );
3354 assert!(output.contains("Host vultr-web"), "Host should be restored");
3355 assert!(output.contains("Host manual"), "Manual host should survive");
3356 assert_eq!(config_str, output);
3357 }
3358
3359 #[test]
3364 fn add_host_inserts_before_trailing_wildcard() {
3365 let config_str = "\
3366Host existing
3367 HostName 10.0.0.1
3368
3369Host *
3370 ServerAliveInterval 60
3371";
3372 let mut config = parse_str(config_str);
3373 let entry = HostEntry {
3374 alias: "newhost".to_string(),
3375 hostname: "10.0.0.2".to_string(),
3376 port: 22,
3377 ..Default::default()
3378 };
3379 config.add_host(&entry);
3380 let output = config.serialize();
3381 let new_pos = output.find("Host newhost").unwrap();
3382 let wildcard_pos = output.find("Host *").unwrap();
3383 assert!(
3384 new_pos < wildcard_pos,
3385 "New host should appear before Host *: {}",
3386 output
3387 );
3388 let existing_pos = output.find("Host existing").unwrap();
3389 assert!(existing_pos < new_pos);
3390 }
3391
3392 #[test]
3393 fn add_host_appends_when_no_wildcards() {
3394 let config_str = "\
3395Host existing
3396 HostName 10.0.0.1
3397";
3398 let mut config = parse_str(config_str);
3399 let entry = HostEntry {
3400 alias: "newhost".to_string(),
3401 hostname: "10.0.0.2".to_string(),
3402 port: 22,
3403 ..Default::default()
3404 };
3405 config.add_host(&entry);
3406 let output = config.serialize();
3407 let existing_pos = output.find("Host existing").unwrap();
3408 let new_pos = output.find("Host newhost").unwrap();
3409 assert!(existing_pos < new_pos, "New host should be appended at end");
3410 }
3411
3412 #[test]
3413 fn add_host_appends_when_wildcard_at_beginning() {
3414 let config_str = "\
3416Host *
3417 ServerAliveInterval 60
3418
3419Host existing
3420 HostName 10.0.0.1
3421";
3422 let mut config = parse_str(config_str);
3423 let entry = HostEntry {
3424 alias: "newhost".to_string(),
3425 hostname: "10.0.0.2".to_string(),
3426 port: 22,
3427 ..Default::default()
3428 };
3429 config.add_host(&entry);
3430 let output = config.serialize();
3431 let existing_pos = output.find("Host existing").unwrap();
3432 let new_pos = output.find("Host newhost").unwrap();
3433 assert!(
3434 existing_pos < new_pos,
3435 "New host should be appended at end when wildcard is at top: {}",
3436 output
3437 );
3438 }
3439
3440 #[test]
3441 fn add_host_inserts_before_trailing_pattern_host() {
3442 let config_str = "\
3443Host existing
3444 HostName 10.0.0.1
3445
3446Host *.example.com
3447 ProxyJump bastion
3448";
3449 let mut config = parse_str(config_str);
3450 let entry = HostEntry {
3451 alias: "newhost".to_string(),
3452 hostname: "10.0.0.2".to_string(),
3453 port: 22,
3454 ..Default::default()
3455 };
3456 config.add_host(&entry);
3457 let output = config.serialize();
3458 let new_pos = output.find("Host newhost").unwrap();
3459 let pattern_pos = output.find("Host *.example.com").unwrap();
3460 assert!(
3461 new_pos < pattern_pos,
3462 "New host should appear before pattern host: {}",
3463 output
3464 );
3465 }
3466
3467 #[test]
3468 fn add_host_no_triple_blank_lines() {
3469 let config_str = "\
3470Host existing
3471 HostName 10.0.0.1
3472
3473Host *
3474 ServerAliveInterval 60
3475";
3476 let mut config = parse_str(config_str);
3477 let entry = HostEntry {
3478 alias: "newhost".to_string(),
3479 hostname: "10.0.0.2".to_string(),
3480 port: 22,
3481 ..Default::default()
3482 };
3483 config.add_host(&entry);
3484 let output = config.serialize();
3485 assert!(
3486 !output.contains("\n\n\n"),
3487 "Should not have triple blank lines: {}",
3488 output
3489 );
3490 }
3491
3492 #[test]
3493 fn provider_group_display_name_matches_providers_mod() {
3494 let providers = [
3499 "digitalocean",
3500 "vultr",
3501 "linode",
3502 "hetzner",
3503 "upcloud",
3504 "proxmox",
3505 "aws",
3506 "scaleway",
3507 "gcp",
3508 "azure",
3509 "tailscale",
3510 "oracle",
3511 ];
3512 for name in &providers {
3513 assert_eq!(
3514 provider_group_display_name(name),
3515 crate::providers::provider_display_name(name),
3516 "Display name mismatch for provider '{}': model.rs has '{}' but providers/mod.rs has '{}'",
3517 name,
3518 provider_group_display_name(name),
3519 crate::providers::provider_display_name(name),
3520 );
3521 }
3522 }
3523
3524 #[test]
3525 fn test_sanitize_tag_strips_control_chars() {
3526 assert_eq!(HostBlock::sanitize_tag("prod"), "prod");
3527 assert_eq!(HostBlock::sanitize_tag("prod\n"), "prod");
3528 assert_eq!(HostBlock::sanitize_tag("pr\x00od"), "prod");
3529 assert_eq!(HostBlock::sanitize_tag("\t\r\n"), "");
3530 }
3531
3532 #[test]
3533 fn test_sanitize_tag_strips_commas() {
3534 assert_eq!(HostBlock::sanitize_tag("prod,staging"), "prodstaging");
3535 assert_eq!(HostBlock::sanitize_tag(",,,"), "");
3536 }
3537
3538 #[test]
3539 fn test_sanitize_tag_strips_bidi() {
3540 assert_eq!(HostBlock::sanitize_tag("prod\u{202E}tset"), "prodtset");
3541 assert_eq!(HostBlock::sanitize_tag("\u{200B}zero\u{FEFF}"), "zero");
3542 }
3543
3544 #[test]
3545 fn test_sanitize_tag_truncates_long() {
3546 let long = "a".repeat(200);
3547 assert_eq!(HostBlock::sanitize_tag(&long).len(), 128);
3548 }
3549
3550 #[test]
3551 fn test_sanitize_tag_preserves_unicode() {
3552 assert_eq!(HostBlock::sanitize_tag("日本語"), "日本語");
3553 assert_eq!(HostBlock::sanitize_tag("café"), "café");
3554 }
3555
3556 #[test]
3561 fn test_provider_tags_parsing() {
3562 let config =
3563 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags a,b,c\n");
3564 let entry = first_block(&config).to_host_entry();
3565 assert_eq!(entry.provider_tags, vec!["a", "b", "c"]);
3566 }
3567
3568 #[test]
3569 fn test_provider_tags_empty() {
3570 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
3571 let entry = first_block(&config).to_host_entry();
3572 assert!(entry.provider_tags.is_empty());
3573 }
3574
3575 #[test]
3576 fn test_has_provider_tags_comment_present() {
3577 let config =
3578 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags prod\n");
3579 assert!(first_block(&config).has_provider_tags_comment());
3580 assert!(first_block(&config).to_host_entry().has_provider_tags);
3581 }
3582
3583 #[test]
3584 fn test_has_provider_tags_comment_sentinel() {
3585 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:provider_tags\n");
3587 assert!(first_block(&config).has_provider_tags_comment());
3588 assert!(first_block(&config).to_host_entry().has_provider_tags);
3589 assert!(
3590 first_block(&config)
3591 .to_host_entry()
3592 .provider_tags
3593 .is_empty()
3594 );
3595 }
3596
3597 #[test]
3598 fn test_has_provider_tags_comment_absent() {
3599 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
3600 assert!(!first_block(&config).has_provider_tags_comment());
3601 assert!(!first_block(&config).to_host_entry().has_provider_tags);
3602 }
3603
3604 #[test]
3605 fn test_set_tags_does_not_delete_provider_tags() {
3606 let mut config = parse_str(
3607 "Host myserver\n HostName 10.0.0.1\n # purple:tags user1\n # purple:provider_tags cloud1,cloud2\n",
3608 );
3609 config.set_host_tags("myserver", &["newuser".to_string()]);
3610 let entry = first_block(&config).to_host_entry();
3611 assert_eq!(entry.tags, vec!["newuser"]);
3612 assert_eq!(entry.provider_tags, vec!["cloud1", "cloud2"]);
3613 }
3614
3615 #[test]
3616 fn test_set_provider_tags_does_not_delete_user_tags() {
3617 let mut config = parse_str(
3618 "Host myserver\n HostName 10.0.0.1\n # purple:tags user1,user2\n # purple:provider_tags old\n",
3619 );
3620 config.set_host_provider_tags("myserver", &["new1".to_string(), "new2".to_string()]);
3621 let entry = first_block(&config).to_host_entry();
3622 assert_eq!(entry.tags, vec!["user1", "user2"]);
3623 assert_eq!(entry.provider_tags, vec!["new1", "new2"]);
3624 }
3625
3626 #[test]
3627 fn test_set_askpass_does_not_delete_similar_comments() {
3628 let mut config = parse_str(
3630 "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n # purple:askpass_backup test\n",
3631 );
3632 config.set_host_askpass("myserver", "op://vault/item/pass");
3633 let entry = first_block(&config).to_host_entry();
3634 assert_eq!(entry.askpass, Some("op://vault/item/pass".to_string()));
3635 let serialized = config.serialize();
3637 assert!(serialized.contains("purple:askpass_backup test"));
3638 }
3639
3640 #[test]
3641 fn test_set_meta_does_not_delete_similar_comments() {
3642 let mut config = parse_str(
3644 "Host myserver\n HostName 10.0.0.1\n # purple:meta region=us-east\n # purple:metadata foo\n",
3645 );
3646 config.set_host_meta("myserver", &[("region".to_string(), "eu-west".to_string())]);
3647 let serialized = config.serialize();
3648 assert!(serialized.contains("purple:meta region=eu-west"));
3649 assert!(serialized.contains("purple:metadata foo"));
3650 }
3651
3652 #[test]
3653 fn test_set_meta_sanitizes_control_chars() {
3654 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
3655 config.set_host_meta(
3656 "myserver",
3657 &[
3658 ("region".to_string(), "us\x00east".to_string()),
3659 ("zone".to_string(), "a\u{202E}b".to_string()),
3660 ],
3661 );
3662 let serialized = config.serialize();
3663 assert!(serialized.contains("region=useast"));
3665 assert!(serialized.contains("zone=ab"));
3666 assert!(!serialized.contains('\x00'));
3667 assert!(!serialized.contains('\u{202E}'));
3668 }
3669
3670 #[test]
3673 fn stale_returns_timestamp() {
3674 let config_str = "\
3675Host web
3676 HostName 1.2.3.4
3677 # purple:stale 1711900000
3678";
3679 let config = parse_str(config_str);
3680 assert_eq!(first_block(&config).stale(), Some(1711900000));
3681 }
3682
3683 #[test]
3684 fn stale_returns_none_when_absent() {
3685 let config_str = "Host web\n HostName 1.2.3.4\n";
3686 let config = parse_str(config_str);
3687 assert_eq!(first_block(&config).stale(), None);
3688 }
3689
3690 #[test]
3691 fn stale_returns_none_for_malformed() {
3692 for bad in &[
3693 "Host w\n HostName 1.2.3.4\n # purple:stale abc\n",
3694 "Host w\n HostName 1.2.3.4\n # purple:stale\n",
3695 "Host w\n HostName 1.2.3.4\n # purple:stale -1\n",
3696 ] {
3697 let config = parse_str(bad);
3698 assert_eq!(first_block(&config).stale(), None, "input: {bad}");
3699 }
3700 }
3701
3702 #[test]
3703 fn set_stale_adds_comment() {
3704 let config_str = "Host web\n HostName 1.2.3.4\n";
3705 let mut config = parse_str(config_str);
3706 first_block_mut(&mut config).set_stale(1711900000);
3707 assert_eq!(first_block(&config).stale(), Some(1711900000));
3708 assert!(config.serialize().contains("# purple:stale 1711900000"));
3709 }
3710
3711 #[test]
3712 fn set_stale_replaces_existing() {
3713 let config_str = "\
3714Host web
3715 HostName 1.2.3.4
3716 # purple:stale 1000
3717";
3718 let mut config = parse_str(config_str);
3719 first_block_mut(&mut config).set_stale(2000);
3720 assert_eq!(first_block(&config).stale(), Some(2000));
3721 let output = config.serialize();
3722 assert!(!output.contains("1000"));
3723 assert!(output.contains("# purple:stale 2000"));
3724 }
3725
3726 #[test]
3727 fn clear_stale_removes_comment() {
3728 let config_str = "\
3729Host web
3730 HostName 1.2.3.4
3731 # purple:stale 1711900000
3732";
3733 let mut config = parse_str(config_str);
3734 first_block_mut(&mut config).clear_stale();
3735 assert_eq!(first_block(&config).stale(), None);
3736 assert!(!config.serialize().contains("purple:stale"));
3737 }
3738
3739 #[test]
3740 fn clear_stale_when_absent_is_noop() {
3741 let config_str = "Host web\n HostName 1.2.3.4\n";
3742 let mut config = parse_str(config_str);
3743 let before = config.serialize();
3744 first_block_mut(&mut config).clear_stale();
3745 assert_eq!(config.serialize(), before);
3746 }
3747
3748 #[test]
3749 fn stale_roundtrip() {
3750 let config_str = "\
3751Host web
3752 HostName 1.2.3.4
3753 # purple:stale 1711900000
3754";
3755 let config = parse_str(config_str);
3756 let output = config.serialize();
3757 let config2 = parse_str(&output);
3758 assert_eq!(first_block(&config2).stale(), Some(1711900000));
3759 }
3760
3761 #[test]
3762 fn stale_in_host_entry() {
3763 let config_str = "\
3764Host web
3765 HostName 1.2.3.4
3766 # purple:stale 1711900000
3767";
3768 let config = parse_str(config_str);
3769 let entry = first_block(&config).to_host_entry();
3770 assert_eq!(entry.stale, Some(1711900000));
3771 }
3772
3773 #[test]
3774 fn stale_coexists_with_other_annotations() {
3775 let config_str = "\
3776Host web
3777 HostName 1.2.3.4
3778 # purple:tags prod
3779 # purple:provider do:12345
3780 # purple:askpass keychain
3781 # purple:meta region=nyc3
3782 # purple:stale 1711900000
3783";
3784 let config = parse_str(config_str);
3785 let entry = first_block(&config).to_host_entry();
3786 assert_eq!(entry.stale, Some(1711900000));
3787 assert!(entry.tags.contains(&"prod".to_string()));
3788 assert_eq!(entry.provider, Some("do".to_string()));
3789 assert_eq!(entry.askpass, Some("keychain".to_string()));
3790 assert_eq!(entry.provider_meta[0].0, "region");
3791 }
3792
3793 #[test]
3794 fn set_host_stale_delegates() {
3795 let config_str = "\
3796Host web
3797 HostName 1.2.3.4
3798
3799Host db
3800 HostName 5.6.7.8
3801";
3802 let mut config = parse_str(config_str);
3803 config.set_host_stale("db", 1234567890);
3804 assert_eq!(config.host_entries()[1].stale, Some(1234567890));
3805 assert_eq!(config.host_entries()[0].stale, None);
3806 }
3807
3808 #[test]
3809 fn clear_host_stale_delegates() {
3810 let config_str = "\
3811Host web
3812 HostName 1.2.3.4
3813 # purple:stale 1711900000
3814";
3815 let mut config = parse_str(config_str);
3816 config.clear_host_stale("web");
3817 assert_eq!(first_block(&config).stale(), None);
3818 }
3819
3820 #[test]
3821 fn stale_hosts_collects_all() {
3822 let config_str = "\
3823Host web
3824 HostName 1.2.3.4
3825 # purple:stale 1000
3826
3827Host db
3828 HostName 5.6.7.8
3829
3830Host app
3831 HostName 9.10.11.12
3832 # purple:stale 2000
3833";
3834 let config = parse_str(config_str);
3835 let stale = config.stale_hosts();
3836 assert_eq!(stale.len(), 2);
3837 assert_eq!(stale[0], ("web".to_string(), 1000));
3838 assert_eq!(stale[1], ("app".to_string(), 2000));
3839 }
3840
3841 #[test]
3842 fn set_stale_preserves_indent() {
3843 let config_str = "Host web\n\tHostName 1.2.3.4\n";
3844 let mut config = parse_str(config_str);
3845 first_block_mut(&mut config).set_stale(1711900000);
3846 assert!(config.serialize().contains("\t# purple:stale 1711900000"));
3847 }
3848
3849 #[test]
3850 fn stale_does_not_match_similar_comments() {
3851 let config_str = "\
3852Host web
3853 HostName 1.2.3.4
3854 # purple:stale_backup 999
3855";
3856 let config = parse_str(config_str);
3857 assert_eq!(first_block(&config).stale(), None);
3858 }
3859
3860 #[test]
3861 fn stale_with_whitespace_in_timestamp() {
3862 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 1711900000 \n";
3863 let config = parse_str(config_str);
3864 assert_eq!(first_block(&config).stale(), Some(1711900000));
3865 }
3866
3867 #[test]
3868 fn stale_with_u64_max() {
3869 let ts = u64::MAX;
3870 let config_str = format!("Host w\n HostName 1.2.3.4\n # purple:stale {}\n", ts);
3871 let config = parse_str(&config_str);
3872 assert_eq!(first_block(&config).stale(), Some(ts));
3873 let output = config.serialize();
3875 let config2 = parse_str(&output);
3876 assert_eq!(first_block(&config2).stale(), Some(ts));
3877 }
3878
3879 #[test]
3880 fn stale_with_u64_overflow() {
3881 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 18446744073709551616\n";
3882 let config = parse_str(config_str);
3883 assert_eq!(first_block(&config).stale(), None);
3884 }
3885
3886 #[test]
3887 fn stale_timestamp_zero() {
3888 let config_str = "Host w\n HostName 1.2.3.4\n # purple:stale 0\n";
3889 let config = parse_str(config_str);
3890 assert_eq!(first_block(&config).stale(), Some(0));
3891 }
3892
3893 #[test]
3894 fn set_host_stale_nonexistent_alias_is_noop() {
3895 let config_str = "Host web\n HostName 1.2.3.4\n";
3896 let mut config = parse_str(config_str);
3897 let before = config.serialize();
3898 config.set_host_stale("nonexistent", 12345);
3899 assert_eq!(config.serialize(), before);
3900 }
3901
3902 #[test]
3903 fn clear_host_stale_nonexistent_alias_is_noop() {
3904 let config_str = "Host web\n HostName 1.2.3.4\n";
3905 let mut config = parse_str(config_str);
3906 let before = config.serialize();
3907 config.clear_host_stale("nonexistent");
3908 assert_eq!(config.serialize(), before);
3909 }
3910
3911 #[test]
3912 fn stale_hosts_empty_config() {
3913 let config_str = "";
3914 let config = parse_str(config_str);
3915 assert!(config.stale_hosts().is_empty());
3916 }
3917
3918 #[test]
3919 fn stale_hosts_no_stale() {
3920 let config_str = "Host web\n HostName 1.2.3.4\n\nHost db\n HostName 5.6.7.8\n";
3921 let config = parse_str(config_str);
3922 assert!(config.stale_hosts().is_empty());
3923 }
3924
3925 #[test]
3926 fn clear_stale_preserves_other_purple_comments() {
3927 let config_str = "\
3928Host web
3929 HostName 1.2.3.4
3930 # purple:tags prod
3931 # purple:provider do:123
3932 # purple:askpass keychain
3933 # purple:meta region=nyc3
3934 # purple:stale 1711900000
3935";
3936 let mut config = parse_str(config_str);
3937 config.clear_host_stale("web");
3938 let entry = first_block(&config).to_host_entry();
3939 assert_eq!(entry.stale, None);
3940 assert!(entry.tags.contains(&"prod".to_string()));
3941 assert_eq!(entry.provider, Some("do".to_string()));
3942 assert_eq!(entry.askpass, Some("keychain".to_string()));
3943 assert_eq!(entry.provider_meta[0].0, "region");
3944 }
3945
3946 #[test]
3947 fn set_stale_preserves_other_purple_comments() {
3948 let config_str = "\
3949Host web
3950 HostName 1.2.3.4
3951 # purple:tags prod
3952 # purple:provider do:123
3953 # purple:askpass keychain
3954 # purple:meta region=nyc3
3955";
3956 let mut config = parse_str(config_str);
3957 config.set_host_stale("web", 1711900000);
3958 let entry = first_block(&config).to_host_entry();
3959 assert_eq!(entry.stale, Some(1711900000));
3960 assert!(entry.tags.contains(&"prod".to_string()));
3961 assert_eq!(entry.provider, Some("do".to_string()));
3962 assert_eq!(entry.askpass, Some("keychain".to_string()));
3963 assert_eq!(entry.provider_meta[0].0, "region");
3964 }
3965
3966 #[test]
3967 fn stale_multiple_comments_first_wins() {
3968 let config_str = "\
3969Host web
3970 HostName 1.2.3.4
3971 # purple:stale 1000
3972 # purple:stale 2000
3973";
3974 let config = parse_str(config_str);
3975 assert_eq!(first_block(&config).stale(), Some(1000));
3976 }
3977
3978 #[test]
3979 fn set_stale_removes_multiple_stale_comments() {
3980 let config_str = "\
3981Host web
3982 HostName 1.2.3.4
3983 # purple:stale 1000
3984 # purple:stale 2000
3985";
3986 let mut config = parse_str(config_str);
3987 first_block_mut(&mut config).set_stale(3000);
3988 assert_eq!(first_block(&config).stale(), Some(3000));
3989 let output = config.serialize();
3990 assert_eq!(output.matches("purple:stale").count(), 1);
3991 }
3992
3993 #[test]
3994 fn stale_absent_in_host_entry() {
3995 let config_str = "Host web\n HostName 1.2.3.4\n";
3996 let config = parse_str(config_str);
3997 assert_eq!(first_block(&config).to_host_entry().stale, None);
3998 }
3999
4000 #[test]
4001 fn set_stale_four_space_indent() {
4002 let config_str = "Host web\n HostName 1.2.3.4\n";
4003 let mut config = parse_str(config_str);
4004 first_block_mut(&mut config).set_stale(1711900000);
4005 assert!(config.serialize().contains(" # purple:stale 1711900000"));
4006 }
4007
4008 #[test]
4009 fn clear_stale_removes_bare_comment() {
4010 let config_str = "Host web\n HostName 1.2.3.4\n # purple:stale\n";
4011 let mut config = parse_str(config_str);
4012 first_block_mut(&mut config).clear_stale();
4013 assert!(!config.serialize().contains("purple:stale"));
4014 }
4015
4016 #[test]
4019 fn stale_preserves_blank_line_between_hosts() {
4020 let config_str = "\
4021Host web
4022 HostName 1.2.3.4
4023
4024Host db
4025 HostName 5.6.7.8
4026";
4027 let mut config = parse_str(config_str);
4028 config.set_host_stale("web", 1711900000);
4029 let output = config.serialize();
4030 assert!(
4032 output.contains("# purple:stale 1711900000\n\nHost db"),
4033 "blank line between hosts lost after set_stale:\n{}",
4034 output
4035 );
4036 }
4037
4038 #[test]
4039 fn stale_preserves_blank_line_before_group_header() {
4040 let config_str = "\
4041Host do-web
4042 HostName 1.2.3.4
4043 # purple:provider digitalocean:111
4044
4045# purple:group Hetzner
4046
4047Host hz-cache
4048 HostName 9.10.11.12
4049 # purple:provider hetzner:333
4050";
4051 let mut config = parse_str(config_str);
4052 config.set_host_stale("do-web", 1711900000);
4053 let output = config.serialize();
4054 assert!(
4056 output.contains("\n\n# purple:group Hetzner"),
4057 "blank line before group header lost after set_stale:\n{}",
4058 output
4059 );
4060 }
4061
4062 #[test]
4063 fn stale_set_and_clear_is_byte_identical() {
4064 let config_str = "\
4065Host manual
4066 HostName 10.0.0.1
4067 User admin
4068
4069# purple:group DigitalOcean
4070
4071Host do-web
4072 HostName 1.2.3.4
4073 User root
4074 # purple:provider digitalocean:111
4075 # purple:tags prod
4076
4077Host do-db
4078 HostName 5.6.7.8
4079 User root
4080 # purple:provider digitalocean:222
4081 # purple:meta region=nyc3
4082
4083# purple:group Hetzner
4084
4085Host hz-cache
4086 HostName 9.10.11.12
4087 User root
4088 # purple:provider hetzner:333
4089";
4090 let original = config_str.to_string();
4091 let mut config = parse_str(config_str);
4092
4093 config.set_host_stale("do-db", 1711900000);
4095 let after_stale = config.serialize();
4096 assert_ne!(after_stale, original, "stale should change the config");
4097
4098 config.clear_host_stale("do-db");
4100 let after_clear = config.serialize();
4101 assert_eq!(
4102 after_clear, original,
4103 "clearing stale must restore byte-identical config"
4104 );
4105 }
4106
4107 #[test]
4108 fn stale_does_not_accumulate_blank_lines() {
4109 let config_str = "Host web\n HostName 1.2.3.4\n\nHost db\n HostName 5.6.7.8\n";
4110 let mut config = parse_str(config_str);
4111
4112 for _ in 0..10 {
4114 config.set_host_stale("web", 1711900000);
4115 config.clear_host_stale("web");
4116 }
4117
4118 let output = config.serialize();
4119 assert_eq!(
4120 output, config_str,
4121 "repeated set/clear must not accumulate blank lines"
4122 );
4123 }
4124
4125 #[test]
4126 fn stale_preserves_all_directives_and_comments() {
4127 let config_str = "\
4128Host complex
4129 HostName 1.2.3.4
4130 User deploy
4131 Port 2222
4132 IdentityFile ~/.ssh/id_ed25519
4133 ProxyJump bastion
4134 LocalForward 8080 localhost:80
4135 # purple:provider digitalocean:999
4136 # purple:tags prod,us-east
4137 # purple:provider_tags web-tier
4138 # purple:askpass keychain
4139 # purple:meta region=nyc3,plan=s-1vcpu-1gb
4140 # This is a user comment
4141";
4142 let mut config = parse_str(config_str);
4143 let entry_before = first_block(&config).to_host_entry();
4144
4145 config.set_host_stale("complex", 1711900000);
4146 let entry_after = first_block(&config).to_host_entry();
4147
4148 assert_eq!(entry_after.hostname, entry_before.hostname);
4150 assert_eq!(entry_after.user, entry_before.user);
4151 assert_eq!(entry_after.port, entry_before.port);
4152 assert_eq!(entry_after.identity_file, entry_before.identity_file);
4153 assert_eq!(entry_after.proxy_jump, entry_before.proxy_jump);
4154 assert_eq!(entry_after.tags, entry_before.tags);
4155 assert_eq!(entry_after.provider_tags, entry_before.provider_tags);
4156 assert_eq!(entry_after.provider, entry_before.provider);
4157 assert_eq!(entry_after.askpass, entry_before.askpass);
4158 assert_eq!(entry_after.provider_meta, entry_before.provider_meta);
4159 assert_eq!(entry_after.tunnel_count, entry_before.tunnel_count);
4160 assert_eq!(entry_after.stale, Some(1711900000));
4161
4162 config.clear_host_stale("complex");
4164 let entry_cleared = first_block(&config).to_host_entry();
4165 assert_eq!(entry_cleared.stale, None);
4166 assert_eq!(entry_cleared.hostname, entry_before.hostname);
4167 assert_eq!(entry_cleared.tags, entry_before.tags);
4168 assert_eq!(entry_cleared.provider, entry_before.provider);
4169 assert_eq!(entry_cleared.askpass, entry_before.askpass);
4170 assert_eq!(entry_cleared.provider_meta, entry_before.provider_meta);
4171
4172 assert!(config.serialize().contains("# This is a user comment"));
4174 }
4175
4176 #[test]
4177 fn stale_on_last_host_preserves_trailing_newline() {
4178 let config_str = "Host web\n HostName 1.2.3.4\n";
4179 let mut config = parse_str(config_str);
4180 config.set_host_stale("web", 1711900000);
4181 let output = config.serialize();
4182 assert!(output.ends_with('\n'), "config must end with newline");
4183
4184 config.clear_host_stale("web");
4185 let output2 = config.serialize();
4186 assert_eq!(output2, config_str);
4187 }
4188
4189 #[test]
4190 fn stale_with_crlf_preserves_line_endings() {
4191 let config_str = "Host web\r\n HostName 1.2.3.4\r\n";
4192 let config = SshConfigFile {
4193 elements: SshConfigFile::parse_content(config_str),
4194 path: std::path::PathBuf::from("/tmp/test"),
4195 crlf: true,
4196 bom: false,
4197 };
4198 let mut config = config;
4199 config.set_host_stale("web", 1711900000);
4200 let output = config.serialize();
4201 for line in output.split('\n') {
4203 if !line.is_empty() {
4204 assert!(
4205 line.ends_with('\r'),
4206 "CRLF lost after set_stale. Line: {:?}",
4207 line
4208 );
4209 }
4210 }
4211
4212 config.clear_host_stale("web");
4213 assert_eq!(config.serialize(), config_str);
4214 }
4215
4216 #[test]
4217 fn pattern_match_star_wildcard() {
4218 assert!(ssh_pattern_match("*", "anything"));
4219 assert!(ssh_pattern_match("10.30.0.*", "10.30.0.5"));
4220 assert!(ssh_pattern_match("10.30.0.*", "10.30.0.100"));
4221 assert!(!ssh_pattern_match("10.30.0.*", "10.30.1.5"));
4222 assert!(ssh_pattern_match("*.example.com", "web.example.com"));
4223 assert!(!ssh_pattern_match("*.example.com", "example.com"));
4224 assert!(ssh_pattern_match("prod-*-web", "prod-us-web"));
4225 assert!(!ssh_pattern_match("prod-*-web", "prod-us-api"));
4226 }
4227
4228 #[test]
4229 fn pattern_match_question_mark() {
4230 assert!(ssh_pattern_match("server-?", "server-1"));
4231 assert!(ssh_pattern_match("server-?", "server-a"));
4232 assert!(!ssh_pattern_match("server-?", "server-10"));
4233 assert!(!ssh_pattern_match("server-?", "server-"));
4234 }
4235
4236 #[test]
4237 fn pattern_match_character_class() {
4238 assert!(ssh_pattern_match("server-[abc]", "server-a"));
4239 assert!(ssh_pattern_match("server-[abc]", "server-c"));
4240 assert!(!ssh_pattern_match("server-[abc]", "server-d"));
4241 assert!(ssh_pattern_match("server-[0-9]", "server-5"));
4242 assert!(!ssh_pattern_match("server-[0-9]", "server-a"));
4243 assert!(ssh_pattern_match("server-[!abc]", "server-d"));
4244 assert!(!ssh_pattern_match("server-[!abc]", "server-a"));
4245 assert!(ssh_pattern_match("server-[^abc]", "server-d"));
4246 assert!(!ssh_pattern_match("server-[^abc]", "server-a"));
4247 }
4248
4249 #[test]
4250 fn pattern_match_negation() {
4251 assert!(!ssh_pattern_match("!prod-*", "prod-web"));
4252 assert!(ssh_pattern_match("!prod-*", "staging-web"));
4253 }
4254
4255 #[test]
4256 fn pattern_match_exact() {
4257 assert!(ssh_pattern_match("myserver", "myserver"));
4258 assert!(!ssh_pattern_match("myserver", "myserver2"));
4259 assert!(!ssh_pattern_match("myserver", "other"));
4260 }
4261
4262 #[test]
4263 fn pattern_match_empty() {
4264 assert!(!ssh_pattern_match("", "anything"));
4265 assert!(!ssh_pattern_match("*", ""));
4266 assert!(ssh_pattern_match("", ""));
4267 }
4268
4269 #[test]
4270 fn host_pattern_matches_multi_pattern() {
4271 assert!(host_pattern_matches("prod staging", "prod"));
4272 assert!(host_pattern_matches("prod staging", "staging"));
4273 assert!(!host_pattern_matches("prod staging", "dev"));
4274 }
4275
4276 #[test]
4277 fn host_pattern_matches_with_negation() {
4278 assert!(host_pattern_matches(
4279 "*.example.com !internal.example.com",
4280 "web.example.com",
4281 ));
4282 assert!(!host_pattern_matches(
4283 "*.example.com !internal.example.com",
4284 "internal.example.com",
4285 ));
4286 }
4287
4288 #[test]
4289 fn host_pattern_matches_alias_only() {
4290 assert!(!host_pattern_matches("10.30.0.*", "production"));
4292 assert!(host_pattern_matches("prod*", "production"));
4293 assert!(!host_pattern_matches("staging*", "production"));
4294 }
4295
4296 #[test]
4297 fn pattern_entries_collects_wildcards() {
4298 let config = parse_str(
4299 "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",
4300 );
4301 let patterns = config.pattern_entries();
4302 assert_eq!(patterns.len(), 2);
4303 assert_eq!(patterns[0].pattern, "10.30.0.*");
4304 assert_eq!(patterns[0].user, "debian");
4305 assert_eq!(patterns[0].proxy_jump, "bastion");
4306 assert_eq!(patterns[1].pattern, "*");
4307 assert!(
4308 patterns[1]
4309 .directives
4310 .iter()
4311 .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
4312 );
4313 }
4314
4315 #[test]
4316 fn pattern_entries_empty_when_no_patterns() {
4317 let config = parse_str("Host myserver\n Hostname 10.0.0.1\n");
4318 let patterns = config.pattern_entries();
4319 assert!(patterns.is_empty());
4320 }
4321
4322 #[test]
4323 fn matching_patterns_returns_in_config_order() {
4324 let config = parse_str(
4325 "Host 10.30.0.*\n User debian\n\nHost myserver\n Hostname 10.30.0.5\n\nHost *\n ServerAliveInterval 60\n",
4326 );
4327 let matches = config.matching_patterns("myserver");
4329 assert_eq!(matches.len(), 1);
4330 assert_eq!(matches[0].pattern, "*");
4331 }
4332
4333 #[test]
4334 fn matching_patterns_negation_excludes() {
4335 let config = parse_str(
4336 "Host * !bastion\n ServerAliveInterval 60\n\nHost bastion\n Hostname 10.0.0.1\n",
4337 );
4338 let matches = config.matching_patterns("bastion");
4339 assert!(matches.is_empty());
4340 }
4341
4342 #[test]
4343 fn pattern_entries_and_host_entries_are_disjoint() {
4344 let config = parse_str(
4345 "Host myserver\n Hostname 10.0.0.1\n\nHost 10.30.0.*\n User debian\n\nHost *\n ServerAliveInterval 60\n",
4346 );
4347 let hosts = config.host_entries();
4348 let patterns = config.pattern_entries();
4349 assert_eq!(hosts.len(), 1);
4350 assert_eq!(hosts[0].alias, "myserver");
4351 assert_eq!(patterns.len(), 2);
4352 assert_eq!(patterns[0].pattern, "10.30.0.*");
4353 assert_eq!(patterns[1].pattern, "*");
4354 }
4355
4356 #[test]
4357 fn pattern_crud_round_trip() {
4358 let mut config = parse_str("Host myserver\n Hostname 10.0.0.1\n");
4359 let entry = HostEntry {
4361 alias: "10.30.0.*".to_string(),
4362 user: "debian".to_string(),
4363 ..Default::default()
4364 };
4365 config.add_host(&entry);
4366 let output = config.serialize();
4367 assert!(output.contains("Host 10.30.0.*"));
4368 assert!(output.contains("User debian"));
4369 let reparsed = parse_str(&output);
4371 assert_eq!(reparsed.host_entries().len(), 1);
4372 assert_eq!(reparsed.pattern_entries().len(), 1);
4373 assert_eq!(reparsed.pattern_entries()[0].pattern, "10.30.0.*");
4374 }
4375
4376 #[test]
4377 fn host_entries_inherit_proxy_jump_from_wildcard_pattern() {
4378 let config =
4380 parse_str("Host web-*\n ProxyJump bastion\n\nHost web-prod\n Hostname 10.0.0.1\n");
4381 let hosts = config.host_entries();
4382 assert_eq!(hosts.len(), 1);
4383 assert_eq!(hosts[0].alias, "web-prod");
4384 assert_eq!(hosts[0].proxy_jump, "bastion");
4385 }
4386
4387 #[test]
4388 fn host_entries_inherit_proxy_jump_from_star_pattern() {
4389 let config = parse_str(
4391 "Host myserver\n Hostname 10.0.0.1\n\nHost *\n ProxyJump gateway\n User admin\n",
4392 );
4393 let hosts = config.host_entries();
4394 assert_eq!(hosts.len(), 1);
4395 assert_eq!(hosts[0].proxy_jump, "gateway");
4396 assert_eq!(hosts[0].user, "admin");
4397 }
4398
4399 #[test]
4400 fn host_entries_own_proxy_jump_takes_precedence() {
4401 let config = parse_str(
4403 "Host web-*\n ProxyJump gateway\n\nHost web-prod\n Hostname 10.0.0.1\n ProxyJump bastion\n",
4404 );
4405 let hosts = config.host_entries();
4406 assert_eq!(hosts.len(), 1);
4407 assert_eq!(hosts[0].proxy_jump, "bastion"); }
4409
4410 #[test]
4411 fn host_entries_hostname_pattern_does_not_match_by_hostname() {
4412 let config = parse_str(
4415 "Host 10.30.0.*\n ProxyJump bastion\n User debian\n\nHost myserver\n Hostname 10.30.0.5\n",
4416 );
4417 let hosts = config.host_entries();
4418 assert_eq!(hosts.len(), 1);
4419 assert_eq!(hosts[0].alias, "myserver");
4420 assert_eq!(hosts[0].proxy_jump, ""); assert_eq!(hosts[0].user, ""); }
4423
4424 #[test]
4425 fn host_entries_first_match_wins() {
4426 let config = parse_str(
4428 "Host web-*\n User team\n\nHost *\n User fallback\n\nHost web-prod\n Hostname 10.0.0.1\n",
4429 );
4430 let hosts = config.host_entries();
4431 assert_eq!(hosts.len(), 1);
4432 assert_eq!(hosts[0].user, "team"); }
4434
4435 #[test]
4436 fn host_entries_no_inheritance_when_all_set() {
4437 let config = parse_str(
4439 "Host *\n User fallback\n ProxyJump gw\n IdentityFile ~/.ssh/other\n\n\
4440 Host myserver\n Hostname 10.0.0.1\n User root\n ProxyJump bastion\n IdentityFile ~/.ssh/mine\n",
4441 );
4442 let hosts = config.host_entries();
4443 assert_eq!(hosts.len(), 1);
4444 assert_eq!(hosts[0].user, "root");
4445 assert_eq!(hosts[0].proxy_jump, "bastion");
4446 assert_eq!(hosts[0].identity_file, "~/.ssh/mine");
4447 }
4448
4449 #[test]
4450 fn host_entries_negation_excludes_from_inheritance() {
4451 let config = parse_str(
4453 "Host * !bastion\n ProxyJump gateway\n\nHost bastion\n Hostname 10.0.0.1\n",
4454 );
4455 let hosts = config.host_entries();
4456 assert_eq!(hosts.len(), 1);
4457 assert_eq!(hosts[0].alias, "bastion");
4458 assert_eq!(hosts[0].proxy_jump, ""); }
4460
4461 #[test]
4462 fn host_entries_inherit_identity_file_from_pattern() {
4463 let config = parse_str(
4465 "Host *\n IdentityFile ~/.ssh/default_key\n\nHost myserver\n Hostname 10.0.0.1\n",
4466 );
4467 let hosts = config.host_entries();
4468 assert_eq!(hosts.len(), 1);
4469 assert_eq!(hosts[0].identity_file, "~/.ssh/default_key");
4470 }
4471
4472 #[test]
4473 fn host_entries_multiple_hosts_mixed_inheritance() {
4474 let config = parse_str(
4476 "Host web-*\n ProxyJump bastion\n\n\
4477 Host web-prod\n Hostname 10.0.0.1\n\n\
4478 Host web-staging\n Hostname 10.0.0.2\n ProxyJump gateway\n\n\
4479 Host bastion\n Hostname 10.0.0.99\n",
4480 );
4481 let hosts = config.host_entries();
4482 assert_eq!(hosts.len(), 3);
4483 let prod = hosts.iter().find(|h| h.alias == "web-prod").unwrap();
4484 let staging = hosts.iter().find(|h| h.alias == "web-staging").unwrap();
4485 let bastion = hosts.iter().find(|h| h.alias == "bastion").unwrap();
4486 assert_eq!(prod.proxy_jump, "bastion"); assert_eq!(staging.proxy_jump, "gateway"); assert_eq!(bastion.proxy_jump, ""); }
4490
4491 #[test]
4492 fn host_entries_partial_inheritance() {
4493 let config = parse_str(
4495 "Host *\n User fallback\n ProxyJump gw\n IdentityFile ~/.ssh/default\n\n\
4496 Host myserver\n Hostname 10.0.0.1\n User root\n ProxyJump bastion\n",
4497 );
4498 let hosts = config.host_entries();
4499 assert_eq!(hosts.len(), 1);
4500 assert_eq!(hosts[0].user, "root"); assert_eq!(hosts[0].proxy_jump, "bastion"); assert_eq!(hosts[0].identity_file, "~/.ssh/default"); }
4504
4505 #[test]
4506 fn host_entries_alias_is_ip_matches_ip_pattern() {
4507 let config =
4509 parse_str("Host 10.0.0.*\n ProxyJump bastion\n\nHost 10.0.0.5\n User root\n");
4510 let hosts = config.host_entries();
4511 assert_eq!(hosts.len(), 1);
4512 assert_eq!(hosts[0].alias, "10.0.0.5");
4513 assert_eq!(hosts[0].proxy_jump, "bastion");
4514 }
4515
4516 #[test]
4517 fn host_entries_no_hostname_still_inherits_by_alias() {
4518 let config = parse_str("Host *\n User admin\n\nHost myserver\n Port 2222\n");
4520 let hosts = config.host_entries();
4521 assert_eq!(hosts.len(), 1);
4522 assert_eq!(hosts[0].user, "admin"); assert!(hosts[0].hostname.is_empty()); }
4525
4526 #[test]
4527 fn host_entries_self_referencing_proxy_jump_assigned() {
4528 let config = parse_str(
4531 "Host *\n ProxyJump gateway\n\n\
4532 Host gateway\n Hostname 10.0.0.1\n\n\
4533 Host backend\n Hostname 10.0.0.2\n",
4534 );
4535 let hosts = config.host_entries();
4536 let gateway = hosts.iter().find(|h| h.alias == "gateway").unwrap();
4537 let backend = hosts.iter().find(|h| h.alias == "backend").unwrap();
4538 assert_eq!(gateway.proxy_jump, "gateway"); assert_eq!(backend.proxy_jump, "gateway");
4540 assert!(proxy_jump_contains_self(
4542 &gateway.proxy_jump,
4543 &gateway.alias
4544 ));
4545 assert!(!proxy_jump_contains_self(
4546 &backend.proxy_jump,
4547 &backend.alias
4548 ));
4549 }
4550
4551 #[test]
4552 fn proxy_jump_contains_self_comma_separated() {
4553 assert!(proxy_jump_contains_self("hop1,gateway", "gateway"));
4554 assert!(proxy_jump_contains_self("gateway,hop2", "gateway"));
4555 assert!(proxy_jump_contains_self("hop1, gateway", "gateway"));
4556 assert!(proxy_jump_contains_self("gateway", "gateway"));
4557 assert!(!proxy_jump_contains_self("hop1,hop2", "gateway"));
4558 assert!(!proxy_jump_contains_self("", "gateway"));
4559 assert!(!proxy_jump_contains_self("gateway-2", "gateway"));
4560 assert!(proxy_jump_contains_self("admin@gateway", "gateway"));
4562 assert!(proxy_jump_contains_self("gateway:2222", "gateway"));
4563 assert!(proxy_jump_contains_self("admin@gateway:2222", "gateway"));
4564 assert!(proxy_jump_contains_self(
4565 "hop1,admin@gateway:2222",
4566 "gateway"
4567 ));
4568 assert!(!proxy_jump_contains_self("admin@gateway-2", "gateway"));
4569 assert!(!proxy_jump_contains_self("admin@other:2222", "gateway"));
4570 assert!(proxy_jump_contains_self("[::1]:2222", "::1"));
4572 assert!(proxy_jump_contains_self("user@[::1]:2222", "::1"));
4573 assert!(!proxy_jump_contains_self("[::2]:2222", "::1"));
4574 assert!(proxy_jump_contains_self("hop1,[::1]:2222", "::1"));
4575 }
4576
4577 #[test]
4582 fn raw_host_entry_returns_without_inheritance() {
4583 let config = parse_str(
4584 "Host *\n ProxyJump gw\n User admin\n\nHost myserver\n Hostname 10.0.0.1\n",
4585 );
4586 let raw = config.raw_host_entry("myserver").unwrap();
4587 assert_eq!(raw.alias, "myserver");
4588 assert_eq!(raw.hostname, "10.0.0.1");
4589 assert_eq!(raw.proxy_jump, ""); assert_eq!(raw.user, ""); let enriched = config.host_entries();
4593 assert_eq!(enriched[0].proxy_jump, "gw");
4594 assert_eq!(enriched[0].user, "admin");
4595 }
4596
4597 #[test]
4598 fn raw_host_entry_preserves_own_values() {
4599 let config = parse_str(
4600 "Host *\n ProxyJump gw\n\nHost myserver\n Hostname 10.0.0.1\n ProxyJump bastion\n",
4601 );
4602 let raw = config.raw_host_entry("myserver").unwrap();
4603 assert_eq!(raw.proxy_jump, "bastion"); }
4605
4606 #[test]
4607 fn raw_host_entry_returns_none_for_missing() {
4608 let config = parse_str("Host myserver\n Hostname 10.0.0.1\n");
4609 assert!(config.raw_host_entry("nonexistent").is_none());
4610 }
4611
4612 #[test]
4613 fn raw_host_entry_returns_none_for_pattern() {
4614 let config = parse_str("Host 10.30.0.*\n ProxyJump bastion\n");
4615 assert!(config.raw_host_entry("10.30.0.*").is_none());
4616 }
4617
4618 #[test]
4623 fn inherited_hints_returns_value_and_source() {
4624 let config = parse_str(
4625 "Host web-*\n ProxyJump bastion\n User team\n\nHost web-prod\n Hostname 10.0.0.1\n",
4626 );
4627 let hints = config.inherited_hints("web-prod");
4628 let (val, src) = hints.proxy_jump.unwrap();
4629 assert_eq!(val, "bastion");
4630 assert_eq!(src, "web-*");
4631 let (val, src) = hints.user.unwrap();
4632 assert_eq!(val, "team");
4633 assert_eq!(src, "web-*");
4634 assert!(hints.identity_file.is_none());
4635 }
4636
4637 #[test]
4638 fn inherited_hints_first_match_wins_with_source() {
4639 let config = parse_str(
4640 "Host web-*\n User team\n\nHost *\n User fallback\n ProxyJump gw\n\nHost web-prod\n Hostname 10.0.0.1\n",
4641 );
4642 let hints = config.inherited_hints("web-prod");
4643 let (val, src) = hints.user.unwrap();
4645 assert_eq!(val, "team");
4646 assert_eq!(src, "web-*");
4647 let (val, src) = hints.proxy_jump.unwrap();
4649 assert_eq!(val, "gw");
4650 assert_eq!(src, "*");
4651 }
4652
4653 #[test]
4654 fn inherited_hints_no_match_returns_default() {
4655 let config =
4656 parse_str("Host web-*\n ProxyJump bastion\n\nHost myserver\n Hostname 10.0.0.1\n");
4657 let hints = config.inherited_hints("myserver");
4658 assert!(hints.proxy_jump.is_none());
4660 assert!(hints.user.is_none());
4661 assert!(hints.identity_file.is_none());
4662 }
4663
4664 #[test]
4665 fn inherited_hints_partial_fields_from_different_patterns() {
4666 let config = parse_str(
4667 "Host web-*\n ProxyJump bastion\n\nHost *\n IdentityFile ~/.ssh/default\n\nHost web-prod\n Hostname 10.0.0.1\n",
4668 );
4669 let hints = config.inherited_hints("web-prod");
4670 let (val, src) = hints.proxy_jump.unwrap();
4671 assert_eq!(val, "bastion");
4672 assert_eq!(src, "web-*");
4673 let (val, src) = hints.identity_file.unwrap();
4674 assert_eq!(val, "~/.ssh/default");
4675 assert_eq!(src, "*");
4676 assert!(hints.user.is_none());
4677 }
4678
4679 #[test]
4680 fn inherited_hints_negation_excludes() {
4681 let config = parse_str(
4683 "Host * !bastion\n ProxyJump gateway\n User admin\n\n\
4684 Host bastion\n Hostname 10.0.0.1\n",
4685 );
4686 let hints = config.inherited_hints("bastion");
4687 assert!(hints.proxy_jump.is_none());
4688 assert!(hints.user.is_none());
4689 }
4690
4691 #[test]
4692 fn inherited_hints_returned_even_when_host_has_own_values() {
4693 let config = parse_str(
4696 "Host *\n ProxyJump gateway\n User admin\n\n\
4697 Host myserver\n Hostname 10.0.0.1\n ProxyJump bastion\n User root\n",
4698 );
4699 let hints = config.inherited_hints("myserver");
4700 let (val, _) = hints.proxy_jump.unwrap();
4702 assert_eq!(val, "gateway");
4703 let (val, _) = hints.user.unwrap();
4704 assert_eq!(val, "admin");
4705 }
4706
4707 #[test]
4708 fn inheritance_across_include_boundary() {
4709 let included_elements =
4711 SshConfigFile::parse_content("Host web-*\n ProxyJump bastion\n User team\n");
4712 let main_elements = vec![
4713 ConfigElement::Include(IncludeDirective {
4714 raw_line: "Include conf.d/*".to_string(),
4715 pattern: "conf.d/*".to_string(),
4716 resolved_files: vec![IncludedFile {
4717 path: PathBuf::from("/etc/ssh/conf.d/patterns.conf"),
4718 elements: included_elements,
4719 }],
4720 }),
4721 ConfigElement::HostBlock(HostBlock {
4723 host_pattern: "web-prod".to_string(),
4724 raw_host_line: "Host web-prod".to_string(),
4725 directives: vec![Directive {
4726 key: "HostName".to_string(),
4727 value: "10.0.0.1".to_string(),
4728 raw_line: " HostName 10.0.0.1".to_string(),
4729 is_non_directive: false,
4730 }],
4731 }),
4732 ];
4733 let config = SshConfigFile {
4734 elements: main_elements,
4735 path: PathBuf::from("/tmp/test_config"),
4736 crlf: false,
4737 bom: false,
4738 };
4739 let hosts = config.host_entries();
4741 assert_eq!(hosts.len(), 1);
4742 assert_eq!(hosts[0].alias, "web-prod");
4743 assert_eq!(hosts[0].proxy_jump, "bastion");
4744 assert_eq!(hosts[0].user, "team");
4745 let hints = config.inherited_hints("web-prod");
4747 let (val, src) = hints.proxy_jump.unwrap();
4748 assert_eq!(val, "bastion");
4749 assert_eq!(src, "web-*");
4750 }
4751
4752 #[test]
4753 fn inheritance_host_in_include_pattern_in_main() {
4754 let included_elements =
4756 SshConfigFile::parse_content("Host web-prod\n HostName 10.0.0.1\n");
4757 let mut main_elements = SshConfigFile::parse_content("Host web-*\n ProxyJump bastion\n");
4758 main_elements.push(ConfigElement::Include(IncludeDirective {
4759 raw_line: "Include conf.d/*".to_string(),
4760 pattern: "conf.d/*".to_string(),
4761 resolved_files: vec![IncludedFile {
4762 path: PathBuf::from("/etc/ssh/conf.d/hosts.conf"),
4763 elements: included_elements,
4764 }],
4765 }));
4766 let config = SshConfigFile {
4767 elements: main_elements,
4768 path: PathBuf::from("/tmp/test_config"),
4769 crlf: false,
4770 bom: false,
4771 };
4772 let hosts = config.host_entries();
4773 assert_eq!(hosts.len(), 1);
4774 assert_eq!(hosts[0].alias, "web-prod");
4775 assert_eq!(hosts[0].proxy_jump, "bastion");
4776 }
4777
4778 #[test]
4779 fn matching_patterns_full_ssh_semantics() {
4780 let config = parse_str(
4781 "Host 10.30.0.*\n User debian\n IdentityFile ~/.ssh/id_bootstrap\n ProxyJump bastion\n\n\
4782 Host *.internal !secret.internal\n ForwardAgent yes\n\n\
4783 Host myserver\n Hostname 10.30.0.5\n\n\
4784 Host *\n ServerAliveInterval 60\n",
4785 );
4786 let matches = config.matching_patterns("myserver");
4788 assert_eq!(matches.len(), 1);
4789 assert_eq!(matches[0].pattern, "*");
4790 assert!(
4791 matches[0]
4792 .directives
4793 .iter()
4794 .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
4795 );
4796 }
4797
4798 #[test]
4799 fn pattern_entries_preserve_all_directives() {
4800 let config = parse_str(
4801 "Host *.example.com\n User admin\n Port 2222\n IdentityFile ~/.ssh/id_example\n ProxyJump gateway\n ServerAliveInterval 30\n ForwardAgent yes\n",
4802 );
4803 let patterns = config.pattern_entries();
4804 assert_eq!(patterns.len(), 1);
4805 let p = &patterns[0];
4806 assert_eq!(p.pattern, "*.example.com");
4807 assert_eq!(p.user, "admin");
4808 assert_eq!(p.port, 2222);
4809 assert_eq!(p.identity_file, "~/.ssh/id_example");
4810 assert_eq!(p.proxy_jump, "gateway");
4811 assert_eq!(p.directives.len(), 6);
4813 assert!(
4814 p.directives
4815 .iter()
4816 .any(|(k, v)| k == "ForwardAgent" && v == "yes")
4817 );
4818 assert!(
4819 p.directives
4820 .iter()
4821 .any(|(k, v)| k == "ServerAliveInterval" && v == "30")
4822 );
4823 }
4824
4825 #[test]
4828 fn roundtrip_pattern_blocks_preserved() {
4829 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";
4830 let config = parse_str(input);
4831 let output = config.serialize();
4832 assert_eq!(
4833 input, output,
4834 "Pattern blocks must survive round-trip exactly"
4835 );
4836 }
4837
4838 #[test]
4839 fn add_pattern_preserves_existing_config() {
4840 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";
4841 let mut config = parse_str(input);
4842 let entry = HostEntry {
4843 alias: "10.30.0.*".to_string(),
4844 user: "debian".to_string(),
4845 ..Default::default()
4846 };
4847 config.add_host(&entry);
4848 let output = config.serialize();
4849 assert!(output.contains("Host myserver"));
4851 assert!(output.contains("Hostname 10.0.0.1"));
4852 assert!(output.contains("Host otherserver"));
4853 assert!(output.contains("Hostname 10.0.0.2"));
4854 assert!(output.contains("Host 10.30.0.*"));
4856 assert!(output.contains("User debian"));
4857 assert!(output.contains("Host *"));
4859 let new_pos = output.find("Host 10.30.0.*").unwrap();
4861 let star_pos = output.find("Host *").unwrap();
4862 assert!(new_pos < star_pos, "New pattern must be before Host *");
4863 let reparsed = parse_str(&output);
4865 assert_eq!(reparsed.host_entries().len(), 2);
4866 assert_eq!(reparsed.pattern_entries().len(), 2); }
4868
4869 #[test]
4870 fn update_pattern_preserves_other_blocks() {
4871 let input = "Host myserver\n Hostname 10.0.0.1\n\nHost 10.30.0.*\n User debian\n\nHost *\n ServerAliveInterval 60\n";
4872 let mut config = parse_str(input);
4873 let updated = HostEntry {
4874 alias: "10.30.0.*".to_string(),
4875 user: "admin".to_string(),
4876 ..Default::default()
4877 };
4878 config.update_host("10.30.0.*", &updated);
4879 let output = config.serialize();
4880 assert!(output.contains("User admin"));
4882 assert!(!output.contains("User debian"));
4883 assert!(output.contains("Host myserver"));
4885 assert!(output.contains("Hostname 10.0.0.1"));
4886 assert!(output.contains("Host *"));
4887 assert!(output.contains("ServerAliveInterval 60"));
4888 }
4889
4890 #[test]
4891 fn delete_pattern_preserves_other_blocks() {
4892 let input = "Host myserver\n Hostname 10.0.0.1\n\nHost 10.30.0.*\n User debian\n\nHost *\n ServerAliveInterval 60\n";
4893 let mut config = parse_str(input);
4894 config.delete_host("10.30.0.*");
4895 let output = config.serialize();
4896 assert!(!output.contains("Host 10.30.0.*"));
4897 assert!(!output.contains("User debian"));
4898 assert!(output.contains("Host myserver"));
4899 assert!(output.contains("Hostname 10.0.0.1"));
4900 assert!(output.contains("Host *"));
4901 assert!(output.contains("ServerAliveInterval 60"));
4902 let reparsed = parse_str(&output);
4903 assert_eq!(reparsed.host_entries().len(), 1);
4904 assert_eq!(reparsed.pattern_entries().len(), 1); }
4906
4907 #[test]
4908 fn update_pattern_rename() {
4909 let input = "Host *.example.com\n User admin\n\nHost myserver\n Hostname 10.0.0.1\n";
4910 let mut config = parse_str(input);
4911 let renamed = HostEntry {
4912 alias: "*.prod.example.com".to_string(),
4913 user: "admin".to_string(),
4914 ..Default::default()
4915 };
4916 config.update_host("*.example.com", &renamed);
4917 let output = config.serialize();
4918 assert!(
4919 !output.contains("Host *.example.com\n"),
4920 "Old pattern removed"
4921 );
4922 assert!(
4923 output.contains("Host *.prod.example.com"),
4924 "New pattern present"
4925 );
4926 assert!(output.contains("Host myserver"), "Other host preserved");
4927 }
4928
4929 #[test]
4930 fn config_with_only_patterns() {
4931 let input = "Host *.example.com\n User admin\n\nHost *\n ServerAliveInterval 60\n";
4932 let config = parse_str(input);
4933 assert!(config.host_entries().is_empty());
4934 assert_eq!(config.pattern_entries().len(), 2);
4935 let output = config.serialize();
4937 assert_eq!(input, output);
4938 }
4939
4940 #[test]
4941 fn host_pattern_matches_all_negative_returns_false() {
4942 assert!(!host_pattern_matches("!prod !staging", "anything"));
4943 assert!(!host_pattern_matches("!prod !staging", "dev"));
4944 }
4945
4946 #[test]
4947 fn host_pattern_matches_negation_only_checks_alias() {
4948 assert!(host_pattern_matches("* !10.0.0.1", "myserver"));
4950 assert!(!host_pattern_matches("* !myserver", "myserver"));
4951 }
4952
4953 #[test]
4954 fn pattern_match_malformed_char_class() {
4955 assert!(!ssh_pattern_match("[abc", "a"));
4957 assert!(!ssh_pattern_match("[", "a"));
4958 assert!(!ssh_pattern_match("[]", "a"));
4960 }
4961
4962 #[test]
4963 fn host_pattern_matches_whitespace_edge_cases() {
4964 assert!(host_pattern_matches("prod staging", "prod"));
4965 assert!(host_pattern_matches(" prod ", "prod"));
4966 assert!(host_pattern_matches("prod\tstaging", "prod"));
4967 assert!(!host_pattern_matches(" ", "anything"));
4968 assert!(!host_pattern_matches("", "anything"));
4969 }
4970
4971 #[test]
4972 fn pattern_with_metadata_roundtrip() {
4973 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";
4974 let config = parse_str(input);
4975 let patterns = config.pattern_entries();
4976 assert_eq!(patterns.len(), 1);
4977 assert_eq!(patterns[0].tags, vec!["internal", "vpn"]);
4978 assert_eq!(patterns[0].askpass.as_deref(), Some("keychain"));
4979 let output = config.serialize();
4981 assert_eq!(input, output);
4982 }
4983
4984 #[test]
4985 fn matching_patterns_multiple_in_config_order() {
4986 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";
4988 let config = parse_str(input);
4989 let matches = config.matching_patterns("my-10-server");
4990 assert_eq!(matches.len(), 4);
4991 assert_eq!(matches[0].pattern, "my-*");
4992 assert_eq!(matches[1].pattern, "my-10*");
4993 assert_eq!(matches[2].pattern, "my-10-*");
4994 assert_eq!(matches[3].pattern, "*");
4995 }
4996
4997 #[test]
4998 fn add_pattern_to_empty_config() {
4999 let mut config = parse_str("");
5000 let entry = HostEntry {
5001 alias: "*.example.com".to_string(),
5002 user: "admin".to_string(),
5003 ..Default::default()
5004 };
5005 config.add_host(&entry);
5006 let output = config.serialize();
5007 assert!(output.contains("Host *.example.com"));
5008 assert!(output.contains("User admin"));
5009 let reparsed = parse_str(&output);
5010 assert!(reparsed.host_entries().is_empty());
5011 assert_eq!(reparsed.pattern_entries().len(), 1);
5012 }
5013
5014 #[test]
5015 fn vault_ssh_parsed_from_comment() {
5016 let config = parse_str(
5017 "Host myserver\n HostName 10.0.0.1\n # purple:vault-ssh ssh/sign/engineer\n",
5018 );
5019 let entries = config.host_entries();
5020 assert_eq!(entries[0].vault_ssh.as_deref(), Some("ssh/sign/engineer"));
5021 }
5022
5023 #[test]
5026 fn vault_addr_parsed_from_comment() {
5027 let config = parse_str(
5028 "Host myserver\n HostName 10.0.0.1\n # purple:vault-addr http://127.0.0.1:8200\n",
5029 );
5030 let entries = config.host_entries();
5031 assert_eq!(
5032 entries[0].vault_addr.as_deref(),
5033 Some("http://127.0.0.1:8200")
5034 );
5035 }
5036
5037 #[test]
5038 fn vault_addr_none_when_absent() {
5039 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
5040 assert!(config.host_entries()[0].vault_addr.is_none());
5041 }
5042
5043 #[test]
5044 fn vault_addr_empty_comment_ignored() {
5045 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:vault-addr \n");
5046 assert!(config.host_entries()[0].vault_addr.is_none());
5047 }
5048
5049 #[test]
5050 fn vault_addr_with_whitespace_value_rejected() {
5051 let config = parse_str(
5052 "Host myserver\n HostName 10.0.0.1\n # purple:vault-addr http://a b:8200\n",
5053 );
5054 assert!(
5059 config.host_entries()[0]
5060 .vault_addr
5061 .as_deref()
5062 .is_none_or(|v| !v.contains(' '))
5063 );
5064 }
5065
5066 #[test]
5067 fn vault_addr_round_trip_preserved() {
5068 let input = "Host myserver\n HostName 10.0.0.1\n # purple:vault-addr https://vault.example:8200\n";
5069 let config = parse_str(input);
5070 assert_eq!(config.serialize(), input);
5071 }
5072
5073 #[test]
5074 fn set_vault_addr_adds_comment() {
5075 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
5076 assert!(config.set_host_vault_addr("myserver", "http://127.0.0.1:8200"));
5077 assert_eq!(
5078 first_block(&config).vault_addr(),
5079 Some("http://127.0.0.1:8200".to_string())
5080 );
5081 }
5082
5083 #[test]
5084 fn set_vault_addr_replaces_existing() {
5085 let mut config = parse_str(
5086 "Host myserver\n HostName 10.0.0.1\n # purple:vault-addr http://old:8200\n",
5087 );
5088 assert!(config.set_host_vault_addr("myserver", "https://new:8200"));
5089 assert_eq!(
5090 first_block(&config).vault_addr(),
5091 Some("https://new:8200".to_string())
5092 );
5093 assert_eq!(
5094 config.serialize().matches("purple:vault-addr").count(),
5095 1,
5096 "Should have exactly one vault-addr comment after replace"
5097 );
5098 }
5099
5100 #[test]
5101 fn set_vault_addr_empty_removes() {
5102 let mut config = parse_str(
5103 "Host myserver\n HostName 10.0.0.1\n # purple:vault-addr http://127.0.0.1:8200\n",
5104 );
5105 assert!(config.set_host_vault_addr("myserver", ""));
5106 assert!(first_block(&config).vault_addr().is_none());
5107 assert!(!config.serialize().contains("vault-addr"));
5108 }
5109
5110 #[test]
5111 fn set_vault_addr_preserves_other_comments() {
5112 let mut config = parse_str(
5113 "Host myserver\n HostName 10.0.0.1\n # purple:tags a,b\n # purple:vault-ssh ssh/sign/engineer\n",
5114 );
5115 assert!(config.set_host_vault_addr("myserver", "http://127.0.0.1:8200"));
5116 let entry = config.host_entries().into_iter().next().unwrap();
5117 assert_eq!(entry.vault_ssh.as_deref(), Some("ssh/sign/engineer"));
5118 assert_eq!(entry.tags, vec!["a".to_string(), "b".to_string()]);
5119 assert_eq!(entry.vault_addr.as_deref(), Some("http://127.0.0.1:8200"));
5120 }
5121
5122 #[test]
5123 fn set_vault_addr_preserves_indent() {
5124 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
5125 assert!(config.set_host_vault_addr("myserver", "http://127.0.0.1:8200"));
5126 let serialized = config.serialize();
5127 assert!(
5128 serialized.contains(" # purple:vault-addr http://127.0.0.1:8200"),
5129 "indent not preserved: {}",
5130 serialized
5131 );
5132 }
5133
5134 #[test]
5135 fn set_vault_addr_twice_replaces_not_appends() {
5136 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
5137 assert!(config.set_host_vault_addr("myserver", "http://one:8200"));
5138 assert!(config.set_host_vault_addr("myserver", "http://two:8200"));
5139 let serialized = config.serialize();
5140 assert_eq!(
5141 serialized.matches("purple:vault-addr").count(),
5142 1,
5143 "Should have exactly one vault-addr comment"
5144 );
5145 assert!(serialized.contains("purple:vault-addr http://two:8200"));
5146 }
5147
5148 #[test]
5149 fn set_vault_addr_removes_duplicate_comments() {
5150 let mut config = parse_str(
5151 "Host myserver\n HostName 10.0.0.1\n # purple:vault-addr http://a:8200\n # purple:vault-addr http://b:8200\n",
5152 );
5153 assert!(config.set_host_vault_addr("myserver", "http://c:8200"));
5154 assert_eq!(
5155 config.serialize().matches("purple:vault-addr").count(),
5156 1,
5157 "duplicate comments must collapse on rewrite"
5158 );
5159 assert_eq!(
5160 first_block(&config).vault_addr(),
5161 Some("http://c:8200".to_string())
5162 );
5163 }
5164
5165 #[test]
5166 fn set_host_vault_addr_returns_false_when_alias_missing() {
5167 let mut config = parse_str("Host alpha\n HostName 10.0.0.1\n");
5168 assert!(!config.set_host_vault_addr("ghost", "http://127.0.0.1:8200"));
5169 assert_eq!(config.serialize(), "Host alpha\n HostName 10.0.0.1\n");
5171 }
5172
5173 #[test]
5174 fn set_host_vault_addr_refuses_wildcard_alias() {
5175 let mut config = parse_str("Host *\n HostName 10.0.0.1\n");
5176 assert!(!config.set_host_vault_addr("*", "http://127.0.0.1:8200"));
5177 assert!(!config.set_host_vault_addr("", "http://127.0.0.1:8200"));
5178 assert!(!config.set_host_vault_addr("a?b", "http://127.0.0.1:8200"));
5179 assert!(!config.set_host_vault_addr("a[bc]", "http://127.0.0.1:8200"));
5180 assert!(!config.set_host_vault_addr("!a", "http://127.0.0.1:8200"));
5181 assert!(!config.set_host_vault_addr("web-* db-*", "http://127.0.0.1:8200"));
5184 assert!(!config.set_host_vault_addr("a b", "http://127.0.0.1:8200"));
5185 assert!(!config.set_host_vault_addr("a\tb", "http://127.0.0.1:8200"));
5186 }
5187
5188 #[test]
5191 fn vault_ssh_none_when_absent() {
5192 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
5193 assert!(config.host_entries()[0].vault_ssh.is_none());
5194 }
5195
5196 #[test]
5197 fn vault_ssh_empty_comment_ignored() {
5198 let config = parse_str("Host myserver\n HostName 10.0.0.1\n # purple:vault-ssh \n");
5199 assert!(config.host_entries()[0].vault_ssh.is_none());
5200 }
5201
5202 #[test]
5203 fn vault_ssh_round_trip_preserved() {
5204 let input = "Host myserver\n HostName 10.0.0.1\n # purple:vault-ssh ssh/sign/engineer\n";
5205 let config = parse_str(input);
5206 assert_eq!(config.serialize(), input);
5207 }
5208
5209 #[test]
5210 fn set_vault_ssh_adds_comment() {
5211 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
5212 config.set_host_vault_ssh("myserver", "ssh/sign/engineer");
5213 assert_eq!(
5214 first_block(&config).vault_ssh(),
5215 Some("ssh/sign/engineer".to_string())
5216 );
5217 }
5218
5219 #[test]
5220 fn set_vault_ssh_replaces_existing() {
5221 let mut config =
5222 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:vault-ssh ssh/sign/old\n");
5223 config.set_host_vault_ssh("myserver", "ssh/sign/new");
5224 assert_eq!(
5225 first_block(&config).vault_ssh(),
5226 Some("ssh/sign/new".to_string())
5227 );
5228 assert_eq!(
5229 config.serialize().matches("purple:vault-ssh").count(),
5230 1,
5231 "Should have exactly one vault-ssh comment"
5232 );
5233 }
5234
5235 #[test]
5236 fn set_vault_ssh_empty_removes() {
5237 let mut config =
5238 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:vault-ssh ssh/sign/old\n");
5239 config.set_host_vault_ssh("myserver", "");
5240 assert!(first_block(&config).vault_ssh().is_none());
5241 assert!(!config.serialize().contains("vault-ssh"));
5242 }
5243
5244 #[test]
5245 fn set_vault_ssh_preserves_other_comments() {
5246 let mut config = parse_str(
5247 "Host myserver\n HostName 10.0.0.1\n # purple:askpass keychain\n # purple:tags prod\n",
5248 );
5249 config.set_host_vault_ssh("myserver", "ssh/sign/engineer");
5250 let entry = first_block(&config).to_host_entry();
5251 assert_eq!(entry.askpass, Some("keychain".to_string()));
5252 assert!(entry.tags.contains(&"prod".to_string()));
5253 assert_eq!(entry.vault_ssh.as_deref(), Some("ssh/sign/engineer"));
5254 }
5255
5256 #[test]
5257 fn set_vault_ssh_preserves_indent() {
5258 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
5259 config.set_host_vault_ssh("myserver", "ssh/sign/engineer");
5260 let raw = first_block(&config)
5261 .directives
5262 .iter()
5263 .find(|d| d.raw_line.contains("purple:vault-ssh"))
5264 .unwrap();
5265 assert!(
5266 raw.raw_line.starts_with(" "),
5267 "Expected 4-space indent, got: {:?}",
5268 raw.raw_line
5269 );
5270 }
5271
5272 #[test]
5273 fn certificate_file_parsed_from_directive() {
5274 let config =
5275 parse_str("Host myserver\n HostName 10.0.0.1\n CertificateFile ~/.ssh/my-cert.pub\n");
5276 let entries = config.host_entries();
5277 assert_eq!(entries[0].certificate_file, "~/.ssh/my-cert.pub");
5278 }
5279
5280 #[test]
5281 fn certificate_file_empty_when_absent() {
5282 let config = parse_str("Host myserver\n HostName 10.0.0.1\n");
5283 let entries = config.host_entries();
5284 assert!(entries[0].certificate_file.is_empty());
5285 }
5286
5287 #[test]
5288 fn set_host_certificate_file_adds_and_removes() {
5289 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
5290 assert!(config.set_host_certificate_file("myserver", "~/.purple/certs/myserver-cert.pub"));
5291 assert!(
5292 config
5293 .serialize()
5294 .contains("CertificateFile ~/.purple/certs/myserver-cert.pub")
5295 );
5296 assert!(config.set_host_certificate_file("myserver", ""));
5297 assert!(!config.serialize().contains("CertificateFile"));
5298 }
5299
5300 #[test]
5301 fn set_host_certificate_file_removes_when_empty() {
5302 let mut config = parse_str(
5303 "Host myserver\n HostName 10.0.0.1\n CertificateFile ~/.purple/certs/myserver-cert.pub\n",
5304 );
5305 assert!(config.set_host_certificate_file("myserver", ""));
5306 assert!(!config.serialize().contains("CertificateFile"));
5307 }
5308
5309 #[test]
5310 fn set_host_certificate_file_returns_false_when_alias_missing() {
5311 let mut config = parse_str("Host alpha\n HostName 10.0.0.1\n");
5312 assert!(!config.set_host_certificate_file("ghost", "/tmp/cert.pub"));
5313 assert_eq!(config.serialize(), "Host alpha\n HostName 10.0.0.1\n");
5315 }
5316
5317 #[test]
5318 fn set_host_certificate_file_ignores_match_blocks() {
5319 let input = "\
5323Host alpha
5324 HostName 10.0.0.1
5325
5326Match host alpha
5327 CertificateFile /user/set/match-cert.pub
5328";
5329 let mut config = parse_str(input);
5330 assert!(config.set_host_certificate_file("alpha", "/purple/managed.pub"));
5331 let out = config.serialize();
5332 assert!(
5334 out.contains("Host alpha\n HostName 10.0.0.1\n CertificateFile /purple/managed.pub")
5335 );
5336 assert!(out.contains("Match host alpha\n CertificateFile /user/set/match-cert.pub"));
5338 }
5339
5340 #[test]
5341 fn set_vault_ssh_twice_replaces_not_appends() {
5342 let mut config = parse_str("Host myserver\n HostName 10.0.0.1\n");
5343 config.set_host_vault_ssh("myserver", "ssh/sign/one");
5344 config.set_host_vault_ssh("myserver", "ssh/sign/two");
5345 let serialized = config.serialize();
5346 assert_eq!(
5347 serialized.matches("purple:vault-ssh").count(),
5348 1,
5349 "expected a single comment after two calls, got: {}",
5350 serialized
5351 );
5352 assert!(serialized.contains("purple:vault-ssh ssh/sign/two"));
5353 }
5354
5355 #[test]
5356 fn vault_ssh_indentation_preserved_with_other_purple_comments() {
5357 let input = "Host myserver\n HostName 10.0.0.1\n # purple:tags prod,web\n";
5358 let mut config = parse_str(input);
5359 config.set_host_vault_ssh("myserver", "ssh/sign/engineer");
5360 let serialized = config.serialize();
5361 assert!(
5362 serialized.contains(" # purple:vault-ssh ssh/sign/engineer"),
5363 "indent preserved: {}",
5364 serialized
5365 );
5366 assert!(serialized.contains(" # purple:tags prod,web"));
5367 }
5368
5369 #[test]
5370 fn clear_vault_ssh_removes_comment_line() {
5371 let mut config =
5372 parse_str("Host myserver\n HostName 10.0.0.1\n # purple:vault-ssh ssh/sign/old\n");
5373 config.set_host_vault_ssh("myserver", "");
5374 let serialized = config.serialize();
5375 assert!(
5376 !serialized.contains("vault-ssh"),
5377 "comment should be gone: {}",
5378 serialized
5379 );
5380 assert!(first_block(&config).vault_ssh().is_none());
5381 }
5382
5383 #[test]
5384 fn set_vault_ssh_removes_duplicate_comments() {
5385 let mut config = parse_str(
5386 "Host myserver\n HostName 10.0.0.1\n # purple:vault-ssh ssh/sign/old1\n # purple:vault-ssh ssh/sign/old2\n",
5387 );
5388 config.set_host_vault_ssh("myserver", "ssh/sign/new");
5389 assert_eq!(
5390 config.serialize().matches("purple:vault-ssh").count(),
5391 1,
5392 "Should have exactly one vault-ssh comment after set"
5393 );
5394 assert_eq!(
5395 first_block(&config).vault_ssh(),
5396 Some("ssh/sign/new".to_string())
5397 );
5398 }
5399}