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" if entry.identity_file.is_empty() => {
875 entry.identity_file = d.value.clone();
876 }
877 "proxyjump" => entry.proxy_jump = d.value.clone(),
878 _ => {}
879 }
880 entry.directives.push((d.key.clone(), d.value.clone()));
881 }
882 entry
883 }
884
885 pub fn tunnel_count(&self) -> u16 {
887 let count = self
888 .directives
889 .iter()
890 .filter(|d| {
891 !d.is_non_directive
892 && (d.key.eq_ignore_ascii_case("localforward")
893 || d.key.eq_ignore_ascii_case("remoteforward")
894 || d.key.eq_ignore_ascii_case("dynamicforward"))
895 })
896 .count();
897 count.min(u16::MAX as usize) as u16
898 }
899
900 #[allow(dead_code)]
902 pub fn has_tunnels(&self) -> bool {
903 self.directives.iter().any(|d| {
904 !d.is_non_directive
905 && (d.key.eq_ignore_ascii_case("localforward")
906 || d.key.eq_ignore_ascii_case("remoteforward")
907 || d.key.eq_ignore_ascii_case("dynamicforward"))
908 })
909 }
910
911 pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
913 self.directives
914 .iter()
915 .filter(|d| !d.is_non_directive)
916 .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
917 .collect()
918 }
919}
920
921impl SshConfigFile {
922 pub fn host_entries(&self) -> Vec<HostEntry> {
927 let mut entries = Vec::new();
928 Self::collect_host_entries(&self.elements, &mut entries);
929 self.apply_pattern_inheritance(&mut entries);
930 entries
931 }
932
933 pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
937 Self::find_raw_host_entry(&self.elements, alias)
938 }
939
940 fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
941 for e in elements {
942 match e {
943 ConfigElement::HostBlock(block)
944 if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
945 {
946 return Some(block.to_host_entry());
947 }
948 ConfigElement::Include(inc) => {
949 for file in &inc.resolved_files {
950 if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
951 if found.source_file.is_none() {
952 found.source_file = Some(file.path.clone());
953 }
954 return Some(found);
955 }
956 }
957 }
958 _ => {}
959 }
960 }
961 None
962 }
963
964 fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
968 let all_patterns = self.pattern_entries();
971 for entry in entries.iter_mut() {
972 if !entry.proxy_jump.is_empty()
973 && !entry.user.is_empty()
974 && !entry.identity_file.is_empty()
975 {
976 continue;
977 }
978 for p in &all_patterns {
979 if !host_pattern_matches(&p.pattern, &entry.alias) {
980 continue;
981 }
982 apply_first_match_fields(
983 &mut entry.proxy_jump,
984 &mut entry.user,
985 &mut entry.identity_file,
986 p,
987 );
988 if !entry.proxy_jump.is_empty()
989 && !entry.user.is_empty()
990 && !entry.identity_file.is_empty()
991 {
992 break;
993 }
994 }
995 }
996 }
997
998 pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
1004 let patterns = self.matching_patterns(alias);
1005 let mut hints = InheritedHints::default();
1006 for p in &patterns {
1007 if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
1008 hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
1009 }
1010 if hints.user.is_none() && !p.user.is_empty() {
1011 hints.user = Some((p.user.clone(), p.pattern.clone()));
1012 }
1013 if hints.identity_file.is_none() && !p.identity_file.is_empty() {
1014 hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
1015 }
1016 if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
1017 break;
1018 }
1019 }
1020 hints
1021 }
1022
1023 pub fn pattern_entries(&self) -> Vec<PatternEntry> {
1025 let mut entries = Vec::new();
1026 Self::collect_pattern_entries(&self.elements, &mut entries);
1027 entries
1028 }
1029
1030 fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
1031 for e in elements {
1032 match e {
1033 ConfigElement::HostBlock(block) => {
1034 if !is_host_pattern(&block.host_pattern) {
1035 continue;
1036 }
1037 entries.push(block.to_pattern_entry());
1038 }
1039 ConfigElement::Include(include) => {
1040 for file in &include.resolved_files {
1041 let start = entries.len();
1042 Self::collect_pattern_entries(&file.elements, entries);
1043 for entry in &mut entries[start..] {
1044 if entry.source_file.is_none() {
1045 entry.source_file = Some(file.path.clone());
1046 }
1047 }
1048 }
1049 }
1050 ConfigElement::GlobalLine(_) => {}
1051 }
1052 }
1053 }
1054
1055 pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
1058 let mut matches = Vec::new();
1059 Self::collect_matching_patterns(&self.elements, alias, &mut matches);
1060 matches
1061 }
1062
1063 fn collect_matching_patterns(
1064 elements: &[ConfigElement],
1065 alias: &str,
1066 matches: &mut Vec<PatternEntry>,
1067 ) {
1068 for e in elements {
1069 match e {
1070 ConfigElement::HostBlock(block) => {
1071 if !is_host_pattern(&block.host_pattern) {
1072 continue;
1073 }
1074 if host_pattern_matches(&block.host_pattern, alias) {
1075 matches.push(block.to_pattern_entry());
1076 }
1077 }
1078 ConfigElement::Include(include) => {
1079 for file in &include.resolved_files {
1080 let start = matches.len();
1081 Self::collect_matching_patterns(&file.elements, alias, matches);
1082 for entry in &mut matches[start..] {
1083 if entry.source_file.is_none() {
1084 entry.source_file = Some(file.path.clone());
1085 }
1086 }
1087 }
1088 }
1089 ConfigElement::GlobalLine(_) => {}
1090 }
1091 }
1092 }
1093
1094 pub fn include_paths(&self) -> Vec<PathBuf> {
1096 let mut paths = Vec::new();
1097 Self::collect_include_paths(&self.elements, &mut paths);
1098 paths
1099 }
1100
1101 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
1102 for e in elements {
1103 if let ConfigElement::Include(include) = e {
1104 for file in &include.resolved_files {
1105 paths.push(file.path.clone());
1106 Self::collect_include_paths(&file.elements, paths);
1107 }
1108 }
1109 }
1110 }
1111
1112 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
1115 let config_dir = self.path.parent();
1116 let mut seen = std::collections::HashSet::new();
1117 let mut dirs = Vec::new();
1118 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
1119 dirs
1120 }
1121
1122 fn collect_include_glob_dirs(
1123 elements: &[ConfigElement],
1124 config_dir: Option<&std::path::Path>,
1125 seen: &mut std::collections::HashSet<PathBuf>,
1126 dirs: &mut Vec<PathBuf>,
1127 ) {
1128 for e in elements {
1129 if let ConfigElement::Include(include) = e {
1130 for single in Self::split_include_patterns(&include.pattern) {
1132 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
1133 let resolved = if expanded.starts_with('/') {
1134 PathBuf::from(&expanded)
1135 } else if let Some(dir) = config_dir {
1136 dir.join(&expanded)
1137 } else {
1138 continue;
1139 };
1140 if let Some(parent) = resolved.parent() {
1141 let parent = parent.to_path_buf();
1142 if seen.insert(parent.clone()) {
1143 dirs.push(parent);
1144 }
1145 }
1146 }
1147 for file in &include.resolved_files {
1149 Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
1150 }
1151 }
1152 }
1153 }
1154
1155 pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
1158 let active_providers: std::collections::HashSet<String> = self
1160 .elements
1161 .iter()
1162 .filter_map(|e| {
1163 if let ConfigElement::HostBlock(block) = e {
1164 block
1165 .provider()
1166 .map(|(name, _)| provider_group_display_name(&name).to_string())
1167 } else {
1168 None
1169 }
1170 })
1171 .collect();
1172
1173 let mut removed = 0;
1174 self.elements.retain(|e| {
1175 if let ConfigElement::GlobalLine(line) = e {
1176 if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
1177 if !active_providers.contains(rest.trim()) {
1178 removed += 1;
1179 return false;
1180 }
1181 }
1182 }
1183 true
1184 });
1185 removed
1186 }
1187
1188 pub fn repair_absorbed_group_comments(&mut self) -> usize {
1192 let mut repaired = 0;
1193 let mut idx = 0;
1194 while idx < self.elements.len() {
1195 let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
1196 block
1197 .directives
1198 .iter()
1199 .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
1200 } else {
1201 false
1202 };
1203
1204 if !needs_repair {
1205 idx += 1;
1206 continue;
1207 }
1208
1209 let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
1211 block
1212 } else {
1213 unreachable!()
1214 };
1215
1216 let group_idx = block
1217 .directives
1218 .iter()
1219 .position(|d| {
1220 d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
1221 })
1222 .unwrap();
1223
1224 let mut keep_end = group_idx;
1226 while keep_end > 0
1227 && block.directives[keep_end - 1].is_non_directive
1228 && block.directives[keep_end - 1].raw_line.trim().is_empty()
1229 {
1230 keep_end -= 1;
1231 }
1232
1233 let extracted: Vec<ConfigElement> = block
1235 .directives
1236 .drain(keep_end..)
1237 .map(|d| ConfigElement::GlobalLine(d.raw_line))
1238 .collect();
1239
1240 let insert_at = idx + 1;
1242 for (i, elem) in extracted.into_iter().enumerate() {
1243 self.elements.insert(insert_at + i, elem);
1244 }
1245
1246 repaired += 1;
1247 idx = insert_at;
1249 while idx < self.elements.len() {
1251 if let ConfigElement::HostBlock(_) = &self.elements[idx] {
1252 break;
1253 }
1254 idx += 1;
1255 }
1256 }
1257 repaired
1258 }
1259
1260 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
1262 for e in elements {
1263 match e {
1264 ConfigElement::HostBlock(block) => {
1265 if is_host_pattern(&block.host_pattern) {
1266 continue;
1267 }
1268 entries.push(block.to_host_entry());
1269 }
1270 ConfigElement::Include(include) => {
1271 for file in &include.resolved_files {
1272 let start = entries.len();
1273 Self::collect_host_entries(&file.elements, entries);
1274 for entry in &mut entries[start..] {
1275 if entry.source_file.is_none() {
1276 entry.source_file = Some(file.path.clone());
1277 }
1278 }
1279 }
1280 }
1281 ConfigElement::GlobalLine(_) => {}
1282 }
1283 }
1284 }
1285
1286 pub fn has_host(&self, alias: &str) -> bool {
1289 Self::has_host_in_elements(&self.elements, alias)
1290 }
1291
1292 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
1293 for e in elements {
1294 match e {
1295 ConfigElement::HostBlock(block) => {
1296 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1297 return true;
1298 }
1299 }
1300 ConfigElement::Include(include) => {
1301 for file in &include.resolved_files {
1302 if Self::has_host_in_elements(&file.elements, alias) {
1303 return true;
1304 }
1305 }
1306 }
1307 ConfigElement::GlobalLine(_) => {}
1308 }
1309 }
1310 false
1311 }
1312
1313 pub fn has_host_block(&self, pattern: &str) -> bool {
1318 self.elements
1319 .iter()
1320 .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
1321 }
1322
1323 pub fn is_included_host(&self, alias: &str) -> bool {
1326 for e in &self.elements {
1328 match e {
1329 ConfigElement::HostBlock(block) => {
1330 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1331 return false;
1332 }
1333 }
1334 ConfigElement::Include(include) => {
1335 for file in &include.resolved_files {
1336 if Self::has_host_in_elements(&file.elements, alias) {
1337 return true;
1338 }
1339 }
1340 }
1341 ConfigElement::GlobalLine(_) => {}
1342 }
1343 }
1344 false
1345 }
1346
1347 pub fn add_host(&mut self, entry: &HostEntry) {
1352 let block = Self::entry_to_block(entry);
1353 let insert_pos = self.find_trailing_pattern_start();
1354
1355 if let Some(pos) = insert_pos {
1356 let needs_blank_before = pos > 0
1358 && !matches!(
1359 self.elements.get(pos - 1),
1360 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1361 );
1362 let mut idx = pos;
1363 if needs_blank_before {
1364 self.elements
1365 .insert(idx, ConfigElement::GlobalLine(String::new()));
1366 idx += 1;
1367 }
1368 self.elements.insert(idx, ConfigElement::HostBlock(block));
1369 let after = idx + 1;
1371 if after < self.elements.len()
1372 && !matches!(
1373 self.elements.get(after),
1374 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1375 )
1376 {
1377 self.elements
1378 .insert(after, ConfigElement::GlobalLine(String::new()));
1379 }
1380 } else {
1381 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
1383 self.elements.push(ConfigElement::GlobalLine(String::new()));
1384 }
1385 self.elements.push(ConfigElement::HostBlock(block));
1386 }
1387 }
1388
1389 fn find_trailing_pattern_start(&self) -> Option<usize> {
1394 let mut first_pattern_pos = None;
1395 for i in (0..self.elements.len()).rev() {
1396 match &self.elements[i] {
1397 ConfigElement::HostBlock(block) => {
1398 if is_host_pattern(&block.host_pattern) {
1399 first_pattern_pos = Some(i);
1400 } else {
1401 break;
1403 }
1404 }
1405 ConfigElement::GlobalLine(_) => {
1406 if first_pattern_pos.is_some() {
1408 first_pattern_pos = Some(i);
1409 }
1410 }
1411 ConfigElement::Include(_) => break,
1412 }
1413 }
1414 first_pattern_pos.filter(|&pos| pos > 0)
1416 }
1417
1418 pub fn last_element_has_trailing_blank(&self) -> bool {
1420 match self.elements.last() {
1421 Some(ConfigElement::HostBlock(block)) => block
1422 .directives
1423 .last()
1424 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
1425 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
1426 _ => false,
1427 }
1428 }
1429
1430 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
1433 for element in &mut self.elements {
1434 if let ConfigElement::HostBlock(block) = element {
1435 if block.host_pattern == old_alias {
1436 if entry.alias != block.host_pattern {
1438 block.host_pattern = entry.alias.clone();
1439 block.raw_host_line = format!("Host {}", entry.alias);
1440 }
1441
1442 Self::upsert_directive(block, "HostName", &entry.hostname);
1444 Self::upsert_directive(block, "User", &entry.user);
1445 if entry.port != 22 {
1446 Self::upsert_directive(block, "Port", &entry.port.to_string());
1447 } else {
1448 block
1450 .directives
1451 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
1452 }
1453 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
1454 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
1455 return;
1456 }
1457 }
1458 }
1459 }
1460
1461 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
1463 if value.is_empty() {
1464 block
1465 .directives
1466 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
1467 return;
1468 }
1469 let indent = block.detect_indent();
1470 for d in &mut block.directives {
1471 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
1472 if d.value != value {
1474 d.value = value.to_string();
1475 let trimmed = d.raw_line.trim_start();
1481 let after_key = &trimmed[d.key.len()..];
1482 let sep = if after_key.trim_start().starts_with('=') {
1483 let eq_pos = after_key.find('=').unwrap();
1484 let after_eq = &after_key[eq_pos + 1..];
1485 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
1486 after_key[..eq_pos + 1 + trailing_ws].to_string()
1487 } else {
1488 " ".to_string()
1489 };
1490 let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
1492 d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
1493 }
1494 return;
1495 }
1496 }
1497 let pos = block.content_end();
1499 block.directives.insert(
1500 pos,
1501 Directive {
1502 key: key.to_string(),
1503 value: value.to_string(),
1504 raw_line: format!("{}{} {}", indent, key, value),
1505 is_non_directive: false,
1506 },
1507 );
1508 }
1509
1510 fn extract_inline_comment(raw_line: &str, key: &str) -> String {
1514 let trimmed = raw_line.trim_start();
1515 if trimmed.len() <= key.len() {
1516 return String::new();
1517 }
1518 let after_key = &trimmed[key.len()..];
1520 let rest = after_key.trim_start();
1521 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
1522 let bytes = rest.as_bytes();
1524 let mut in_quote = false;
1525 for i in 0..bytes.len() {
1526 if bytes[i] == b'"' {
1527 in_quote = !in_quote;
1528 } else if !in_quote
1529 && bytes[i] == b'#'
1530 && i > 0
1531 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
1532 {
1533 let clean_end = rest[..i].trim_end().len();
1535 return rest[clean_end..].to_string();
1536 }
1537 }
1538 String::new()
1539 }
1540
1541 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
1543 for element in &mut self.elements {
1544 if let ConfigElement::HostBlock(block) = element {
1545 if block.host_pattern == alias {
1546 block.set_provider(provider_name, server_id);
1547 return;
1548 }
1549 }
1550 }
1551 }
1552
1553 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
1557 let mut results = Vec::new();
1558 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1559 results
1560 }
1561
1562 fn collect_provider_hosts(
1563 elements: &[ConfigElement],
1564 provider_name: &str,
1565 results: &mut Vec<(String, String)>,
1566 ) {
1567 for element in elements {
1568 match element {
1569 ConfigElement::HostBlock(block) => {
1570 if let Some((name, id)) = block.provider() {
1571 if name == provider_name {
1572 results.push((block.host_pattern.clone(), id));
1573 }
1574 }
1575 }
1576 ConfigElement::Include(include) => {
1577 for file in &include.resolved_files {
1578 Self::collect_provider_hosts(&file.elements, provider_name, results);
1579 }
1580 }
1581 ConfigElement::GlobalLine(_) => {}
1582 }
1583 }
1584 }
1585
1586 fn values_match(a: &str, b: &str) -> bool {
1589 a.split_whitespace().eq(b.split_whitespace())
1590 }
1591
1592 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1596 for element in &mut self.elements {
1597 if let ConfigElement::HostBlock(block) = element {
1598 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1599 let indent = block.detect_indent();
1600 let pos = block.content_end();
1601 block.directives.insert(
1602 pos,
1603 Directive {
1604 key: directive_key.to_string(),
1605 value: value.to_string(),
1606 raw_line: format!("{}{} {}", indent, directive_key, value),
1607 is_non_directive: false,
1608 },
1609 );
1610 return;
1611 }
1612 }
1613 }
1614 }
1615
1616 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1621 for element in &mut self.elements {
1622 if let ConfigElement::HostBlock(block) = element {
1623 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1624 if let Some(pos) = block.directives.iter().position(|d| {
1625 !d.is_non_directive
1626 && d.key.eq_ignore_ascii_case(directive_key)
1627 && Self::values_match(&d.value, value)
1628 }) {
1629 block.directives.remove(pos);
1630 return true;
1631 }
1632 return false;
1633 }
1634 }
1635 }
1636 false
1637 }
1638
1639 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1642 for element in &self.elements {
1643 if let ConfigElement::HostBlock(block) = element {
1644 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1645 return block.directives.iter().any(|d| {
1646 !d.is_non_directive
1647 && d.key.eq_ignore_ascii_case(directive_key)
1648 && Self::values_match(&d.value, value)
1649 });
1650 }
1651 }
1652 }
1653 false
1654 }
1655
1656 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1660 Self::find_tunnel_directives_in(&self.elements, alias)
1661 }
1662
1663 fn find_tunnel_directives_in(
1664 elements: &[ConfigElement],
1665 alias: &str,
1666 ) -> Vec<crate::tunnel::TunnelRule> {
1667 for element in elements {
1668 match element {
1669 ConfigElement::HostBlock(block) => {
1670 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1671 return block.tunnel_directives();
1672 }
1673 }
1674 ConfigElement::Include(include) => {
1675 for file in &include.resolved_files {
1676 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1677 if !rules.is_empty() {
1678 return rules;
1679 }
1680 }
1681 }
1682 ConfigElement::GlobalLine(_) => {}
1683 }
1684 }
1685 Vec::new()
1686 }
1687
1688 pub fn deduplicate_alias(&self, base: &str) -> String {
1690 self.deduplicate_alias_excluding(base, None)
1691 }
1692
1693 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1696 let is_taken = |alias: &str| {
1697 if exclude == Some(alias) {
1698 return false;
1699 }
1700 self.has_host(alias)
1701 };
1702 if !is_taken(base) {
1703 return base.to_string();
1704 }
1705 for n in 2..=9999 {
1706 let candidate = format!("{}-{}", base, n);
1707 if !is_taken(&candidate) {
1708 return candidate;
1709 }
1710 }
1711 format!("{}-{}", base, std::process::id())
1713 }
1714
1715 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1717 for element in &mut self.elements {
1718 if let ConfigElement::HostBlock(block) = element {
1719 if block.host_pattern == alias {
1720 block.set_tags(tags);
1721 return;
1722 }
1723 }
1724 }
1725 }
1726
1727 pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1729 for element in &mut self.elements {
1730 if let ConfigElement::HostBlock(block) = element {
1731 if block.host_pattern == alias {
1732 block.set_provider_tags(tags);
1733 return;
1734 }
1735 }
1736 }
1737 }
1738
1739 pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1741 for element in &mut self.elements {
1742 if let ConfigElement::HostBlock(block) = element {
1743 if block.host_pattern == alias {
1744 block.set_askpass(source);
1745 return;
1746 }
1747 }
1748 }
1749 }
1750
1751 pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) {
1753 for element in &mut self.elements {
1754 if let ConfigElement::HostBlock(block) = element {
1755 if block.host_pattern == alias {
1756 block.set_vault_ssh(role);
1757 return;
1758 }
1759 }
1760 }
1761 }
1762
1763 #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1778 pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
1779 if alias.is_empty() || is_host_pattern(alias) {
1783 return false;
1784 }
1785 for element in &mut self.elements {
1786 if let ConfigElement::HostBlock(block) = element {
1787 if block.host_pattern == alias {
1788 block.set_vault_addr(url);
1789 return true;
1790 }
1791 }
1792 }
1793 false
1794 }
1795
1796 #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1807 pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
1808 if alias.is_empty() || is_host_pattern(alias) {
1816 return false;
1817 }
1818 for element in &mut self.elements {
1819 if let ConfigElement::HostBlock(block) = element {
1820 if block.host_pattern == alias {
1821 Self::upsert_directive(block, "CertificateFile", path);
1822 return true;
1823 }
1824 }
1825 }
1826 false
1827 }
1828
1829 pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1831 for element in &mut self.elements {
1832 if let ConfigElement::HostBlock(block) = element {
1833 if block.host_pattern == alias {
1834 block.set_meta(meta);
1835 return;
1836 }
1837 }
1838 }
1839 }
1840
1841 pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1843 for element in &mut self.elements {
1844 if let ConfigElement::HostBlock(block) = element {
1845 if block.host_pattern == alias {
1846 block.set_stale(timestamp);
1847 return;
1848 }
1849 }
1850 }
1851 }
1852
1853 pub fn clear_host_stale(&mut self, alias: &str) {
1855 for element in &mut self.elements {
1856 if let ConfigElement::HostBlock(block) = element {
1857 if block.host_pattern == alias {
1858 block.clear_stale();
1859 return;
1860 }
1861 }
1862 }
1863 }
1864
1865 pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1867 let mut result = Vec::new();
1868 for element in &self.elements {
1869 if let ConfigElement::HostBlock(block) = element {
1870 if let Some(ts) = block.stale() {
1871 result.push((block.host_pattern.clone(), ts));
1872 }
1873 }
1874 }
1875 result
1876 }
1877
1878 pub fn delete_host(&mut self, alias: &str) {
1880 let provider_name = self.elements.iter().find_map(|e| {
1883 if let ConfigElement::HostBlock(b) = e {
1884 if b.host_pattern == alias {
1885 return b.provider().map(|(name, _)| name);
1886 }
1887 }
1888 None
1889 });
1890
1891 self.elements.retain(|e| match e {
1892 ConfigElement::HostBlock(block) => block.host_pattern != alias,
1893 _ => true,
1894 });
1895
1896 if let Some(name) = provider_name {
1898 self.remove_orphaned_group_header(&name);
1899 }
1900
1901 self.elements.dedup_by(|a, b| {
1903 matches!(
1904 (&*a, &*b),
1905 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1906 if x.trim().is_empty() && y.trim().is_empty()
1907 )
1908 });
1909 }
1910
1911 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1916 let pos = self
1917 .elements
1918 .iter()
1919 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
1920 let element = self.elements.remove(pos);
1921 Some((element, pos))
1922 }
1923
1924 fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1927 if self.find_hosts_by_provider(provider_name).is_empty() {
1928 let display = provider_group_display_name(provider_name);
1929 let header = format!("# purple:group {}", display);
1930 self.elements
1931 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1932 }
1933 }
1934
1935 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1937 let pos = position.min(self.elements.len());
1938 self.elements.insert(pos, element);
1939 }
1940
1941 pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1945 let mut last_pos = None;
1946 for (i, element) in self.elements.iter().enumerate() {
1947 if let ConfigElement::HostBlock(block) = element {
1948 if let Some((name, _)) = block.provider() {
1949 if name == provider_name {
1950 last_pos = Some(i);
1951 }
1952 }
1953 }
1954 }
1955 last_pos.map(|p| p + 1)
1957 }
1958
1959 #[allow(dead_code)]
1961 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1962 let pos_a = self
1963 .elements
1964 .iter()
1965 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1966 let pos_b = self
1967 .elements
1968 .iter()
1969 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1970 if let (Some(a), Some(b)) = (pos_a, pos_b) {
1971 if a == b {
1972 return false;
1973 }
1974 let (first, second) = (a.min(b), a.max(b));
1975
1976 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1978 block.pop_trailing_blanks();
1979 }
1980 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1981 block.pop_trailing_blanks();
1982 }
1983
1984 self.elements.swap(first, second);
1986
1987 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1989 block.ensure_trailing_blank();
1990 }
1991
1992 if second < self.elements.len() - 1 {
1994 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1995 block.ensure_trailing_blank();
1996 }
1997 }
1998
1999 return true;
2000 }
2001 false
2002 }
2003
2004 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
2006 debug_assert!(
2009 !entry.alias.contains('\n') && !entry.alias.contains('\r'),
2010 "entry_to_block: alias contains newline"
2011 );
2012 debug_assert!(
2013 !entry.hostname.contains('\n') && !entry.hostname.contains('\r'),
2014 "entry_to_block: hostname contains newline"
2015 );
2016 debug_assert!(
2017 !entry.user.contains('\n') && !entry.user.contains('\r'),
2018 "entry_to_block: user contains newline"
2019 );
2020
2021 let mut directives = Vec::new();
2022
2023 if !entry.hostname.is_empty() {
2024 directives.push(Directive {
2025 key: "HostName".to_string(),
2026 value: entry.hostname.clone(),
2027 raw_line: format!(" HostName {}", entry.hostname),
2028 is_non_directive: false,
2029 });
2030 }
2031 if !entry.user.is_empty() {
2032 directives.push(Directive {
2033 key: "User".to_string(),
2034 value: entry.user.clone(),
2035 raw_line: format!(" User {}", entry.user),
2036 is_non_directive: false,
2037 });
2038 }
2039 if entry.port != 22 {
2040 directives.push(Directive {
2041 key: "Port".to_string(),
2042 value: entry.port.to_string(),
2043 raw_line: format!(" Port {}", entry.port),
2044 is_non_directive: false,
2045 });
2046 }
2047 if !entry.identity_file.is_empty() {
2048 directives.push(Directive {
2049 key: "IdentityFile".to_string(),
2050 value: entry.identity_file.clone(),
2051 raw_line: format!(" IdentityFile {}", entry.identity_file),
2052 is_non_directive: false,
2053 });
2054 }
2055 if !entry.proxy_jump.is_empty() {
2056 directives.push(Directive {
2057 key: "ProxyJump".to_string(),
2058 value: entry.proxy_jump.clone(),
2059 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
2060 is_non_directive: false,
2061 });
2062 }
2063
2064 HostBlock {
2065 host_pattern: entry.alias.clone(),
2066 raw_host_line: format!("Host {}", entry.alias),
2067 directives,
2068 }
2069 }
2070}
2071
2072#[cfg(test)]
2073#[path = "model_tests.rs"]
2074mod tests;