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)]
37pub struct IncludeDirective {
38 pub raw_line: String,
39 pub pattern: String,
40 pub resolved_files: Vec<IncludedFile>,
41}
42
43#[derive(Debug, Clone)]
45pub struct IncludedFile {
46 pub path: PathBuf,
47 pub elements: Vec<ConfigElement>,
48}
49
50#[derive(Debug, Clone)]
52pub enum ConfigElement {
53 HostBlock(HostBlock),
55 GlobalLine(String),
57 Include(IncludeDirective),
59}
60
61#[derive(Debug, Clone)]
63pub struct HostBlock {
64 pub host_pattern: String,
66 pub raw_host_line: String,
68 pub directives: Vec<Directive>,
70}
71
72#[derive(Debug, Clone)]
74pub struct Directive {
75 pub key: String,
77 pub value: String,
79 pub raw_line: String,
81 pub is_non_directive: bool,
83}
84
85#[derive(Debug, Clone)]
87pub struct HostEntry {
88 pub alias: String,
89 pub hostname: String,
90 pub user: String,
91 pub port: u16,
92 pub identity_file: String,
93 pub proxy_jump: String,
94 pub source_file: Option<PathBuf>,
96 pub tags: Vec<String>,
98 pub provider_tags: Vec<String>,
100 pub has_provider_tags: bool,
102 pub provider: Option<String>,
104 pub tunnel_count: u16,
106 pub askpass: Option<String>,
108 pub vault_ssh: Option<String>,
110 pub vault_addr: Option<String>,
114 pub certificate_file: String,
116 pub provider_meta: Vec<(String, String)>,
118 pub stale: Option<u64>,
120}
121
122impl Default for HostEntry {
123 fn default() -> Self {
124 Self {
125 alias: String::new(),
126 hostname: String::new(),
127 user: String::new(),
128 port: 22,
129 identity_file: String::new(),
130 proxy_jump: String::new(),
131 source_file: None,
132 tags: Vec::new(),
133 provider_tags: Vec::new(),
134 has_provider_tags: false,
135 provider: None,
136 tunnel_count: 0,
137 askpass: None,
138 vault_ssh: None,
139 vault_addr: None,
140 certificate_file: String::new(),
141 provider_meta: Vec::new(),
142 stale: None,
143 }
144 }
145}
146
147impl HostEntry {
148 pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
153 let escaped = self.alias.replace('\'', "'\\''");
154 let default = dirs::home_dir()
155 .map(|h| h.join(".ssh/config"))
156 .unwrap_or_default();
157 if config_path == default {
158 format!("ssh -- '{}'", escaped)
159 } else {
160 let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
161 format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
162 }
163 }
164}
165
166#[derive(Debug, Clone, Default)]
168pub struct PatternEntry {
169 pub pattern: String,
170 pub hostname: String,
171 pub user: String,
172 pub port: u16,
173 pub identity_file: String,
174 pub proxy_jump: String,
175 pub tags: Vec<String>,
176 pub askpass: Option<String>,
177 pub source_file: Option<PathBuf>,
178 pub directives: Vec<(String, String)>,
180}
181
182#[derive(Debug, Clone, Default)]
185pub struct InheritedHints {
186 pub proxy_jump: Option<(String, String)>,
187 pub user: Option<(String, String)>,
188 pub identity_file: Option<(String, String)>,
189}
190
191pub fn is_host_pattern(pattern: &str) -> bool {
195 pattern.contains('*')
196 || pattern.contains('?')
197 || pattern.contains('[')
198 || pattern.starts_with('!')
199 || pattern.contains(' ')
200 || pattern.contains('\t')
201}
202
203pub fn ssh_pattern_match(pattern: &str, text: &str) -> bool {
207 if let Some(rest) = pattern.strip_prefix('!') {
208 return !match_glob(rest, text);
209 }
210 match_glob(pattern, text)
211}
212
213fn match_glob(pattern: &str, text: &str) -> bool {
216 if text.is_empty() {
217 return pattern.is_empty();
218 }
219 if pattern.is_empty() {
220 return false;
221 }
222 let pat: Vec<char> = pattern.chars().collect();
223 let txt: Vec<char> = text.chars().collect();
224 glob_match(&pat, &txt)
225}
226
227fn glob_match(pat: &[char], txt: &[char]) -> bool {
229 let mut pi = 0;
230 let mut ti = 0;
231 let mut star: Option<(usize, usize)> = None; while ti < txt.len() {
234 if pi < pat.len() && pat[pi] == '?' {
235 pi += 1;
236 ti += 1;
237 } else if pi < pat.len() && pat[pi] == '*' {
238 star = Some((pi + 1, ti));
239 pi += 1;
240 } else if pi < pat.len() && pat[pi] == '[' {
241 if let Some((matches, end)) = match_char_class(pat, pi, txt[ti]) {
242 if matches {
243 pi = end;
244 ti += 1;
245 } else if let Some((spi, sti)) = star {
246 let sti = sti + 1;
247 star = Some((spi, sti));
248 pi = spi;
249 ti = sti;
250 } else {
251 return false;
252 }
253 } else if let Some((spi, sti)) = star {
254 let sti = sti + 1;
256 star = Some((spi, sti));
257 pi = spi;
258 ti = sti;
259 } else {
260 return false;
261 }
262 } else if pi < pat.len() && pat[pi] == txt[ti] {
263 pi += 1;
264 ti += 1;
265 } else if let Some((spi, sti)) = star {
266 let sti = sti + 1;
267 star = Some((spi, sti));
268 pi = spi;
269 ti = sti;
270 } else {
271 return false;
272 }
273 }
274
275 while pi < pat.len() && pat[pi] == '*' {
276 pi += 1;
277 }
278 pi == pat.len()
279}
280
281fn match_char_class(pat: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
285 let mut i = start + 1;
286 if i >= pat.len() {
287 return None;
288 }
289
290 let negate = pat[i] == '!' || pat[i] == '^';
291 if negate {
292 i += 1;
293 }
294
295 let mut matched = false;
296 while i < pat.len() && pat[i] != ']' {
297 if i + 2 < pat.len() && pat[i + 1] == '-' && pat[i + 2] != ']' {
298 let lo = pat[i];
299 let hi = pat[i + 2];
300 if ch >= lo && ch <= hi {
301 matched = true;
302 }
303 i += 3;
304 } else {
305 matched |= pat[i] == ch;
306 i += 1;
307 }
308 }
309
310 if i >= pat.len() {
311 return None;
312 }
313
314 let result = if negate { !matched } else { matched };
315 Some((result, i + 1))
316}
317
318pub fn host_pattern_matches(host_pattern: &str, alias: &str) -> bool {
322 let patterns: Vec<&str> = host_pattern.split_whitespace().collect();
323 if patterns.is_empty() {
324 return false;
325 }
326
327 let mut any_positive_match = false;
328 for pat in &patterns {
329 if let Some(neg) = pat.strip_prefix('!') {
330 if match_glob(neg, alias) {
331 return false;
332 }
333 } else if ssh_pattern_match(pat, alias) {
334 any_positive_match = true;
335 }
336 }
337
338 any_positive_match
339}
340
341pub fn proxy_jump_contains_self(proxy_jump: &str, alias: &str) -> bool {
346 proxy_jump.split(',').any(|hop| {
347 let h = hop.trim();
348 let h = h.split_once('@').map_or(h, |(_, host)| host);
350 let h = if let Some(bracketed) = h.strip_prefix('[') {
352 bracketed.split_once(']').map_or(h, |(host, _)| host)
353 } else {
354 h.rsplit_once(':').map_or(h, |(host, _)| host)
355 };
356 h == alias
357 })
358}
359
360fn apply_first_match_fields(
364 proxy_jump: &mut String,
365 user: &mut String,
366 identity_file: &mut String,
367 p: &PatternEntry,
368) {
369 if proxy_jump.is_empty() && !p.proxy_jump.is_empty() {
370 proxy_jump.clone_from(&p.proxy_jump);
371 }
372 if user.is_empty() && !p.user.is_empty() {
373 user.clone_from(&p.user);
374 }
375 if identity_file.is_empty() && !p.identity_file.is_empty() {
376 identity_file.clone_from(&p.identity_file);
377 }
378}
379
380impl HostBlock {
381 fn content_end(&self) -> usize {
383 let mut pos = self.directives.len();
384 while pos > 0 {
385 if self.directives[pos - 1].is_non_directive
386 && self.directives[pos - 1].raw_line.trim().is_empty()
387 {
388 pos -= 1;
389 } else {
390 break;
391 }
392 }
393 pos
394 }
395
396 #[allow(dead_code)]
398 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
399 let end = self.content_end();
400 self.directives.drain(end..).collect()
401 }
402
403 #[allow(dead_code)]
405 fn ensure_trailing_blank(&mut self) {
406 self.pop_trailing_blanks();
407 self.directives.push(Directive {
408 key: String::new(),
409 value: String::new(),
410 raw_line: String::new(),
411 is_non_directive: true,
412 });
413 }
414
415 fn detect_indent(&self) -> String {
417 for d in &self.directives {
418 if !d.is_non_directive && !d.raw_line.is_empty() {
419 let trimmed = d.raw_line.trim_start();
420 let indent_len = d.raw_line.len() - trimmed.len();
421 if indent_len > 0 {
422 return d.raw_line[..indent_len].to_string();
423 }
424 }
425 }
426 " ".to_string()
427 }
428
429 pub fn tags(&self) -> Vec<String> {
431 for d in &self.directives {
432 if d.is_non_directive {
433 let trimmed = d.raw_line.trim();
434 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
435 return rest
436 .split(',')
437 .map(|t| t.trim().to_string())
438 .filter(|t| !t.is_empty())
439 .collect();
440 }
441 }
442 }
443 Vec::new()
444 }
445
446 pub fn provider_tags(&self) -> Vec<String> {
448 for d in &self.directives {
449 if d.is_non_directive {
450 let trimmed = d.raw_line.trim();
451 if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
452 return rest
453 .split(',')
454 .map(|t| t.trim().to_string())
455 .filter(|t| !t.is_empty())
456 .collect();
457 }
458 }
459 }
460 Vec::new()
461 }
462
463 pub fn has_provider_tags_comment(&self) -> bool {
466 self.directives.iter().any(|d| {
467 d.is_non_directive && {
468 let t = d.raw_line.trim();
469 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
470 }
471 })
472 }
473
474 pub fn provider(&self) -> Option<(String, String)> {
477 for d in &self.directives {
478 if d.is_non_directive {
479 let trimmed = d.raw_line.trim();
480 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
481 if let Some((name, id)) = rest.split_once(':') {
482 return Some((name.trim().to_string(), id.trim().to_string()));
483 }
484 }
485 }
486 }
487 None
488 }
489
490 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
492 let indent = self.detect_indent();
493 self.directives.retain(|d| {
494 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
495 });
496 let pos = self.content_end();
497 self.directives.insert(
498 pos,
499 Directive {
500 key: String::new(),
501 value: String::new(),
502 raw_line: format!(
503 "{}# purple:provider {}:{}",
504 indent, provider_name, server_id
505 ),
506 is_non_directive: true,
507 },
508 );
509 }
510
511 pub fn askpass(&self) -> Option<String> {
513 for d in &self.directives {
514 if d.is_non_directive {
515 let trimmed = d.raw_line.trim();
516 if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
517 let val = rest.trim();
518 if !val.is_empty() {
519 return Some(val.to_string());
520 }
521 }
522 }
523 }
524 None
525 }
526
527 pub fn vault_ssh(&self) -> Option<String> {
529 for d in &self.directives {
530 if d.is_non_directive {
531 let trimmed = d.raw_line.trim();
532 if let Some(rest) = trimmed.strip_prefix("# purple:vault-ssh ") {
533 let val = rest.trim();
534 if !val.is_empty() && crate::vault_ssh::is_valid_role(val) {
535 return Some(val.to_string());
536 }
537 }
538 }
539 }
540 None
541 }
542
543 pub fn set_vault_ssh(&mut self, role: &str) {
545 let indent = self.detect_indent();
546 self.directives.retain(|d| {
547 !(d.is_non_directive && {
548 let t = d.raw_line.trim();
549 t == "# purple:vault-ssh" || t.starts_with("# purple:vault-ssh ")
550 })
551 });
552 if !role.is_empty() {
553 let pos = self.content_end();
554 self.directives.insert(
555 pos,
556 Directive {
557 key: String::new(),
558 value: String::new(),
559 raw_line: format!("{}# purple:vault-ssh {}", indent, role),
560 is_non_directive: true,
561 },
562 );
563 }
564 }
565
566 pub fn vault_addr(&self) -> Option<String> {
572 for d in &self.directives {
573 if d.is_non_directive {
574 let trimmed = d.raw_line.trim();
575 if let Some(rest) = trimmed.strip_prefix("# purple:vault-addr ") {
576 let val = rest.trim();
577 if !val.is_empty() && crate::vault_ssh::is_valid_vault_addr(val) {
578 return Some(val.to_string());
579 }
580 }
581 }
582 }
583 None
584 }
585
586 pub fn set_vault_addr(&mut self, url: &str) {
590 let indent = self.detect_indent();
591 self.directives.retain(|d| {
592 !(d.is_non_directive && {
593 let t = d.raw_line.trim();
594 t == "# purple:vault-addr" || t.starts_with("# purple:vault-addr ")
595 })
596 });
597 if !url.is_empty() {
598 let pos = self.content_end();
599 self.directives.insert(
600 pos,
601 Directive {
602 key: String::new(),
603 value: String::new(),
604 raw_line: format!("{}# purple:vault-addr {}", indent, url),
605 is_non_directive: true,
606 },
607 );
608 }
609 }
610
611 pub fn set_askpass(&mut self, source: &str) {
614 let indent = self.detect_indent();
615 self.directives.retain(|d| {
616 !(d.is_non_directive && {
617 let t = d.raw_line.trim();
618 t == "# purple:askpass" || t.starts_with("# purple:askpass ")
619 })
620 });
621 if !source.is_empty() {
622 let pos = self.content_end();
623 self.directives.insert(
624 pos,
625 Directive {
626 key: String::new(),
627 value: String::new(),
628 raw_line: format!("{}# purple:askpass {}", indent, source),
629 is_non_directive: true,
630 },
631 );
632 }
633 }
634
635 pub fn meta(&self) -> Vec<(String, String)> {
638 for d in &self.directives {
639 if d.is_non_directive {
640 let trimmed = d.raw_line.trim();
641 if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
642 return rest
643 .split(',')
644 .filter_map(|pair| {
645 let (k, v) = pair.split_once('=')?;
646 let k = k.trim();
647 let v = v.trim();
648 if k.is_empty() {
649 None
650 } else {
651 Some((k.to_string(), v.to_string()))
652 }
653 })
654 .collect();
655 }
656 }
657 }
658 Vec::new()
659 }
660
661 pub fn set_meta(&mut self, meta: &[(String, String)]) {
664 let indent = self.detect_indent();
665 self.directives.retain(|d| {
666 !(d.is_non_directive && {
667 let t = d.raw_line.trim();
668 t == "# purple:meta" || t.starts_with("# purple:meta ")
669 })
670 });
671 if !meta.is_empty() {
672 let encoded: Vec<String> = meta
673 .iter()
674 .map(|(k, v)| {
675 let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
676 let clean_v = Self::sanitize_tag(&v.replace(',', ""));
677 format!("{}={}", clean_k, clean_v)
678 })
679 .collect();
680 let pos = self.content_end();
681 self.directives.insert(
682 pos,
683 Directive {
684 key: String::new(),
685 value: String::new(),
686 raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
687 is_non_directive: true,
688 },
689 );
690 }
691 }
692
693 pub fn stale(&self) -> Option<u64> {
696 for d in &self.directives {
697 if d.is_non_directive {
698 let trimmed = d.raw_line.trim();
699 if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
700 return rest.trim().parse::<u64>().ok();
701 }
702 }
703 }
704 None
705 }
706
707 pub fn set_stale(&mut self, timestamp: u64) {
710 let indent = self.detect_indent();
711 self.clear_stale();
712 let pos = self.content_end();
713 self.directives.insert(
714 pos,
715 Directive {
716 key: String::new(),
717 value: String::new(),
718 raw_line: format!("{}# purple:stale {}", indent, timestamp),
719 is_non_directive: true,
720 },
721 );
722 }
723
724 pub fn clear_stale(&mut self) {
726 self.directives.retain(|d| {
727 !(d.is_non_directive && {
728 let t = d.raw_line.trim();
729 t == "# purple:stale" || t.starts_with("# purple:stale ")
730 })
731 });
732 }
733
734 fn sanitize_tag(tag: &str) -> String {
737 tag.chars()
738 .filter(|c| {
739 !c.is_control()
740 && *c != ','
741 && !('\u{200B}'..='\u{200F}').contains(c) && !('\u{202A}'..='\u{202E}').contains(c) && !('\u{2066}'..='\u{2069}').contains(c) && *c != '\u{FEFF}' })
746 .take(128)
747 .collect()
748 }
749
750 pub fn set_tags(&mut self, tags: &[String]) {
752 let indent = self.detect_indent();
753 self.directives.retain(|d| {
754 !(d.is_non_directive && {
755 let t = d.raw_line.trim();
756 t == "# purple:tags" || t.starts_with("# purple:tags ")
757 })
758 });
759 let sanitized: Vec<String> = tags
760 .iter()
761 .map(|t| Self::sanitize_tag(t))
762 .filter(|t| !t.is_empty())
763 .collect();
764 if !sanitized.is_empty() {
765 let pos = self.content_end();
766 self.directives.insert(
767 pos,
768 Directive {
769 key: String::new(),
770 value: String::new(),
771 raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
772 is_non_directive: true,
773 },
774 );
775 }
776 }
777
778 pub fn set_provider_tags(&mut self, tags: &[String]) {
781 let indent = self.detect_indent();
782 self.directives.retain(|d| {
783 !(d.is_non_directive && {
784 let t = d.raw_line.trim();
785 t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
786 })
787 });
788 let sanitized: Vec<String> = tags
789 .iter()
790 .map(|t| Self::sanitize_tag(t))
791 .filter(|t| !t.is_empty())
792 .collect();
793 let raw = if sanitized.is_empty() {
794 format!("{}# purple:provider_tags", indent)
795 } else {
796 format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
797 };
798 let pos = self.content_end();
799 self.directives.insert(
800 pos,
801 Directive {
802 key: String::new(),
803 value: String::new(),
804 raw_line: raw,
805 is_non_directive: true,
806 },
807 );
808 }
809
810 pub fn to_host_entry(&self) -> HostEntry {
812 let mut entry = HostEntry {
813 alias: self.host_pattern.clone(),
814 port: 22,
815 ..Default::default()
816 };
817 for d in &self.directives {
818 if d.is_non_directive {
819 continue;
820 }
821 if d.key.eq_ignore_ascii_case("hostname") {
822 entry.hostname = d.value.clone();
823 } else if d.key.eq_ignore_ascii_case("user") {
824 entry.user = d.value.clone();
825 } else if d.key.eq_ignore_ascii_case("port") {
826 entry.port = d.value.parse().unwrap_or(22);
827 } else if d.key.eq_ignore_ascii_case("identityfile") {
828 if entry.identity_file.is_empty() {
829 entry.identity_file = d.value.clone();
830 }
831 } else if d.key.eq_ignore_ascii_case("proxyjump") {
832 entry.proxy_jump = d.value.clone();
833 } else if d.key.eq_ignore_ascii_case("certificatefile")
834 && entry.certificate_file.is_empty()
835 {
836 entry.certificate_file = d.value.clone();
837 }
838 }
839 entry.tags = self.tags();
840 entry.provider_tags = self.provider_tags();
841 entry.has_provider_tags = self.has_provider_tags_comment();
842 entry.provider = self.provider().map(|(name, _)| name);
843 entry.tunnel_count = self.tunnel_count();
844 entry.askpass = self.askpass();
845 entry.vault_ssh = self.vault_ssh();
846 entry.vault_addr = self.vault_addr();
847 entry.provider_meta = self.meta();
848 entry.stale = self.stale();
849 entry
850 }
851
852 pub fn to_pattern_entry(&self) -> PatternEntry {
854 let mut entry = PatternEntry {
855 pattern: self.host_pattern.clone(),
856 hostname: String::new(),
857 user: String::new(),
858 port: 22,
859 identity_file: String::new(),
860 proxy_jump: String::new(),
861 tags: self.tags(),
862 askpass: self.askpass(),
863 source_file: None,
864 directives: Vec::new(),
865 };
866 for d in &self.directives {
867 if d.is_non_directive {
868 continue;
869 }
870 match d.key.to_ascii_lowercase().as_str() {
871 "hostname" => entry.hostname = d.value.clone(),
872 "user" => entry.user = d.value.clone(),
873 "port" => entry.port = d.value.parse().unwrap_or(22),
874 "identityfile" => {
875 if entry.identity_file.is_empty() {
876 entry.identity_file = d.value.clone();
877 }
878 }
879 "proxyjump" => entry.proxy_jump = d.value.clone(),
880 _ => {}
881 }
882 entry.directives.push((d.key.clone(), d.value.clone()));
883 }
884 entry
885 }
886
887 pub fn tunnel_count(&self) -> u16 {
889 let count = self
890 .directives
891 .iter()
892 .filter(|d| {
893 !d.is_non_directive
894 && (d.key.eq_ignore_ascii_case("localforward")
895 || d.key.eq_ignore_ascii_case("remoteforward")
896 || d.key.eq_ignore_ascii_case("dynamicforward"))
897 })
898 .count();
899 count.min(u16::MAX as usize) as u16
900 }
901
902 #[allow(dead_code)]
904 pub fn has_tunnels(&self) -> bool {
905 self.directives.iter().any(|d| {
906 !d.is_non_directive
907 && (d.key.eq_ignore_ascii_case("localforward")
908 || d.key.eq_ignore_ascii_case("remoteforward")
909 || d.key.eq_ignore_ascii_case("dynamicforward"))
910 })
911 }
912
913 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
915 self.directives
916 .iter()
917 .filter(|d| !d.is_non_directive)
918 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
919 .collect()
920 }
921}
922
923impl SshConfigFile {
924 pub fn host_entries(&self) -> Vec<HostEntry> {
929 let mut entries = Vec::new();
930 Self::collect_host_entries(&self.elements, &mut entries);
931 self.apply_pattern_inheritance(&mut entries);
932 entries
933 }
934
935 pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
939 Self::find_raw_host_entry(&self.elements, alias)
940 }
941
942 fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
943 for e in elements {
944 match e {
945 ConfigElement::HostBlock(block)
946 if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
947 {
948 return Some(block.to_host_entry());
949 }
950 ConfigElement::Include(inc) => {
951 for file in &inc.resolved_files {
952 if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
953 if found.source_file.is_none() {
954 found.source_file = Some(file.path.clone());
955 }
956 return Some(found);
957 }
958 }
959 }
960 _ => {}
961 }
962 }
963 None
964 }
965
966 fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
970 let all_patterns = self.pattern_entries();
973 for entry in entries.iter_mut() {
974 if !entry.proxy_jump.is_empty()
975 && !entry.user.is_empty()
976 && !entry.identity_file.is_empty()
977 {
978 continue;
979 }
980 for p in &all_patterns {
981 if !host_pattern_matches(&p.pattern, &entry.alias) {
982 continue;
983 }
984 apply_first_match_fields(
985 &mut entry.proxy_jump,
986 &mut entry.user,
987 &mut entry.identity_file,
988 p,
989 );
990 if !entry.proxy_jump.is_empty()
991 && !entry.user.is_empty()
992 && !entry.identity_file.is_empty()
993 {
994 break;
995 }
996 }
997 }
998 }
999
1000 pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
1006 let patterns = self.matching_patterns(alias);
1007 let mut hints = InheritedHints::default();
1008 for p in &patterns {
1009 if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
1010 hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
1011 }
1012 if hints.user.is_none() && !p.user.is_empty() {
1013 hints.user = Some((p.user.clone(), p.pattern.clone()));
1014 }
1015 if hints.identity_file.is_none() && !p.identity_file.is_empty() {
1016 hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
1017 }
1018 if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
1019 break;
1020 }
1021 }
1022 hints
1023 }
1024
1025 pub fn pattern_entries(&self) -> Vec<PatternEntry> {
1027 let mut entries = Vec::new();
1028 Self::collect_pattern_entries(&self.elements, &mut entries);
1029 entries
1030 }
1031
1032 fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
1033 for e in elements {
1034 match e {
1035 ConfigElement::HostBlock(block) => {
1036 if !is_host_pattern(&block.host_pattern) {
1037 continue;
1038 }
1039 entries.push(block.to_pattern_entry());
1040 }
1041 ConfigElement::Include(include) => {
1042 for file in &include.resolved_files {
1043 let start = entries.len();
1044 Self::collect_pattern_entries(&file.elements, entries);
1045 for entry in &mut entries[start..] {
1046 if entry.source_file.is_none() {
1047 entry.source_file = Some(file.path.clone());
1048 }
1049 }
1050 }
1051 }
1052 ConfigElement::GlobalLine(_) => {}
1053 }
1054 }
1055 }
1056
1057 pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
1060 let mut matches = Vec::new();
1061 Self::collect_matching_patterns(&self.elements, alias, &mut matches);
1062 matches
1063 }
1064
1065 fn collect_matching_patterns(
1066 elements: &[ConfigElement],
1067 alias: &str,
1068 matches: &mut Vec<PatternEntry>,
1069 ) {
1070 for e in elements {
1071 match e {
1072 ConfigElement::HostBlock(block) => {
1073 if !is_host_pattern(&block.host_pattern) {
1074 continue;
1075 }
1076 if host_pattern_matches(&block.host_pattern, alias) {
1077 matches.push(block.to_pattern_entry());
1078 }
1079 }
1080 ConfigElement::Include(include) => {
1081 for file in &include.resolved_files {
1082 let start = matches.len();
1083 Self::collect_matching_patterns(&file.elements, alias, matches);
1084 for entry in &mut matches[start..] {
1085 if entry.source_file.is_none() {
1086 entry.source_file = Some(file.path.clone());
1087 }
1088 }
1089 }
1090 }
1091 ConfigElement::GlobalLine(_) => {}
1092 }
1093 }
1094 }
1095
1096 pub fn include_paths(&self) -> Vec<PathBuf> {
1098 let mut paths = Vec::new();
1099 Self::collect_include_paths(&self.elements, &mut paths);
1100 paths
1101 }
1102
1103 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
1104 for e in elements {
1105 if let ConfigElement::Include(include) = e {
1106 for file in &include.resolved_files {
1107 paths.push(file.path.clone());
1108 Self::collect_include_paths(&file.elements, paths);
1109 }
1110 }
1111 }
1112 }
1113
1114 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
1117 let config_dir = self.path.parent();
1118 let mut seen = std::collections::HashSet::new();
1119 let mut dirs = Vec::new();
1120 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
1121 dirs
1122 }
1123
1124 fn collect_include_glob_dirs(
1125 elements: &[ConfigElement],
1126 config_dir: Option<&std::path::Path>,
1127 seen: &mut std::collections::HashSet<PathBuf>,
1128 dirs: &mut Vec<PathBuf>,
1129 ) {
1130 for e in elements {
1131 if let ConfigElement::Include(include) = e {
1132 for single in Self::split_include_patterns(&include.pattern) {
1134 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
1135 let resolved = if expanded.starts_with('/') {
1136 PathBuf::from(&expanded)
1137 } else if let Some(dir) = config_dir {
1138 dir.join(&expanded)
1139 } else {
1140 continue;
1141 };
1142 if let Some(parent) = resolved.parent() {
1143 let parent = parent.to_path_buf();
1144 if seen.insert(parent.clone()) {
1145 dirs.push(parent);
1146 }
1147 }
1148 }
1149 for file in &include.resolved_files {
1151 Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
1152 }
1153 }
1154 }
1155 }
1156
1157 pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
1160 let active_providers: std::collections::HashSet<String> = self
1162 .elements
1163 .iter()
1164 .filter_map(|e| {
1165 if let ConfigElement::HostBlock(block) = e {
1166 block
1167 .provider()
1168 .map(|(name, _)| provider_group_display_name(&name).to_string())
1169 } else {
1170 None
1171 }
1172 })
1173 .collect();
1174
1175 let mut removed = 0;
1176 self.elements.retain(|e| {
1177 if let ConfigElement::GlobalLine(line) = e {
1178 if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
1179 if !active_providers.contains(rest.trim()) {
1180 removed += 1;
1181 return false;
1182 }
1183 }
1184 }
1185 true
1186 });
1187 removed
1188 }
1189
1190 pub fn repair_absorbed_group_comments(&mut self) -> usize {
1194 let mut repaired = 0;
1195 let mut idx = 0;
1196 while idx < self.elements.len() {
1197 let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
1198 block
1199 .directives
1200 .iter()
1201 .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
1202 } else {
1203 false
1204 };
1205
1206 if !needs_repair {
1207 idx += 1;
1208 continue;
1209 }
1210
1211 let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
1213 block
1214 } else {
1215 unreachable!()
1216 };
1217
1218 let group_idx = block
1219 .directives
1220 .iter()
1221 .position(|d| {
1222 d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
1223 })
1224 .unwrap();
1225
1226 let mut keep_end = group_idx;
1228 while keep_end > 0
1229 && block.directives[keep_end - 1].is_non_directive
1230 && block.directives[keep_end - 1].raw_line.trim().is_empty()
1231 {
1232 keep_end -= 1;
1233 }
1234
1235 let extracted: Vec<ConfigElement> = block
1237 .directives
1238 .drain(keep_end..)
1239 .map(|d| ConfigElement::GlobalLine(d.raw_line))
1240 .collect();
1241
1242 let insert_at = idx + 1;
1244 for (i, elem) in extracted.into_iter().enumerate() {
1245 self.elements.insert(insert_at + i, elem);
1246 }
1247
1248 repaired += 1;
1249 idx = insert_at;
1251 while idx < self.elements.len() {
1253 if let ConfigElement::HostBlock(_) = &self.elements[idx] {
1254 break;
1255 }
1256 idx += 1;
1257 }
1258 }
1259 repaired
1260 }
1261
1262 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
1264 for e in elements {
1265 match e {
1266 ConfigElement::HostBlock(block) => {
1267 if is_host_pattern(&block.host_pattern) {
1268 continue;
1269 }
1270 entries.push(block.to_host_entry());
1271 }
1272 ConfigElement::Include(include) => {
1273 for file in &include.resolved_files {
1274 let start = entries.len();
1275 Self::collect_host_entries(&file.elements, entries);
1276 for entry in &mut entries[start..] {
1277 if entry.source_file.is_none() {
1278 entry.source_file = Some(file.path.clone());
1279 }
1280 }
1281 }
1282 }
1283 ConfigElement::GlobalLine(_) => {}
1284 }
1285 }
1286 }
1287
1288 pub fn has_host(&self, alias: &str) -> bool {
1291 Self::has_host_in_elements(&self.elements, alias)
1292 }
1293
1294 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
1295 for e in elements {
1296 match e {
1297 ConfigElement::HostBlock(block) => {
1298 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1299 return true;
1300 }
1301 }
1302 ConfigElement::Include(include) => {
1303 for file in &include.resolved_files {
1304 if Self::has_host_in_elements(&file.elements, alias) {
1305 return true;
1306 }
1307 }
1308 }
1309 ConfigElement::GlobalLine(_) => {}
1310 }
1311 }
1312 false
1313 }
1314
1315 pub fn has_host_block(&self, pattern: &str) -> bool {
1320 self.elements
1321 .iter()
1322 .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
1323 }
1324
1325 pub fn is_included_host(&self, alias: &str) -> bool {
1328 for e in &self.elements {
1330 match e {
1331 ConfigElement::HostBlock(block) => {
1332 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1333 return false;
1334 }
1335 }
1336 ConfigElement::Include(include) => {
1337 for file in &include.resolved_files {
1338 if Self::has_host_in_elements(&file.elements, alias) {
1339 return true;
1340 }
1341 }
1342 }
1343 ConfigElement::GlobalLine(_) => {}
1344 }
1345 }
1346 false
1347 }
1348
1349 pub fn add_host(&mut self, entry: &HostEntry) {
1354 let block = Self::entry_to_block(entry);
1355 let insert_pos = self.find_trailing_pattern_start();
1356
1357 if let Some(pos) = insert_pos {
1358 let needs_blank_before = pos > 0
1360 && !matches!(
1361 self.elements.get(pos - 1),
1362 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1363 );
1364 let mut idx = pos;
1365 if needs_blank_before {
1366 self.elements
1367 .insert(idx, ConfigElement::GlobalLine(String::new()));
1368 idx += 1;
1369 }
1370 self.elements.insert(idx, ConfigElement::HostBlock(block));
1371 let after = idx + 1;
1373 if after < self.elements.len()
1374 && !matches!(
1375 self.elements.get(after),
1376 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1377 )
1378 {
1379 self.elements
1380 .insert(after, ConfigElement::GlobalLine(String::new()));
1381 }
1382 } else {
1383 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
1385 self.elements.push(ConfigElement::GlobalLine(String::new()));
1386 }
1387 self.elements.push(ConfigElement::HostBlock(block));
1388 }
1389 }
1390
1391 fn find_trailing_pattern_start(&self) -> Option<usize> {
1396 let mut first_pattern_pos = None;
1397 for i in (0..self.elements.len()).rev() {
1398 match &self.elements[i] {
1399 ConfigElement::HostBlock(block) => {
1400 if is_host_pattern(&block.host_pattern) {
1401 first_pattern_pos = Some(i);
1402 } else {
1403 break;
1405 }
1406 }
1407 ConfigElement::GlobalLine(_) => {
1408 if first_pattern_pos.is_some() {
1410 first_pattern_pos = Some(i);
1411 }
1412 }
1413 ConfigElement::Include(_) => break,
1414 }
1415 }
1416 first_pattern_pos.filter(|&pos| pos > 0)
1418 }
1419
1420 pub fn last_element_has_trailing_blank(&self) -> bool {
1422 match self.elements.last() {
1423 Some(ConfigElement::HostBlock(block)) => block
1424 .directives
1425 .last()
1426 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
1427 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
1428 _ => false,
1429 }
1430 }
1431
1432 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
1435 for element in &mut self.elements {
1436 if let ConfigElement::HostBlock(block) = element {
1437 if block.host_pattern == old_alias {
1438 if entry.alias != block.host_pattern {
1440 block.host_pattern = entry.alias.clone();
1441 block.raw_host_line = format!("Host {}", entry.alias);
1442 }
1443
1444 Self::upsert_directive(block, "HostName", &entry.hostname);
1446 Self::upsert_directive(block, "User", &entry.user);
1447 if entry.port != 22 {
1448 Self::upsert_directive(block, "Port", &entry.port.to_string());
1449 } else {
1450 block
1452 .directives
1453 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
1454 }
1455 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
1456 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
1457 return;
1458 }
1459 }
1460 }
1461 }
1462
1463 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
1465 if value.is_empty() {
1466 block
1467 .directives
1468 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
1469 return;
1470 }
1471 let indent = block.detect_indent();
1472 for d in &mut block.directives {
1473 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
1474 if d.value != value {
1476 d.value = value.to_string();
1477 let trimmed = d.raw_line.trim_start();
1483 let after_key = &trimmed[d.key.len()..];
1484 let sep = if after_key.trim_start().starts_with('=') {
1485 let eq_pos = after_key.find('=').unwrap();
1486 let after_eq = &after_key[eq_pos + 1..];
1487 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
1488 after_key[..eq_pos + 1 + trailing_ws].to_string()
1489 } else {
1490 " ".to_string()
1491 };
1492 let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
1494 d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
1495 }
1496 return;
1497 }
1498 }
1499 let pos = block.content_end();
1501 block.directives.insert(
1502 pos,
1503 Directive {
1504 key: key.to_string(),
1505 value: value.to_string(),
1506 raw_line: format!("{}{} {}", indent, key, value),
1507 is_non_directive: false,
1508 },
1509 );
1510 }
1511
1512 fn extract_inline_comment(raw_line: &str, key: &str) -> String {
1516 let trimmed = raw_line.trim_start();
1517 if trimmed.len() <= key.len() {
1518 return String::new();
1519 }
1520 let after_key = &trimmed[key.len()..];
1522 let rest = after_key.trim_start();
1523 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
1524 let bytes = rest.as_bytes();
1526 let mut in_quote = false;
1527 for i in 0..bytes.len() {
1528 if bytes[i] == b'"' {
1529 in_quote = !in_quote;
1530 } else if !in_quote
1531 && bytes[i] == b'#'
1532 && i > 0
1533 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
1534 {
1535 let clean_end = rest[..i].trim_end().len();
1537 return rest[clean_end..].to_string();
1538 }
1539 }
1540 String::new()
1541 }
1542
1543 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
1545 for element in &mut self.elements {
1546 if let ConfigElement::HostBlock(block) = element {
1547 if block.host_pattern == alias {
1548 block.set_provider(provider_name, server_id);
1549 return;
1550 }
1551 }
1552 }
1553 }
1554
1555 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
1559 let mut results = Vec::new();
1560 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1561 results
1562 }
1563
1564 fn collect_provider_hosts(
1565 elements: &[ConfigElement],
1566 provider_name: &str,
1567 results: &mut Vec<(String, String)>,
1568 ) {
1569 for element in elements {
1570 match element {
1571 ConfigElement::HostBlock(block) => {
1572 if let Some((name, id)) = block.provider() {
1573 if name == provider_name {
1574 results.push((block.host_pattern.clone(), id));
1575 }
1576 }
1577 }
1578 ConfigElement::Include(include) => {
1579 for file in &include.resolved_files {
1580 Self::collect_provider_hosts(&file.elements, provider_name, results);
1581 }
1582 }
1583 ConfigElement::GlobalLine(_) => {}
1584 }
1585 }
1586 }
1587
1588 fn values_match(a: &str, b: &str) -> bool {
1591 a.split_whitespace().eq(b.split_whitespace())
1592 }
1593
1594 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1598 for element in &mut self.elements {
1599 if let ConfigElement::HostBlock(block) = element {
1600 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1601 let indent = block.detect_indent();
1602 let pos = block.content_end();
1603 block.directives.insert(
1604 pos,
1605 Directive {
1606 key: directive_key.to_string(),
1607 value: value.to_string(),
1608 raw_line: format!("{}{} {}", indent, directive_key, value),
1609 is_non_directive: false,
1610 },
1611 );
1612 return;
1613 }
1614 }
1615 }
1616 }
1617
1618 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1623 for element in &mut self.elements {
1624 if let ConfigElement::HostBlock(block) = element {
1625 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1626 if let Some(pos) = block.directives.iter().position(|d| {
1627 !d.is_non_directive
1628 && d.key.eq_ignore_ascii_case(directive_key)
1629 && Self::values_match(&d.value, value)
1630 }) {
1631 block.directives.remove(pos);
1632 return true;
1633 }
1634 return false;
1635 }
1636 }
1637 }
1638 false
1639 }
1640
1641 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1644 for element in &self.elements {
1645 if let ConfigElement::HostBlock(block) = element {
1646 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1647 return block.directives.iter().any(|d| {
1648 !d.is_non_directive
1649 && d.key.eq_ignore_ascii_case(directive_key)
1650 && Self::values_match(&d.value, value)
1651 });
1652 }
1653 }
1654 }
1655 false
1656 }
1657
1658 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1662 Self::find_tunnel_directives_in(&self.elements, alias)
1663 }
1664
1665 fn find_tunnel_directives_in(
1666 elements: &[ConfigElement],
1667 alias: &str,
1668 ) -> Vec<crate::tunnel::TunnelRule> {
1669 for element in elements {
1670 match element {
1671 ConfigElement::HostBlock(block) => {
1672 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1673 return block.tunnel_directives();
1674 }
1675 }
1676 ConfigElement::Include(include) => {
1677 for file in &include.resolved_files {
1678 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1679 if !rules.is_empty() {
1680 return rules;
1681 }
1682 }
1683 }
1684 ConfigElement::GlobalLine(_) => {}
1685 }
1686 }
1687 Vec::new()
1688 }
1689
1690 pub fn deduplicate_alias(&self, base: &str) -> String {
1692 self.deduplicate_alias_excluding(base, None)
1693 }
1694
1695 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1698 let is_taken = |alias: &str| {
1699 if exclude == Some(alias) {
1700 return false;
1701 }
1702 self.has_host(alias)
1703 };
1704 if !is_taken(base) {
1705 return base.to_string();
1706 }
1707 for n in 2..=9999 {
1708 let candidate = format!("{}-{}", base, n);
1709 if !is_taken(&candidate) {
1710 return candidate;
1711 }
1712 }
1713 format!("{}-{}", base, std::process::id())
1715 }
1716
1717 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1719 for element in &mut self.elements {
1720 if let ConfigElement::HostBlock(block) = element {
1721 if block.host_pattern == alias {
1722 block.set_tags(tags);
1723 return;
1724 }
1725 }
1726 }
1727 }
1728
1729 pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1731 for element in &mut self.elements {
1732 if let ConfigElement::HostBlock(block) = element {
1733 if block.host_pattern == alias {
1734 block.set_provider_tags(tags);
1735 return;
1736 }
1737 }
1738 }
1739 }
1740
1741 pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1743 for element in &mut self.elements {
1744 if let ConfigElement::HostBlock(block) = element {
1745 if block.host_pattern == alias {
1746 block.set_askpass(source);
1747 return;
1748 }
1749 }
1750 }
1751 }
1752
1753 pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) {
1755 for element in &mut self.elements {
1756 if let ConfigElement::HostBlock(block) = element {
1757 if block.host_pattern == alias {
1758 block.set_vault_ssh(role);
1759 return;
1760 }
1761 }
1762 }
1763 }
1764
1765 #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1780 pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
1781 if alias.is_empty() || is_host_pattern(alias) {
1785 return false;
1786 }
1787 for element in &mut self.elements {
1788 if let ConfigElement::HostBlock(block) = element {
1789 if block.host_pattern == alias {
1790 block.set_vault_addr(url);
1791 return true;
1792 }
1793 }
1794 }
1795 false
1796 }
1797
1798 #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1809 pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
1810 if alias.is_empty() || is_host_pattern(alias) {
1818 return false;
1819 }
1820 for element in &mut self.elements {
1821 if let ConfigElement::HostBlock(block) = element {
1822 if block.host_pattern == alias {
1823 Self::upsert_directive(block, "CertificateFile", path);
1824 return true;
1825 }
1826 }
1827 }
1828 false
1829 }
1830
1831 pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1833 for element in &mut self.elements {
1834 if let ConfigElement::HostBlock(block) = element {
1835 if block.host_pattern == alias {
1836 block.set_meta(meta);
1837 return;
1838 }
1839 }
1840 }
1841 }
1842
1843 pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1845 for element in &mut self.elements {
1846 if let ConfigElement::HostBlock(block) = element {
1847 if block.host_pattern == alias {
1848 block.set_stale(timestamp);
1849 return;
1850 }
1851 }
1852 }
1853 }
1854
1855 pub fn clear_host_stale(&mut self, alias: &str) {
1857 for element in &mut self.elements {
1858 if let ConfigElement::HostBlock(block) = element {
1859 if block.host_pattern == alias {
1860 block.clear_stale();
1861 return;
1862 }
1863 }
1864 }
1865 }
1866
1867 pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1869 let mut result = Vec::new();
1870 for element in &self.elements {
1871 if let ConfigElement::HostBlock(block) = element {
1872 if let Some(ts) = block.stale() {
1873 result.push((block.host_pattern.clone(), ts));
1874 }
1875 }
1876 }
1877 result
1878 }
1879
1880 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 fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1929 if self.find_hosts_by_provider(provider_name).is_empty() {
1930 let display = provider_group_display_name(provider_name);
1931 let header = format!("# purple:group {}", display);
1932 self.elements
1933 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1934 }
1935 }
1936
1937 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1939 let pos = position.min(self.elements.len());
1940 self.elements.insert(pos, element);
1941 }
1942
1943 pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1947 let mut last_pos = None;
1948 for (i, element) in self.elements.iter().enumerate() {
1949 if let ConfigElement::HostBlock(block) = element {
1950 if let Some((name, _)) = block.provider() {
1951 if name == provider_name {
1952 last_pos = Some(i);
1953 }
1954 }
1955 }
1956 }
1957 last_pos.map(|p| p + 1)
1959 }
1960
1961 #[allow(dead_code)]
1963 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1964 let pos_a = self
1965 .elements
1966 .iter()
1967 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1968 let pos_b = self
1969 .elements
1970 .iter()
1971 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1972 if let (Some(a), Some(b)) = (pos_a, pos_b) {
1973 if a == b {
1974 return false;
1975 }
1976 let (first, second) = (a.min(b), a.max(b));
1977
1978 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1980 block.pop_trailing_blanks();
1981 }
1982 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1983 block.pop_trailing_blanks();
1984 }
1985
1986 self.elements.swap(first, second);
1988
1989 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1991 block.ensure_trailing_blank();
1992 }
1993
1994 if second < self.elements.len() - 1 {
1996 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1997 block.ensure_trailing_blank();
1998 }
1999 }
2000
2001 return true;
2002 }
2003 false
2004 }
2005
2006 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
2008 debug_assert!(
2011 !entry.alias.contains('\n') && !entry.alias.contains('\r'),
2012 "entry_to_block: alias contains newline"
2013 );
2014 debug_assert!(
2015 !entry.hostname.contains('\n') && !entry.hostname.contains('\r'),
2016 "entry_to_block: hostname contains newline"
2017 );
2018 debug_assert!(
2019 !entry.user.contains('\n') && !entry.user.contains('\r'),
2020 "entry_to_block: user contains newline"
2021 );
2022
2023 let mut directives = Vec::new();
2024
2025 if !entry.hostname.is_empty() {
2026 directives.push(Directive {
2027 key: "HostName".to_string(),
2028 value: entry.hostname.clone(),
2029 raw_line: format!(" HostName {}", entry.hostname),
2030 is_non_directive: false,
2031 });
2032 }
2033 if !entry.user.is_empty() {
2034 directives.push(Directive {
2035 key: "User".to_string(),
2036 value: entry.user.clone(),
2037 raw_line: format!(" User {}", entry.user),
2038 is_non_directive: false,
2039 });
2040 }
2041 if entry.port != 22 {
2042 directives.push(Directive {
2043 key: "Port".to_string(),
2044 value: entry.port.to_string(),
2045 raw_line: format!(" Port {}", entry.port),
2046 is_non_directive: false,
2047 });
2048 }
2049 if !entry.identity_file.is_empty() {
2050 directives.push(Directive {
2051 key: "IdentityFile".to_string(),
2052 value: entry.identity_file.clone(),
2053 raw_line: format!(" IdentityFile {}", entry.identity_file),
2054 is_non_directive: false,
2055 });
2056 }
2057 if !entry.proxy_jump.is_empty() {
2058 directives.push(Directive {
2059 key: "ProxyJump".to_string(),
2060 value: entry.proxy_jump.clone(),
2061 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
2062 is_non_directive: false,
2063 });
2064 }
2065
2066 HostBlock {
2067 host_pattern: entry.alias.clone(),
2068 raw_host_line: format!("Host {}", entry.alias),
2069 directives,
2070 }
2071 }
2072}
2073
2074#[cfg(test)]
2075#[path = "model_tests.rs"]
2076mod tests;