1use std::path::PathBuf;
2
3#[derive(Debug, Clone)]
6pub struct SshConfigFile {
7 pub elements: Vec<ConfigElement>,
8 pub path: PathBuf,
9 pub crlf: bool,
11 pub bom: bool,
13}
14
15#[derive(Debug, Clone)]
17pub struct IncludeDirective {
18 pub raw_line: String,
19 pub pattern: String,
20 pub resolved_files: Vec<IncludedFile>,
21}
22
23#[derive(Debug, Clone)]
25pub struct IncludedFile {
26 pub path: PathBuf,
27 pub elements: Vec<ConfigElement>,
28}
29
30#[derive(Debug, Clone)]
32pub enum ConfigElement {
33 HostBlock(HostBlock),
35 GlobalLine(String),
37 Include(IncludeDirective),
39}
40
41#[derive(Debug, Clone)]
43pub struct HostBlock {
44 pub host_pattern: String,
46 pub raw_host_line: String,
48 pub directives: Vec<Directive>,
50}
51
52#[derive(Debug, Clone)]
54pub struct Directive {
55 pub key: String,
57 pub value: String,
59 pub raw_line: String,
61 pub is_non_directive: bool,
63}
64
65#[derive(Debug, Clone)]
67pub struct HostEntry {
68 pub alias: String,
69 pub hostname: String,
70 pub user: String,
71 pub port: u16,
72 pub identity_file: String,
73 pub proxy_jump: String,
74 pub source_file: Option<PathBuf>,
76 pub tags: Vec<String>,
78 pub provider_tags: Vec<String>,
80 pub has_provider_tags: bool,
82 pub provider: Option<String>,
84 pub tunnel_count: u16,
86 pub askpass: Option<String>,
88 pub vault_ssh: Option<String>,
90 pub vault_addr: Option<String>,
94 pub certificate_file: String,
96 pub provider_meta: Vec<(String, String)>,
98 pub stale: Option<u64>,
100}
101
102impl Default for HostEntry {
103 fn default() -> Self {
104 Self {
105 alias: String::new(),
106 hostname: String::new(),
107 user: String::new(),
108 port: 22,
109 identity_file: String::new(),
110 proxy_jump: String::new(),
111 source_file: None,
112 tags: Vec::new(),
113 provider_tags: Vec::new(),
114 has_provider_tags: false,
115 provider: None,
116 tunnel_count: 0,
117 askpass: None,
118 vault_ssh: None,
119 vault_addr: None,
120 certificate_file: String::new(),
121 provider_meta: Vec::new(),
122 stale: None,
123 }
124 }
125}
126
127impl HostEntry {
128 pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
133 let escaped = self.alias.replace('\'', "'\\''");
134 let default = dirs::home_dir()
135 .map(|h| h.join(".ssh/config"))
136 .unwrap_or_default();
137 if config_path == default {
138 format!("ssh -- '{}'", escaped)
139 } else {
140 let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
141 format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
142 }
143 }
144}
145
146#[derive(Debug, Clone, Default)]
148pub struct PatternEntry {
149 pub pattern: String,
150 pub hostname: String,
151 pub user: String,
152 pub port: u16,
153 pub identity_file: String,
154 pub proxy_jump: String,
155 pub tags: Vec<String>,
156 pub askpass: Option<String>,
157 pub source_file: Option<PathBuf>,
158 pub directives: Vec<(String, String)>,
160}
161
162#[derive(Debug, Clone, Default)]
165pub struct InheritedHints {
166 pub proxy_jump: Option<(String, String)>,
167 pub user: Option<(String, String)>,
168 pub identity_file: Option<(String, String)>,
169}
170
171use super::pattern::apply_first_match_fields;
172#[allow(unused_imports)]
179pub use super::pattern::{
180 host_pattern_matches, is_host_pattern, proxy_jump_contains_self, ssh_pattern_match,
181};
182#[allow(unused_imports)]
184pub(super) use super::repair::provider_group_display_name;
185
186impl SshConfigFile {
187 pub fn host_entries(&self) -> Vec<HostEntry> {
192 let mut entries = Vec::new();
193 Self::collect_host_entries(&self.elements, &mut entries);
194 self.apply_pattern_inheritance(&mut entries);
195 entries
196 }
197
198 pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
202 Self::find_raw_host_entry(&self.elements, alias)
203 }
204
205 fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
206 for e in elements {
207 match e {
208 ConfigElement::HostBlock(block)
209 if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
210 {
211 return Some(block.to_host_entry());
212 }
213 ConfigElement::Include(inc) => {
214 for file in &inc.resolved_files {
215 if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
216 if found.source_file.is_none() {
217 found.source_file = Some(file.path.clone());
218 }
219 return Some(found);
220 }
221 }
222 }
223 _ => {}
224 }
225 }
226 None
227 }
228
229 fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
233 let all_patterns = self.pattern_entries();
236 for entry in entries.iter_mut() {
237 if !entry.proxy_jump.is_empty()
238 && !entry.user.is_empty()
239 && !entry.identity_file.is_empty()
240 {
241 continue;
242 }
243 for p in &all_patterns {
244 if !host_pattern_matches(&p.pattern, &entry.alias) {
245 continue;
246 }
247 apply_first_match_fields(
248 &mut entry.proxy_jump,
249 &mut entry.user,
250 &mut entry.identity_file,
251 p,
252 );
253 if !entry.proxy_jump.is_empty()
254 && !entry.user.is_empty()
255 && !entry.identity_file.is_empty()
256 {
257 break;
258 }
259 }
260 }
261 }
262
263 pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
269 let patterns = self.matching_patterns(alias);
270 let mut hints = InheritedHints::default();
271 for p in &patterns {
272 if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
273 hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
274 }
275 if hints.user.is_none() && !p.user.is_empty() {
276 hints.user = Some((p.user.clone(), p.pattern.clone()));
277 }
278 if hints.identity_file.is_none() && !p.identity_file.is_empty() {
279 hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
280 }
281 if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
282 break;
283 }
284 }
285 hints
286 }
287
288 pub fn pattern_entries(&self) -> Vec<PatternEntry> {
290 let mut entries = Vec::new();
291 Self::collect_pattern_entries(&self.elements, &mut entries);
292 entries
293 }
294
295 fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
296 for e in elements {
297 match e {
298 ConfigElement::HostBlock(block) => {
299 if !is_host_pattern(&block.host_pattern) {
300 continue;
301 }
302 entries.push(block.to_pattern_entry());
303 }
304 ConfigElement::Include(include) => {
305 for file in &include.resolved_files {
306 let start = entries.len();
307 Self::collect_pattern_entries(&file.elements, entries);
308 for entry in &mut entries[start..] {
309 if entry.source_file.is_none() {
310 entry.source_file = Some(file.path.clone());
311 }
312 }
313 }
314 }
315 ConfigElement::GlobalLine(_) => {}
316 }
317 }
318 }
319
320 pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
323 let mut matches = Vec::new();
324 Self::collect_matching_patterns(&self.elements, alias, &mut matches);
325 matches
326 }
327
328 fn collect_matching_patterns(
329 elements: &[ConfigElement],
330 alias: &str,
331 matches: &mut Vec<PatternEntry>,
332 ) {
333 for e in elements {
334 match e {
335 ConfigElement::HostBlock(block) => {
336 if !is_host_pattern(&block.host_pattern) {
337 continue;
338 }
339 if host_pattern_matches(&block.host_pattern, alias) {
340 matches.push(block.to_pattern_entry());
341 }
342 }
343 ConfigElement::Include(include) => {
344 for file in &include.resolved_files {
345 let start = matches.len();
346 Self::collect_matching_patterns(&file.elements, alias, matches);
347 for entry in &mut matches[start..] {
348 if entry.source_file.is_none() {
349 entry.source_file = Some(file.path.clone());
350 }
351 }
352 }
353 }
354 ConfigElement::GlobalLine(_) => {}
355 }
356 }
357 }
358
359 pub fn include_paths(&self) -> Vec<PathBuf> {
361 let mut paths = Vec::new();
362 Self::collect_include_paths(&self.elements, &mut paths);
363 paths
364 }
365
366 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
367 for e in elements {
368 if let ConfigElement::Include(include) = e {
369 for file in &include.resolved_files {
370 paths.push(file.path.clone());
371 Self::collect_include_paths(&file.elements, paths);
372 }
373 }
374 }
375 }
376
377 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
380 let config_dir = self.path.parent();
381 let mut seen = std::collections::HashSet::new();
382 let mut dirs = Vec::new();
383 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
384 dirs
385 }
386
387 fn collect_include_glob_dirs(
388 elements: &[ConfigElement],
389 config_dir: Option<&std::path::Path>,
390 seen: &mut std::collections::HashSet<PathBuf>,
391 dirs: &mut Vec<PathBuf>,
392 ) {
393 for e in elements {
394 if let ConfigElement::Include(include) = e {
395 for single in Self::split_include_patterns(&include.pattern) {
397 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
398 let resolved = if expanded.starts_with('/') {
399 PathBuf::from(&expanded)
400 } else if let Some(dir) = config_dir {
401 dir.join(&expanded)
402 } else {
403 continue;
404 };
405 if let Some(parent) = resolved.parent() {
406 let parent = parent.to_path_buf();
407 if seen.insert(parent.clone()) {
408 dirs.push(parent);
409 }
410 }
411 }
412 for file in &include.resolved_files {
414 Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
415 }
416 }
417 }
418 }
419
420 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
424 for e in elements {
425 match e {
426 ConfigElement::HostBlock(block) => {
427 if is_host_pattern(&block.host_pattern) {
428 continue;
429 }
430 entries.push(block.to_host_entry());
431 }
432 ConfigElement::Include(include) => {
433 for file in &include.resolved_files {
434 let start = entries.len();
435 Self::collect_host_entries(&file.elements, entries);
436 for entry in &mut entries[start..] {
437 if entry.source_file.is_none() {
438 entry.source_file = Some(file.path.clone());
439 }
440 }
441 }
442 }
443 ConfigElement::GlobalLine(_) => {}
444 }
445 }
446 }
447
448 pub fn has_host(&self, alias: &str) -> bool {
451 Self::has_host_in_elements(&self.elements, alias)
452 }
453
454 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
455 for e in elements {
456 match e {
457 ConfigElement::HostBlock(block) => {
458 if block.host_pattern.split_whitespace().any(|p| p == alias) {
459 return true;
460 }
461 }
462 ConfigElement::Include(include) => {
463 for file in &include.resolved_files {
464 if Self::has_host_in_elements(&file.elements, alias) {
465 return true;
466 }
467 }
468 }
469 ConfigElement::GlobalLine(_) => {}
470 }
471 }
472 false
473 }
474
475 pub fn has_host_block(&self, pattern: &str) -> bool {
480 self.elements
481 .iter()
482 .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
483 }
484
485 pub fn is_included_host(&self, alias: &str) -> bool {
488 for e in &self.elements {
490 match e {
491 ConfigElement::HostBlock(block) => {
492 if block.host_pattern.split_whitespace().any(|p| p == alias) {
493 return false;
494 }
495 }
496 ConfigElement::Include(include) => {
497 for file in &include.resolved_files {
498 if Self::has_host_in_elements(&file.elements, alias) {
499 return true;
500 }
501 }
502 }
503 ConfigElement::GlobalLine(_) => {}
504 }
505 }
506 false
507 }
508
509 pub fn add_host(&mut self, entry: &HostEntry) {
514 let block = Self::entry_to_block(entry);
515 let insert_pos = self.find_trailing_pattern_start();
516
517 if let Some(pos) = insert_pos {
518 let needs_blank_before = pos > 0
520 && !matches!(
521 self.elements.get(pos - 1),
522 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
523 );
524 let mut idx = pos;
525 if needs_blank_before {
526 self.elements
527 .insert(idx, ConfigElement::GlobalLine(String::new()));
528 idx += 1;
529 }
530 self.elements.insert(idx, ConfigElement::HostBlock(block));
531 let after = idx + 1;
533 if after < self.elements.len()
534 && !matches!(
535 self.elements.get(after),
536 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
537 )
538 {
539 self.elements
540 .insert(after, ConfigElement::GlobalLine(String::new()));
541 }
542 } else {
543 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
545 self.elements.push(ConfigElement::GlobalLine(String::new()));
546 }
547 self.elements.push(ConfigElement::HostBlock(block));
548 }
549 }
550
551 fn find_trailing_pattern_start(&self) -> Option<usize> {
556 let mut first_pattern_pos = None;
557 for i in (0..self.elements.len()).rev() {
558 match &self.elements[i] {
559 ConfigElement::HostBlock(block) => {
560 if is_host_pattern(&block.host_pattern) {
561 first_pattern_pos = Some(i);
562 } else {
563 break;
565 }
566 }
567 ConfigElement::GlobalLine(_) => {
568 if first_pattern_pos.is_some() {
570 first_pattern_pos = Some(i);
571 }
572 }
573 ConfigElement::Include(_) => break,
574 }
575 }
576 first_pattern_pos.filter(|&pos| pos > 0)
578 }
579
580 pub fn last_element_has_trailing_blank(&self) -> bool {
582 match self.elements.last() {
583 Some(ConfigElement::HostBlock(block)) => block
584 .directives
585 .last()
586 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
587 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
588 _ => false,
589 }
590 }
591
592 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
595 for element in &mut self.elements {
596 if let ConfigElement::HostBlock(block) = element {
597 if block.host_pattern == old_alias {
598 if entry.alias != block.host_pattern {
600 block.host_pattern = entry.alias.clone();
601 block.raw_host_line = format!("Host {}", entry.alias);
602 }
603
604 Self::upsert_directive(block, "HostName", &entry.hostname);
606 Self::upsert_directive(block, "User", &entry.user);
607 if entry.port != 22 {
608 Self::upsert_directive(block, "Port", &entry.port.to_string());
609 } else {
610 block
612 .directives
613 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
614 }
615 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
616 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
617 return;
618 }
619 }
620 }
621 }
622
623 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
625 if value.is_empty() {
626 block
627 .directives
628 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
629 return;
630 }
631 let indent = block.detect_indent();
632 for d in &mut block.directives {
633 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
634 if d.value != value {
636 d.value = value.to_string();
637 let trimmed = d.raw_line.trim_start();
643 let after_key = &trimmed[d.key.len()..];
644 let sep = if after_key.trim_start().starts_with('=') {
645 let eq_pos = after_key.find('=').unwrap();
646 let after_eq = &after_key[eq_pos + 1..];
647 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
648 after_key[..eq_pos + 1 + trailing_ws].to_string()
649 } else {
650 " ".to_string()
651 };
652 let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
654 d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
655 }
656 return;
657 }
658 }
659 let pos = block.content_end();
661 block.directives.insert(
662 pos,
663 Directive {
664 key: key.to_string(),
665 value: value.to_string(),
666 raw_line: format!("{}{} {}", indent, key, value),
667 is_non_directive: false,
668 },
669 );
670 }
671
672 fn extract_inline_comment(raw_line: &str, key: &str) -> String {
676 let trimmed = raw_line.trim_start();
677 if trimmed.len() <= key.len() {
678 return String::new();
679 }
680 let after_key = &trimmed[key.len()..];
682 let rest = after_key.trim_start();
683 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
684 let bytes = rest.as_bytes();
686 let mut in_quote = false;
687 for i in 0..bytes.len() {
688 if bytes[i] == b'"' {
689 in_quote = !in_quote;
690 } else if !in_quote
691 && bytes[i] == b'#'
692 && i > 0
693 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
694 {
695 let clean_end = rest[..i].trim_end().len();
697 return rest[clean_end..].to_string();
698 }
699 }
700 String::new()
701 }
702
703 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
705 for element in &mut self.elements {
706 if let ConfigElement::HostBlock(block) = element {
707 if block.host_pattern == alias {
708 block.set_provider(provider_name, server_id);
709 return;
710 }
711 }
712 }
713 }
714
715 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
719 let mut results = Vec::new();
720 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
721 results
722 }
723
724 fn collect_provider_hosts(
725 elements: &[ConfigElement],
726 provider_name: &str,
727 results: &mut Vec<(String, String)>,
728 ) {
729 for element in elements {
730 match element {
731 ConfigElement::HostBlock(block) => {
732 if let Some((name, id)) = block.provider() {
733 if name == provider_name {
734 results.push((block.host_pattern.clone(), id));
735 }
736 }
737 }
738 ConfigElement::Include(include) => {
739 for file in &include.resolved_files {
740 Self::collect_provider_hosts(&file.elements, provider_name, results);
741 }
742 }
743 ConfigElement::GlobalLine(_) => {}
744 }
745 }
746 }
747
748 fn values_match(a: &str, b: &str) -> bool {
751 a.split_whitespace().eq(b.split_whitespace())
752 }
753
754 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
758 for element in &mut self.elements {
759 if let ConfigElement::HostBlock(block) = element {
760 if block.host_pattern.split_whitespace().any(|p| p == alias) {
761 let indent = block.detect_indent();
762 let pos = block.content_end();
763 block.directives.insert(
764 pos,
765 Directive {
766 key: directive_key.to_string(),
767 value: value.to_string(),
768 raw_line: format!("{}{} {}", indent, directive_key, value),
769 is_non_directive: false,
770 },
771 );
772 return;
773 }
774 }
775 }
776 }
777
778 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
783 for element in &mut self.elements {
784 if let ConfigElement::HostBlock(block) = element {
785 if block.host_pattern.split_whitespace().any(|p| p == alias) {
786 if let Some(pos) = block.directives.iter().position(|d| {
787 !d.is_non_directive
788 && d.key.eq_ignore_ascii_case(directive_key)
789 && Self::values_match(&d.value, value)
790 }) {
791 block.directives.remove(pos);
792 return true;
793 }
794 return false;
795 }
796 }
797 }
798 false
799 }
800
801 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
804 for element in &self.elements {
805 if let ConfigElement::HostBlock(block) = element {
806 if block.host_pattern.split_whitespace().any(|p| p == alias) {
807 return block.directives.iter().any(|d| {
808 !d.is_non_directive
809 && d.key.eq_ignore_ascii_case(directive_key)
810 && Self::values_match(&d.value, value)
811 });
812 }
813 }
814 }
815 false
816 }
817
818 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
822 Self::find_tunnel_directives_in(&self.elements, alias)
823 }
824
825 fn find_tunnel_directives_in(
826 elements: &[ConfigElement],
827 alias: &str,
828 ) -> Vec<crate::tunnel::TunnelRule> {
829 for element in elements {
830 match element {
831 ConfigElement::HostBlock(block) => {
832 if block.host_pattern.split_whitespace().any(|p| p == alias) {
833 return block.tunnel_directives();
834 }
835 }
836 ConfigElement::Include(include) => {
837 for file in &include.resolved_files {
838 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
839 if !rules.is_empty() {
840 return rules;
841 }
842 }
843 }
844 ConfigElement::GlobalLine(_) => {}
845 }
846 }
847 Vec::new()
848 }
849
850 pub fn deduplicate_alias(&self, base: &str) -> String {
852 self.deduplicate_alias_excluding(base, None)
853 }
854
855 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
858 let is_taken = |alias: &str| {
859 if exclude == Some(alias) {
860 return false;
861 }
862 self.has_host(alias)
863 };
864 if !is_taken(base) {
865 return base.to_string();
866 }
867 for n in 2..=9999 {
868 let candidate = format!("{}-{}", base, n);
869 if !is_taken(&candidate) {
870 return candidate;
871 }
872 }
873 format!("{}-{}", base, std::process::id())
875 }
876
877 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
879 for element in &mut self.elements {
880 if let ConfigElement::HostBlock(block) = element {
881 if block.host_pattern == alias {
882 block.set_tags(tags);
883 return;
884 }
885 }
886 }
887 }
888
889 pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
891 for element in &mut self.elements {
892 if let ConfigElement::HostBlock(block) = element {
893 if block.host_pattern == alias {
894 block.set_provider_tags(tags);
895 return;
896 }
897 }
898 }
899 }
900
901 pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
903 for element in &mut self.elements {
904 if let ConfigElement::HostBlock(block) = element {
905 if block.host_pattern == alias {
906 block.set_askpass(source);
907 return;
908 }
909 }
910 }
911 }
912
913 pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) {
915 for element in &mut self.elements {
916 if let ConfigElement::HostBlock(block) = element {
917 if block.host_pattern == alias {
918 block.set_vault_ssh(role);
919 return;
920 }
921 }
922 }
923 }
924
925 #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
940 pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
941 if alias.is_empty() || is_host_pattern(alias) {
945 return false;
946 }
947 for element in &mut self.elements {
948 if let ConfigElement::HostBlock(block) = element {
949 if block.host_pattern == alias {
950 block.set_vault_addr(url);
951 return true;
952 }
953 }
954 }
955 false
956 }
957
958 #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
969 pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
970 if alias.is_empty() || is_host_pattern(alias) {
978 return false;
979 }
980 for element in &mut self.elements {
981 if let ConfigElement::HostBlock(block) = element {
982 if block.host_pattern == alias {
983 Self::upsert_directive(block, "CertificateFile", path);
984 return true;
985 }
986 }
987 }
988 false
989 }
990
991 pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
993 for element in &mut self.elements {
994 if let ConfigElement::HostBlock(block) = element {
995 if block.host_pattern == alias {
996 block.set_meta(meta);
997 return;
998 }
999 }
1000 }
1001 }
1002
1003 pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1005 for element in &mut self.elements {
1006 if let ConfigElement::HostBlock(block) = element {
1007 if block.host_pattern == alias {
1008 block.set_stale(timestamp);
1009 return;
1010 }
1011 }
1012 }
1013 }
1014
1015 pub fn clear_host_stale(&mut self, alias: &str) {
1017 for element in &mut self.elements {
1018 if let ConfigElement::HostBlock(block) = element {
1019 if block.host_pattern == alias {
1020 block.clear_stale();
1021 return;
1022 }
1023 }
1024 }
1025 }
1026
1027 pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1029 let mut result = Vec::new();
1030 for element in &self.elements {
1031 if let ConfigElement::HostBlock(block) = element {
1032 if let Some(ts) = block.stale() {
1033 result.push((block.host_pattern.clone(), ts));
1034 }
1035 }
1036 }
1037 result
1038 }
1039
1040 pub fn delete_host(&mut self, alias: &str) {
1042 let provider_name = self.elements.iter().find_map(|e| {
1045 if let ConfigElement::HostBlock(b) = e {
1046 if b.host_pattern == alias {
1047 return b.provider().map(|(name, _)| name);
1048 }
1049 }
1050 None
1051 });
1052
1053 self.elements.retain(|e| match e {
1054 ConfigElement::HostBlock(block) => block.host_pattern != alias,
1055 _ => true,
1056 });
1057
1058 if let Some(name) = provider_name {
1060 self.remove_orphaned_group_header(&name);
1061 }
1062
1063 self.elements.dedup_by(|a, b| {
1065 matches!(
1066 (&*a, &*b),
1067 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1068 if x.trim().is_empty() && y.trim().is_empty()
1069 )
1070 });
1071 }
1072
1073 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1078 let pos = self
1079 .elements
1080 .iter()
1081 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
1082 let element = self.elements.remove(pos);
1083 Some((element, pos))
1084 }
1085
1086 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1088 let pos = position.min(self.elements.len());
1089 self.elements.insert(pos, element);
1090 }
1091
1092 pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1096 let mut last_pos = None;
1097 for (i, element) in self.elements.iter().enumerate() {
1098 if let ConfigElement::HostBlock(block) = element {
1099 if let Some((name, _)) = block.provider() {
1100 if name == provider_name {
1101 last_pos = Some(i);
1102 }
1103 }
1104 }
1105 }
1106 last_pos.map(|p| p + 1)
1108 }
1109
1110 #[allow(dead_code)]
1112 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1113 let pos_a = self
1114 .elements
1115 .iter()
1116 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1117 let pos_b = self
1118 .elements
1119 .iter()
1120 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1121 if let (Some(a), Some(b)) = (pos_a, pos_b) {
1122 if a == b {
1123 return false;
1124 }
1125 let (first, second) = (a.min(b), a.max(b));
1126
1127 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1129 block.pop_trailing_blanks();
1130 }
1131 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1132 block.pop_trailing_blanks();
1133 }
1134
1135 self.elements.swap(first, second);
1137
1138 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1140 block.ensure_trailing_blank();
1141 }
1142
1143 if second < self.elements.len() - 1 {
1145 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1146 block.ensure_trailing_blank();
1147 }
1148 }
1149
1150 return true;
1151 }
1152 false
1153 }
1154
1155 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1157 debug_assert!(
1160 !entry.alias.contains('\n') && !entry.alias.contains('\r'),
1161 "entry_to_block: alias contains newline"
1162 );
1163 debug_assert!(
1164 !entry.hostname.contains('\n') && !entry.hostname.contains('\r'),
1165 "entry_to_block: hostname contains newline"
1166 );
1167 debug_assert!(
1168 !entry.user.contains('\n') && !entry.user.contains('\r'),
1169 "entry_to_block: user contains newline"
1170 );
1171
1172 let mut directives = Vec::new();
1173
1174 if !entry.hostname.is_empty() {
1175 directives.push(Directive {
1176 key: "HostName".to_string(),
1177 value: entry.hostname.clone(),
1178 raw_line: format!(" HostName {}", entry.hostname),
1179 is_non_directive: false,
1180 });
1181 }
1182 if !entry.user.is_empty() {
1183 directives.push(Directive {
1184 key: "User".to_string(),
1185 value: entry.user.clone(),
1186 raw_line: format!(" User {}", entry.user),
1187 is_non_directive: false,
1188 });
1189 }
1190 if entry.port != 22 {
1191 directives.push(Directive {
1192 key: "Port".to_string(),
1193 value: entry.port.to_string(),
1194 raw_line: format!(" Port {}", entry.port),
1195 is_non_directive: false,
1196 });
1197 }
1198 if !entry.identity_file.is_empty() {
1199 directives.push(Directive {
1200 key: "IdentityFile".to_string(),
1201 value: entry.identity_file.clone(),
1202 raw_line: format!(" IdentityFile {}", entry.identity_file),
1203 is_non_directive: false,
1204 });
1205 }
1206 if !entry.proxy_jump.is_empty() {
1207 directives.push(Directive {
1208 key: "ProxyJump".to_string(),
1209 value: entry.proxy_jump.clone(),
1210 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
1211 is_non_directive: false,
1212 });
1213 }
1214
1215 HostBlock {
1216 host_pattern: entry.alias.clone(),
1217 raw_host_line: format!("Host {}", entry.alias),
1218 directives,
1219 }
1220 }
1221}
1222
1223#[cfg(test)]
1224#[path = "model_tests.rs"]
1225mod tests;