purple_ssh/ssh_config/model.rs
1use std::path::PathBuf;
2
3/// Represents the entire SSH config file as a sequence of elements.
4/// Preserves the original structure for round-trip fidelity.
5#[derive(Debug, Clone)]
6pub struct SshConfigFile {
7 pub elements: Vec<ConfigElement>,
8 pub path: PathBuf,
9 /// Whether the original file used CRLF line endings.
10 pub crlf: bool,
11 /// Whether the original file started with a UTF-8 BOM.
12 pub bom: bool,
13}
14
15/// An Include directive that references other config files.
16#[derive(Debug, Clone)]
17pub struct IncludeDirective {
18 pub raw_line: String,
19 pub pattern: String,
20 pub resolved_files: Vec<IncludedFile>,
21}
22
23/// A file resolved from an Include directive.
24#[derive(Debug, Clone)]
25pub struct IncludedFile {
26 pub path: PathBuf,
27 pub elements: Vec<ConfigElement>,
28}
29
30/// A single element in the config file.
31#[derive(Debug, Clone)]
32pub enum ConfigElement {
33 /// A Host block: the `Host <pattern>` line plus all indented directives.
34 HostBlock(HostBlock),
35 /// A comment, blank line, or global directive not inside a Host block.
36 GlobalLine(String),
37 /// An Include directive referencing other config files (read-only).
38 Include(IncludeDirective),
39}
40
41/// A parsed Host block with its directives.
42#[derive(Debug, Clone)]
43pub struct HostBlock {
44 /// The host alias/pattern (the value after "Host").
45 pub host_pattern: String,
46 /// The original raw "Host ..." line for faithful reproduction.
47 pub raw_host_line: String,
48 /// Parsed directives inside this block.
49 pub directives: Vec<Directive>,
50}
51
52/// A directive line inside a Host block.
53#[derive(Debug, Clone)]
54pub struct Directive {
55 /// The directive key (e.g., "HostName", "User", "Port").
56 pub key: String,
57 /// The directive value.
58 pub value: String,
59 /// The original raw line (preserves indentation, inline comments).
60 pub raw_line: String,
61 /// Whether this is a comment-only or blank line inside a host block.
62 pub is_non_directive: bool,
63}
64
65/// Convenience view for the TUI — extracted from a HostBlock.
66#[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 /// If this host comes from an included file, the file path.
75 pub source_file: Option<PathBuf>,
76 /// User-added tags from purple:tags comment.
77 pub tags: Vec<String>,
78 /// Provider-synced tags from purple:provider_tags comment.
79 pub provider_tags: Vec<String>,
80 /// Whether a purple:provider_tags comment exists (distinguishes "never migrated" from "empty").
81 pub has_provider_tags: bool,
82 /// Cloud provider label from purple:provider comment (e.g. "do", "vultr").
83 pub provider: Option<String>,
84 /// Provider config label from a 3-segment purple:provider marker
85 /// (`provider:label:server_id`). None for legacy 2-segment markers.
86 /// Used together with `provider` to resolve which labeled config a host
87 /// belongs to in multi-config setups.
88 pub provider_label: Option<String>,
89 /// Number of tunnel forwarding directives.
90 pub tunnel_count: u16,
91 /// Password source from purple:askpass comment (e.g. "keychain", "op://...", "pass:...").
92 pub askpass: Option<String>,
93 /// Vault SSH certificate signing role from purple:vault-ssh comment.
94 pub vault_ssh: Option<String>,
95 /// Optional Vault HTTP endpoint from purple:vault-addr comment. When
96 /// set, purple passes it as `VAULT_ADDR` to the `vault` subprocess for
97 /// this host's signing, overriding the parent shell. Empty = inherit env.
98 pub vault_addr: Option<String>,
99 /// CertificateFile directive value (e.g. "~/.ssh/my-cert.pub").
100 pub certificate_file: String,
101 /// Provider metadata from purple:meta comment (region, plan, etc.).
102 pub provider_meta: Vec<(String, String)>,
103 /// Unix timestamp when the host was marked stale (disappeared from provider sync).
104 pub stale: Option<u64>,
105}
106
107impl Default for HostEntry {
108 fn default() -> Self {
109 Self {
110 alias: String::new(),
111 hostname: String::new(),
112 user: String::new(),
113 port: 22,
114 identity_file: String::new(),
115 proxy_jump: String::new(),
116 source_file: None,
117 tags: Vec::new(),
118 provider_tags: Vec::new(),
119 has_provider_tags: false,
120 provider: None,
121 provider_label: None,
122 tunnel_count: 0,
123 askpass: None,
124 vault_ssh: None,
125 vault_addr: None,
126 certificate_file: String::new(),
127 provider_meta: Vec::new(),
128 stale: None,
129 }
130 }
131}
132
133impl HostEntry {
134 /// Build the SSH command string for this host.
135 /// Includes `-F <config_path>` when the config is non-default so the alias
136 /// resolves correctly when pasted into a terminal.
137 /// Shell-quotes both the config path and alias to prevent injection.
138 pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
139 let escaped = self.alias.replace('\'', "'\\''");
140 let default = dirs::home_dir()
141 .map(|h| h.join(".ssh/config"))
142 .unwrap_or_default();
143 if config_path == default {
144 format!("ssh -- '{}'", escaped)
145 } else {
146 let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
147 format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
148 }
149 }
150}
151
152/// Convenience view for pattern Host blocks in the TUI.
153#[derive(Debug, Clone, Default)]
154pub struct PatternEntry {
155 pub pattern: String,
156 pub hostname: String,
157 pub user: String,
158 pub port: u16,
159 pub identity_file: String,
160 pub proxy_jump: String,
161 pub tags: Vec<String>,
162 pub askpass: Option<String>,
163 pub source_file: Option<PathBuf>,
164 /// All non-comment directives as key-value pairs for display.
165 pub directives: Vec<(String, String)>,
166}
167
168/// Inherited field hints from matching patterns. Each field is `Some((value,
169/// source_pattern))` when a pattern provides that directive, `None` otherwise.
170#[derive(Debug, Clone, Default)]
171pub struct InheritedHints {
172 pub proxy_jump: Option<(String, String)>,
173 pub user: Option<(String, String)>,
174 pub identity_file: Option<(String, String)>,
175}
176
177use super::pattern::apply_first_match_fields;
178/// Returns true if the host pattern contains wildcards, character classes,
179/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
180/// These are SSH match patterns, not concrete hosts.
181// Pattern-matching lives in `ssh_config::pattern`. These re-exports preserve
182// the old `ssh_config::model::*` import paths used across the codebase and in
183// the model_tests file mounted below.
184#[allow(unused_imports)]
185pub use super::pattern::{
186 host_pattern_matches, is_host_pattern, proxy_jump_contains_self, ssh_pattern_match,
187};
188// Re-exported so the test file mounted below keeps working.
189#[allow(unused_imports)]
190pub(super) use super::repair::provider_group_display_name;
191
192impl SshConfigFile {
193 /// Get all host entries as convenience views (including from Include files).
194 /// Pattern-inherited directives (ProxyJump, User, IdentityFile) are merged
195 /// using SSH-faithful alias-only matching so indicators like ↗ reflect what
196 /// SSH will actually apply when connecting via `ssh <alias>`.
197 pub fn host_entries(&self) -> Vec<HostEntry> {
198 let mut entries = Vec::new();
199 Self::collect_host_entries(&self.elements, &mut entries);
200 self.apply_pattern_inheritance(&mut entries);
201 entries
202 }
203
204 /// Get a single host entry by alias without pattern inheritance applied.
205 /// Returns the raw directives from the host's own block only. Used by the
206 /// edit form so inherited values can be shown as dimmed placeholders.
207 pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
208 Self::find_raw_host_entry(&self.elements, alias)
209 }
210
211 fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
212 for e in elements {
213 match e {
214 ConfigElement::HostBlock(block)
215 if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
216 {
217 return Some(block.to_host_entry());
218 }
219 ConfigElement::Include(inc) => {
220 for file in &inc.resolved_files {
221 if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
222 if found.source_file.is_none() {
223 found.source_file = Some(file.path.clone());
224 }
225 return Some(found);
226 }
227 }
228 }
229 _ => {}
230 }
231 }
232 None
233 }
234
235 /// Apply SSH first-match-wins pattern inheritance to host entries.
236 /// Matches patterns against the alias only (SSH-faithful: `Host` patterns
237 /// match the token typed on the command line, not the resolved `Hostname`).
238 fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
239 // Patterns are pre-collected once. Host entries never contain pattern
240 // aliases — collect_host_entries skips is_host_pattern blocks.
241 let all_patterns = self.pattern_entries();
242 for entry in entries.iter_mut() {
243 if !entry.proxy_jump.is_empty()
244 && !entry.user.is_empty()
245 && !entry.identity_file.is_empty()
246 {
247 continue;
248 }
249 for p in &all_patterns {
250 if !host_pattern_matches(&p.pattern, &entry.alias) {
251 continue;
252 }
253 apply_first_match_fields(
254 &mut entry.proxy_jump,
255 &mut entry.user,
256 &mut entry.identity_file,
257 p,
258 );
259 if !entry.proxy_jump.is_empty()
260 && !entry.user.is_empty()
261 && !entry.identity_file.is_empty()
262 {
263 break;
264 }
265 }
266 }
267 }
268
269 /// Compute pattern-provided field hints for a host alias. Returns first-match
270 /// values and their source patterns for ProxyJump, User and IdentityFile.
271 /// These are returned regardless of whether the host has its own values for
272 /// those fields. The caller (form rendering) decides visibility based on
273 /// whether the field is empty. Matches by alias only (SSH-faithful).
274 pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
275 let patterns = self.matching_patterns(alias);
276 let mut hints = InheritedHints::default();
277 for p in &patterns {
278 if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
279 hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
280 }
281 if hints.user.is_none() && !p.user.is_empty() {
282 hints.user = Some((p.user.clone(), p.pattern.clone()));
283 }
284 if hints.identity_file.is_none() && !p.identity_file.is_empty() {
285 hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
286 }
287 if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
288 break;
289 }
290 }
291 hints
292 }
293
294 /// Get all pattern entries as convenience views (including from Include files).
295 pub fn pattern_entries(&self) -> Vec<PatternEntry> {
296 let mut entries = Vec::new();
297 Self::collect_pattern_entries(&self.elements, &mut entries);
298 entries
299 }
300
301 fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
302 for e in elements {
303 match e {
304 ConfigElement::HostBlock(block) => {
305 if !is_host_pattern(&block.host_pattern) {
306 continue;
307 }
308 entries.push(block.to_pattern_entry());
309 }
310 ConfigElement::Include(include) => {
311 for file in &include.resolved_files {
312 let start = entries.len();
313 Self::collect_pattern_entries(&file.elements, entries);
314 for entry in &mut entries[start..] {
315 if entry.source_file.is_none() {
316 entry.source_file = Some(file.path.clone());
317 }
318 }
319 }
320 }
321 ConfigElement::GlobalLine(_) => {}
322 }
323 }
324 }
325
326 /// Find all pattern blocks that match a given host alias and hostname.
327 /// Returns entries in config order (first match first).
328 pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
329 let mut matches = Vec::new();
330 Self::collect_matching_patterns(&self.elements, alias, &mut matches);
331 matches
332 }
333
334 fn collect_matching_patterns(
335 elements: &[ConfigElement],
336 alias: &str,
337 matches: &mut Vec<PatternEntry>,
338 ) {
339 for e in elements {
340 match e {
341 ConfigElement::HostBlock(block) => {
342 if !is_host_pattern(&block.host_pattern) {
343 continue;
344 }
345 if host_pattern_matches(&block.host_pattern, alias) {
346 matches.push(block.to_pattern_entry());
347 }
348 }
349 ConfigElement::Include(include) => {
350 for file in &include.resolved_files {
351 let start = matches.len();
352 Self::collect_matching_patterns(&file.elements, alias, matches);
353 for entry in &mut matches[start..] {
354 if entry.source_file.is_none() {
355 entry.source_file = Some(file.path.clone());
356 }
357 }
358 }
359 }
360 ConfigElement::GlobalLine(_) => {}
361 }
362 }
363 }
364
365 /// Collect all resolved Include file paths (recursively).
366 pub fn include_paths(&self) -> Vec<PathBuf> {
367 let mut paths = Vec::new();
368 Self::collect_include_paths(&self.elements, &mut paths);
369 paths
370 }
371
372 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
373 for e in elements {
374 if let ConfigElement::Include(include) = e {
375 for file in &include.resolved_files {
376 paths.push(file.path.clone());
377 Self::collect_include_paths(&file.elements, paths);
378 }
379 }
380 }
381 }
382
383 /// Collect parent directories of Include glob patterns.
384 /// When a file is added/removed under a glob dir, the directory's mtime changes.
385 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
386 let config_dir = self.path.parent();
387 let mut seen = std::collections::HashSet::new();
388 let mut dirs = Vec::new();
389 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
390 dirs
391 }
392
393 fn collect_include_glob_dirs(
394 elements: &[ConfigElement],
395 config_dir: Option<&std::path::Path>,
396 seen: &mut std::collections::HashSet<PathBuf>,
397 dirs: &mut Vec<PathBuf>,
398 ) {
399 for e in elements {
400 if let ConfigElement::Include(include) = e {
401 // Split respecting quoted paths (same as resolve_include does)
402 for single in Self::split_include_patterns(&include.pattern) {
403 let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
404 let resolved = if expanded.starts_with('/') {
405 PathBuf::from(&expanded)
406 } else if let Some(dir) = config_dir {
407 dir.join(&expanded)
408 } else {
409 continue;
410 };
411 if let Some(parent) = resolved.parent() {
412 let parent = parent.to_path_buf();
413 if seen.insert(parent.clone()) {
414 dirs.push(parent);
415 }
416 }
417 }
418 // Recurse into resolved files
419 for file in &include.resolved_files {
420 Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
421 }
422 }
423 }
424 }
425
426 /// Remove `# purple:group <Name>` headers that have no corresponding
427 /// provider hosts. Returns the number of headers removed.
428 /// Recursively collect host entries from a list of elements.
429 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
430 for e in elements {
431 match e {
432 ConfigElement::HostBlock(block) => {
433 if is_host_pattern(&block.host_pattern) {
434 continue;
435 }
436 entries.push(block.to_host_entry());
437 }
438 ConfigElement::Include(include) => {
439 for file in &include.resolved_files {
440 let start = entries.len();
441 Self::collect_host_entries(&file.elements, entries);
442 for entry in &mut entries[start..] {
443 if entry.source_file.is_none() {
444 entry.source_file = Some(file.path.clone());
445 }
446 }
447 }
448 }
449 ConfigElement::GlobalLine(_) => {}
450 }
451 }
452 }
453
454 /// Check if a host alias already exists (including in Include files).
455 /// Walks the element tree directly without building HostEntry structs.
456 pub fn has_host(&self, alias: &str) -> bool {
457 Self::has_host_in_elements(&self.elements, alias)
458 }
459
460 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
461 for e in elements {
462 match e {
463 ConfigElement::HostBlock(block) => {
464 if block.host_pattern.split_whitespace().any(|p| p == alias) {
465 return true;
466 }
467 }
468 ConfigElement::Include(include) => {
469 for file in &include.resolved_files {
470 if Self::has_host_in_elements(&file.elements, alias) {
471 return true;
472 }
473 }
474 }
475 ConfigElement::GlobalLine(_) => {}
476 }
477 }
478 false
479 }
480
481 /// Return the sibling aliases that share a `Host` block with `alias`.
482 ///
483 /// An empty vector means `alias` lives in its own single-alias block (or
484 /// is not present). A non-empty vector lists the other tokens in the
485 /// block in source order, so the UI can render indicators like `+N` or
486 /// spell the aliases out in a confirm dialog before a destructive
487 /// action. Does not recurse into `Include`d files: those are read-only
488 /// and their hosts cannot be edited from purple anyway.
489 pub fn siblings_of(&self, alias: &str) -> Vec<String> {
490 if alias.is_empty() {
491 return Vec::new();
492 }
493 self.elements
494 .iter()
495 .find_map(|el| match el {
496 ConfigElement::HostBlock(b) => {
497 // Full-pattern match means the caller is acting on the
498 // whole block (e.g. pattern browser delete of
499 // `web-01 web-01.prod`). All tokens are the target, so
500 // there are no "siblings" to preserve.
501 if b.host_pattern == alias {
502 return Some(Vec::new());
503 }
504 let tokens: Vec<String> = b
505 .host_pattern
506 .split_whitespace()
507 .map(String::from)
508 .collect();
509 if tokens.iter().any(|t| t == alias) {
510 Some(tokens.into_iter().filter(|t| t != alias).collect())
511 } else {
512 None
513 }
514 }
515 _ => None,
516 })
517 .unwrap_or_default()
518 }
519
520 /// Find a mutable top-level `HostBlock` whose `host_pattern` contains
521 /// `alias` as one of its whitespace-separated tokens.
522 ///
523 /// Mirrors the matching used by read-path helpers like `has_host` and
524 /// `find_tunnel_directives`, so that any host visible in the TUI is also
525 /// addressable from write paths (`update_host`, `delete_host`,
526 /// `set_host_*`). Prior to this helper, writers compared the full
527 /// `host_pattern` for exact equality, which silently no-op'd on
528 /// multi-alias blocks like `Host web-01 web-01.prod 10.0.1.5` and
529 /// resulted in on-disk drift between the in-memory view and the config
530 /// file.
531 ///
532 /// Does not recurse into `Include`d files: those are read-only.
533 ///
534 /// A block matches when either (a) its full `host_pattern` equals
535 /// `alias` (used by the pattern browser for blocks like `web-* db-*`
536 /// or `web-01 web-01.prod` whose full pattern is the caller's key) or
537 /// (b) `alias` appears as one of the whitespace-separated tokens (used
538 /// by the host list for multi-alias blocks). The full-pattern match is
539 /// tried first so callers that pass a pattern string do not
540 /// accidentally trigger the token-strip path.
541 fn find_host_block_mut(&mut self, alias: &str) -> Option<&mut HostBlock> {
542 if alias.is_empty() {
543 return None;
544 }
545 self.elements.iter_mut().find_map(|el| match el {
546 ConfigElement::HostBlock(b)
547 if b.host_pattern == alias
548 || b.host_pattern.split_whitespace().any(|t| t == alias) =>
549 {
550 Some(b)
551 }
552 _ => None,
553 })
554 }
555
556 /// Check if a host block with exactly this host_pattern exists (top-level only).
557 /// Unlike `has_host` which splits multi-host patterns and checks individual parts,
558 /// this matches the full `Host` line pattern string (e.g. "web-* db-*").
559 /// Does not search Include files (patterns from includes are read-only).
560 pub fn has_host_block(&self, pattern: &str) -> bool {
561 self.elements
562 .iter()
563 .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
564 }
565
566 /// Check if a host alias is from an included file (read-only).
567 /// Handles multi-pattern Host lines by splitting on whitespace.
568 pub fn is_included_host(&self, alias: &str) -> bool {
569 // Not in top-level elements → must be in an Include
570 for e in &self.elements {
571 match e {
572 ConfigElement::HostBlock(block) => {
573 if block.host_pattern.split_whitespace().any(|p| p == alias) {
574 return false;
575 }
576 }
577 ConfigElement::Include(include) => {
578 for file in &include.resolved_files {
579 if Self::has_host_in_elements(&file.elements, alias) {
580 return true;
581 }
582 }
583 }
584 ConfigElement::GlobalLine(_) => {}
585 }
586 }
587 false
588 }
589
590 /// Add a new host entry to the config.
591 /// Inserts before any trailing wildcard/pattern Host blocks (e.g. `Host *`)
592 /// so that SSH "first match wins" semantics are preserved. If wildcards are
593 /// only at the top of the file (acting as global defaults), appends at end.
594 pub fn add_host(&mut self, entry: &HostEntry) {
595 let block = Self::entry_to_block(entry);
596 let insert_pos = self.find_trailing_pattern_start();
597
598 if let Some(pos) = insert_pos {
599 // Insert before the trailing pattern group, with blank separators
600 let needs_blank_before = pos > 0
601 && !matches!(
602 self.elements.get(pos - 1),
603 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
604 );
605 let mut idx = pos;
606 if needs_blank_before {
607 self.elements
608 .insert(idx, ConfigElement::GlobalLine(String::new()));
609 idx += 1;
610 }
611 self.elements.insert(idx, ConfigElement::HostBlock(block));
612 // Ensure a blank separator after the new block (before the wildcard group)
613 let after = idx + 1;
614 if after < self.elements.len()
615 && !matches!(
616 self.elements.get(after),
617 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
618 )
619 {
620 self.elements
621 .insert(after, ConfigElement::GlobalLine(String::new()));
622 }
623 } else {
624 // No trailing patterns: append at end
625 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
626 self.elements.push(ConfigElement::GlobalLine(String::new()));
627 }
628 self.elements.push(ConfigElement::HostBlock(block));
629 }
630 }
631
632 /// Find the start of a trailing group of wildcard/pattern Host blocks.
633 /// Scans backwards from the end, skipping GlobalLines (blanks/comments/Match).
634 /// Returns `None` if no trailing patterns exist (or if ALL hosts are patterns,
635 /// i.e. patterns start at position 0 — in that case we append at end).
636 fn find_trailing_pattern_start(&self) -> Option<usize> {
637 let mut first_pattern_pos = None;
638 for i in (0..self.elements.len()).rev() {
639 match &self.elements[i] {
640 ConfigElement::HostBlock(block) => {
641 if is_host_pattern(&block.host_pattern) {
642 first_pattern_pos = Some(i);
643 } else {
644 // Found a concrete host: the trailing group starts after this
645 break;
646 }
647 }
648 ConfigElement::GlobalLine(_) => {
649 // Blank lines, comments, Match blocks between patterns: keep scanning
650 if first_pattern_pos.is_some() {
651 first_pattern_pos = Some(i);
652 }
653 }
654 ConfigElement::Include(_) => break,
655 }
656 }
657 // Don't return position 0 — that means everything is patterns (or patterns at top)
658 first_pattern_pos.filter(|&pos| pos > 0)
659 }
660
661 /// Check if the last element already ends with a blank line.
662 pub fn last_element_has_trailing_blank(&self) -> bool {
663 match self.elements.last() {
664 Some(ConfigElement::HostBlock(block)) => block
665 .directives
666 .last()
667 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
668 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
669 _ => false,
670 }
671 }
672
673 /// Update an existing host entry by alias.
674 /// Merges changes into the existing block, preserving unknown directives.
675 ///
676 /// Alias matching uses whitespace-tokenized equality, so a host visible
677 /// under a multi-alias block like `Host web-01 web-01.prod` is reachable
678 /// from any of its aliases. Directives are shared across all tokens in
679 /// the block (per SSH semantics): updating `User` on `web-01.prod`
680 /// therefore also affects `web-01`.
681 ///
682 /// On rename of a multi-alias block only the matching token is replaced
683 /// in the `Host` line; sibling aliases are preserved verbatim.
684 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
685 let Some(block) = self.find_host_block_mut(old_alias) else {
686 return;
687 };
688
689 if entry.alias != old_alias {
690 // Full-pattern match (pattern browser rename) replaces the whole
691 // `host_pattern` verbatim. Token match (host list rename on a
692 // multi-alias block) replaces only the selected token so
693 // siblings survive. Single-alias blocks are covered by the
694 // token path because `tokens == [old_alias]`.
695 let is_full_pattern_match = block.host_pattern == old_alias;
696 let new_pattern: String = if is_full_pattern_match {
697 entry.alias.clone()
698 } else {
699 block
700 .host_pattern
701 .split_whitespace()
702 .map(|t| {
703 if t == old_alias {
704 entry.alias.as_str()
705 } else {
706 t
707 }
708 })
709 .collect::<Vec<_>>()
710 .join(" ")
711 };
712 block.host_pattern = new_pattern.clone();
713 block.raw_host_line = format!("Host {}", new_pattern);
714 }
715
716 // Merge known directives (update existing, add missing, remove empty)
717 Self::upsert_directive(block, "HostName", &entry.hostname);
718 Self::upsert_directive(block, "User", &entry.user);
719 if entry.port != 22 {
720 Self::upsert_directive(block, "Port", &entry.port.to_string());
721 } else {
722 // Remove explicit Port 22 (it's the default)
723 block
724 .directives
725 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
726 }
727 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
728 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
729 }
730
731 /// Update a directive in-place, add it if missing, or remove it if value is empty.
732 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
733 if value.is_empty() {
734 block
735 .directives
736 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
737 return;
738 }
739 let indent = block.detect_indent();
740 for d in &mut block.directives {
741 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
742 // Only rebuild raw_line when value actually changed (preserves inline comments)
743 if d.value != value {
744 d.value = value.to_string();
745 // Detect separator style from original raw_line and preserve it.
746 // Handles: "Key value", "Key=value", "Key = value", "Key =value"
747 // Only considers '=' as separator if it appears before any
748 // non-whitespace content (avoids matching '=' inside values
749 // like "IdentityFile ~/.ssh/id=prod").
750 let trimmed = d.raw_line.trim_start();
751 let after_key = &trimmed[d.key.len()..];
752 let sep = if after_key.trim_start().starts_with('=') {
753 let eq_pos = after_key.find('=').unwrap();
754 let after_eq = &after_key[eq_pos + 1..];
755 let trailing_ws = after_eq.len() - after_eq.trim_start().len();
756 after_key[..eq_pos + 1 + trailing_ws].to_string()
757 } else {
758 " ".to_string()
759 };
760 // Preserve inline comment from original raw_line (e.g. "# production")
761 let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
762 d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
763 }
764 return;
765 }
766 }
767 // Not found — insert before trailing blanks
768 let pos = block.content_end();
769 block.directives.insert(
770 pos,
771 Directive {
772 key: key.to_string(),
773 value: value.to_string(),
774 raw_line: format!("{}{} {}", indent, key, value),
775 is_non_directive: false,
776 },
777 );
778 }
779
780 /// Extract the inline comment suffix from a directive's raw line.
781 /// Returns the trailing portion (e.g. " # production") or empty string.
782 /// Respects double-quoted strings so that `#` inside quotes is not a comment.
783 fn extract_inline_comment(raw_line: &str, key: &str) -> String {
784 let trimmed = raw_line.trim_start();
785 if trimmed.len() <= key.len() {
786 return String::new();
787 }
788 // Skip past key and separator to reach the value portion
789 let after_key = &trimmed[key.len()..];
790 let rest = after_key.trim_start();
791 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
792 // Scan for inline comment (# preceded by whitespace, outside quotes)
793 let bytes = rest.as_bytes();
794 let mut in_quote = false;
795 for i in 0..bytes.len() {
796 if bytes[i] == b'"' {
797 in_quote = !in_quote;
798 } else if !in_quote
799 && bytes[i] == b'#'
800 && i > 0
801 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
802 {
803 // Found comment start. The clean value ends before the whitespace preceding #.
804 let clean_end = rest[..i].trim_end().len();
805 return rest[clean_end..].to_string();
806 }
807 }
808 String::new()
809 }
810
811 /// Set provider on a host block by alias using a full ProviderConfigId.
812 /// Emits a 3-segment marker when the id has a label, 2-segment otherwise.
813 pub fn set_host_provider_id(
814 &mut self,
815 alias: &str,
816 id: &crate::providers::config::ProviderConfigId,
817 server_id: &str,
818 ) {
819 if let Some(block) = self.find_host_block_mut(alias) {
820 block.set_provider_id(id, server_id);
821 }
822 }
823
824 /// Rewrite every 2-segment legacy marker for `provider_name` to a
825 /// 3-segment marker keyed to `(provider_name, label)`. Used by the
826 /// lazy-migration flow so existing hosts of a now-labeled config stay
827 /// owned (and don't get re-claimed or stale-marked) on the next sync.
828 ///
829 /// Only top-level host blocks are rewritten; Include files are read-only
830 /// per the project's invariant. Returns the count of host blocks touched.
831 pub fn rewrite_legacy_markers_to_label(&mut self, provider_name: &str, label: &str) -> usize {
832 let new_id = crate::providers::config::ProviderConfigId::labeled(provider_name, label);
833 let mut rewritten = 0usize;
834 for element in &mut self.elements {
835 if let ConfigElement::HostBlock(block) = element {
836 let Some((id, server_id)) = block.provider_id() else {
837 continue;
838 };
839 if id.provider == provider_name && id.label.is_none() {
840 block.set_provider_id(&new_id, &server_id);
841 rewritten += 1;
842 }
843 }
844 }
845 rewritten
846 }
847
848 /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
849 /// Searches both top-level elements and Include files so that provider hosts
850 /// in included configs are recognized during sync (prevents duplicate additions).
851 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
852 let mut results = Vec::new();
853 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
854 results
855 }
856
857 /// Find hosts owned by an exact `ProviderConfigId`. Used during multi-config sync
858 /// so two labeled configs of the same provider don't claim each other's hosts.
859 /// Legacy 2-segment markers match a bare id (label=None) for backward compatibility.
860 pub fn find_hosts_by_id(
861 &self,
862 id: &crate::providers::config::ProviderConfigId,
863 ) -> Vec<(String, String)> {
864 let mut results = Vec::new();
865 Self::collect_provider_hosts_by_id(&self.elements, id, &mut results);
866 results
867 }
868
869 /// Like `find_hosts_by_provider`, but returns the FULL server_id from the
870 /// raw marker (everything after the first colon), without trying to
871 /// interpret the middle segment as a label. Used by sync of BARE configs
872 /// so server_ids containing colons (Proxmox `qemu:300`) are matched
873 /// against the API response one-to-one instead of being mis-parsed as
874 /// labeled markers.
875 pub fn find_hosts_by_provider_raw(&self, provider_name: &str) -> Vec<(String, String)> {
876 let mut results = Vec::new();
877 Self::collect_provider_hosts_raw(&self.elements, provider_name, &mut results);
878 results
879 }
880
881 fn collect_provider_hosts_raw(
882 elements: &[ConfigElement],
883 provider_name: &str,
884 results: &mut Vec<(String, String)>,
885 ) {
886 for element in elements {
887 match element {
888 ConfigElement::HostBlock(block) => {
889 if let Some((name, server_id)) = block.provider_raw() {
890 if name == provider_name {
891 results.push((block.host_pattern.clone(), server_id));
892 }
893 }
894 }
895 ConfigElement::Include(include) => {
896 for file in &include.resolved_files {
897 Self::collect_provider_hosts_raw(&file.elements, provider_name, results);
898 }
899 }
900 ConfigElement::GlobalLine(_) => {}
901 }
902 }
903 }
904
905 fn collect_provider_hosts(
906 elements: &[ConfigElement],
907 provider_name: &str,
908 results: &mut Vec<(String, String)>,
909 ) {
910 for element in elements {
911 match element {
912 ConfigElement::HostBlock(block) => {
913 if let Some((name, id)) = block.provider() {
914 if name == provider_name {
915 results.push((block.host_pattern.clone(), id));
916 }
917 }
918 }
919 ConfigElement::Include(include) => {
920 for file in &include.resolved_files {
921 Self::collect_provider_hosts(&file.elements, provider_name, results);
922 }
923 }
924 ConfigElement::GlobalLine(_) => {}
925 }
926 }
927 }
928
929 fn collect_provider_hosts_by_id(
930 elements: &[ConfigElement],
931 id: &crate::providers::config::ProviderConfigId,
932 results: &mut Vec<(String, String)>,
933 ) {
934 for element in elements {
935 match element {
936 ConfigElement::HostBlock(block) => {
937 if let Some((host_id, server_id)) = block.provider_id() {
938 if &host_id == id {
939 results.push((block.host_pattern.clone(), server_id));
940 }
941 }
942 }
943 ConfigElement::Include(include) => {
944 for file in &include.resolved_files {
945 Self::collect_provider_hosts_by_id(&file.elements, id, results);
946 }
947 }
948 ConfigElement::GlobalLine(_) => {}
949 }
950 }
951 }
952
953 /// Compare two directive values with whitespace normalization.
954 /// Handles hand-edited configs with tabs or multiple spaces.
955 fn values_match(a: &str, b: &str) -> bool {
956 a.split_whitespace().eq(b.split_whitespace())
957 }
958
959 /// Add a forwarding directive to a host block.
960 /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
961 /// Uses split_whitespace matching for multi-pattern Host lines.
962 pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
963 for element in &mut self.elements {
964 if let ConfigElement::HostBlock(block) = element {
965 if block.host_pattern.split_whitespace().any(|p| p == alias) {
966 let indent = block.detect_indent();
967 let pos = block.content_end();
968 block.directives.insert(
969 pos,
970 Directive {
971 key: directive_key.to_string(),
972 value: value.to_string(),
973 raw_line: format!("{}{} {}", indent, directive_key, value),
974 is_non_directive: false,
975 },
976 );
977 return;
978 }
979 }
980 }
981 }
982
983 /// Remove a specific forwarding directive from a host block.
984 /// Matches key (case-insensitive) and value (whitespace-normalized).
985 /// Uses split_whitespace matching for multi-pattern Host lines.
986 /// Returns true if a directive was actually removed.
987 pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
988 for element in &mut self.elements {
989 if let ConfigElement::HostBlock(block) = element {
990 if block.host_pattern.split_whitespace().any(|p| p == alias) {
991 if let Some(pos) = block.directives.iter().position(|d| {
992 !d.is_non_directive
993 && d.key.eq_ignore_ascii_case(directive_key)
994 && Self::values_match(&d.value, value)
995 }) {
996 block.directives.remove(pos);
997 return true;
998 }
999 return false;
1000 }
1001 }
1002 }
1003 false
1004 }
1005
1006 /// Check if a host block has a specific forwarding directive.
1007 /// Uses whitespace-normalized value comparison and split_whitespace host matching.
1008 pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1009 for element in &self.elements {
1010 if let ConfigElement::HostBlock(block) = element {
1011 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1012 return block.directives.iter().any(|d| {
1013 !d.is_non_directive
1014 && d.key.eq_ignore_ascii_case(directive_key)
1015 && Self::values_match(&d.value, value)
1016 });
1017 }
1018 }
1019 }
1020 false
1021 }
1022
1023 /// Find tunnel directives for a host alias, searching all elements including
1024 /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
1025 /// Host lines.
1026 pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1027 Self::find_tunnel_directives_in(&self.elements, alias)
1028 }
1029
1030 fn find_tunnel_directives_in(
1031 elements: &[ConfigElement],
1032 alias: &str,
1033 ) -> Vec<crate::tunnel::TunnelRule> {
1034 for element in elements {
1035 match element {
1036 ConfigElement::HostBlock(block) => {
1037 if block.host_pattern.split_whitespace().any(|p| p == alias) {
1038 return block.tunnel_directives();
1039 }
1040 }
1041 ConfigElement::Include(include) => {
1042 for file in &include.resolved_files {
1043 let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1044 if !rules.is_empty() {
1045 return rules;
1046 }
1047 }
1048 }
1049 ConfigElement::GlobalLine(_) => {}
1050 }
1051 }
1052 Vec::new()
1053 }
1054
1055 /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
1056 pub fn deduplicate_alias(&self, base: &str) -> String {
1057 self.deduplicate_alias_excluding(base, None)
1058 }
1059
1060 /// Generate a unique alias, optionally excluding one alias from collision detection.
1061 /// Used during rename so the host being renamed doesn't collide with itself.
1062 pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1063 let is_taken = |alias: &str| {
1064 if exclude == Some(alias) {
1065 return false;
1066 }
1067 self.has_host(alias)
1068 };
1069 if !is_taken(base) {
1070 return base.to_string();
1071 }
1072 for n in 2..=9999 {
1073 let candidate = format!("{}-{}", base, n);
1074 if !is_taken(&candidate) {
1075 return candidate;
1076 }
1077 }
1078 // Practically unreachable: fall back to PID-based suffix
1079 format!("{}-{}", base, std::process::id())
1080 }
1081
1082 /// Set tags on a host block by alias.
1083 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1084 if let Some(block) = self.find_host_block_mut(alias) {
1085 block.set_tags(tags);
1086 }
1087 }
1088
1089 /// Set provider-synced tags on a host block by alias.
1090 pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1091 if let Some(block) = self.find_host_block_mut(alias) {
1092 block.set_provider_tags(tags);
1093 }
1094 }
1095
1096 /// Set askpass source on a host block by alias.
1097 pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1098 if let Some(block) = self.find_host_block_mut(alias) {
1099 block.set_askpass(source);
1100 }
1101 }
1102
1103 /// Set vault-ssh role on a host block by alias.
1104 pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) {
1105 if let Some(block) = self.find_host_block_mut(alias) {
1106 block.set_vault_ssh(role);
1107 }
1108 }
1109
1110 /// Set or remove the Vault SSH endpoint comment on a host block by alias.
1111 /// Empty `url` removes the comment.
1112 ///
1113 /// Mirrors the safety invariants of `set_host_certificate_file`: wildcard
1114 /// aliases are refused to avoid accidentally applying a vault address to
1115 /// every host resolved through a pattern, and Match blocks are not
1116 /// touched (they live as inert `GlobalLines`). Returns `true` on a
1117 /// successful mutation, `false` when the alias is invalid or the block
1118 /// is not found.
1119 ///
1120 /// Callers that run asynchronously (e.g. form submit handlers that
1121 /// resolve the alias before writing) MUST check the return value so a
1122 /// silent config mutation failure is surfaced instead of pretending the
1123 /// vault address was wired up.
1124 #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1125 pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
1126 // Same guard as `set_host_certificate_file`: refuse empty aliases
1127 // and any SSH pattern shape. `is_host_pattern` already covers
1128 // wildcards, negation and whitespace-separated multi-host forms.
1129 if alias.is_empty() || is_host_pattern(alias) {
1130 return false;
1131 }
1132 let Some(block) = self.find_host_block_mut(alias) else {
1133 return false;
1134 };
1135 // Defense in depth: refuse to mutate a block that is itself a
1136 // pattern or a multi-alias block (ExactAliasOnly policy). Writing a
1137 // vault endpoint onto such a block would apply to every sibling
1138 // alias and every host resolving through the pattern, which is
1139 // almost certainly not what the caller intends.
1140 if is_host_pattern(&block.host_pattern) {
1141 return false;
1142 }
1143 block.set_vault_addr(url);
1144 true
1145 }
1146
1147 /// Set or remove the CertificateFile directive on a host block by alias.
1148 /// Empty path removes the directive.
1149 /// Set the `CertificateFile` directive on the host block that matches
1150 /// `alias` exactly. Returns `true` if a matching block was found and
1151 /// updated, `false` if no top-level `HostBlock` matched (alias was
1152 /// renamed, deleted or lives only inside an `Include`d file).
1153 ///
1154 /// Callers that run asynchronously (e.g. the Vault SSH bulk-sign worker)
1155 /// MUST check the return value so a silent config mutation failure is
1156 /// surfaced to the user instead of pretending the cert was wired up.
1157 #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1158 pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
1159 // Defense in depth: refuse to mutate a host block when the requested
1160 // alias is empty or matches any SSH pattern shape (`*`, `?`, `[`,
1161 // leading `!`, or whitespace-separated multi-host form like
1162 // `Host web-* db-*`). Writing `CertificateFile` onto a pattern
1163 // block is almost never what a user intends and would affect every
1164 // host that resolves through that pattern. Reusing `is_host_pattern`
1165 // keeps this check in sync with the form-level pattern detection.
1166 if alias.is_empty() || is_host_pattern(alias) {
1167 return false;
1168 }
1169 let Some(block) = self.find_host_block_mut(alias) else {
1170 return false;
1171 };
1172 // Additionally refuse when the matched block is itself a pattern or
1173 // multi-alias block (ExactAliasOnly policy). The input `alias` may
1174 // be a plain token yet resolve into a block like `Host web-01
1175 // web-01.prod`, where writing `CertificateFile` would silently
1176 // affect sibling aliases.
1177 if is_host_pattern(&block.host_pattern) {
1178 return false;
1179 }
1180 Self::upsert_directive(block, "CertificateFile", path);
1181 true
1182 }
1183
1184 /// Set provider metadata on a host block by alias.
1185 pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1186 if let Some(block) = self.find_host_block_mut(alias) {
1187 block.set_meta(meta);
1188 }
1189 }
1190
1191 /// Mark a host as stale by alias.
1192 pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1193 if let Some(block) = self.find_host_block_mut(alias) {
1194 block.set_stale(timestamp);
1195 }
1196 }
1197
1198 /// Clear stale marking from a host by alias.
1199 pub fn clear_host_stale(&mut self, alias: &str) {
1200 if let Some(block) = self.find_host_block_mut(alias) {
1201 block.clear_stale();
1202 }
1203 }
1204
1205 /// Collect all stale hosts with their timestamps.
1206 pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1207 let mut result = Vec::new();
1208 for element in &self.elements {
1209 if let ConfigElement::HostBlock(block) = element {
1210 if let Some(ts) = block.stale() {
1211 result.push((block.host_pattern.clone(), ts));
1212 }
1213 }
1214 }
1215 result
1216 }
1217
1218 /// Delete a host entry by alias.
1219 ///
1220 /// For a single-alias block this removes the whole block (and cleans up
1221 /// any orphaned `# purple:group` header left behind). For a multi-alias
1222 /// block like `Host web-01 web-01.prod 10.0.1.5` only the matching
1223 /// alias token is stripped from the `Host` line; sibling aliases and
1224 /// all directives are preserved so that `delete_host("web-01.prod")`
1225 /// does not silently wipe configuration for `web-01` and `10.0.1.5`.
1226 ///
1227 /// Callers that want to remove the entire block regardless of sibling
1228 /// aliases should surface an explicit confirmation in the UI and then
1229 /// delete each sibling alias in turn.
1230 pub fn delete_host(&mut self, alias: &str) {
1231 // Two matching modes:
1232 // - Full-pattern match: block.host_pattern == alias. Removes the
1233 // entire block (plus duplicates). Used by the pattern browser,
1234 // where `alias` is a full pattern string like `web-* db-*` or
1235 // `web-01 web-01.prod`.
1236 // - Token match: alias appears as one of the whitespace-separated
1237 // tokens. Strips just that token from a multi-alias block and
1238 // removes single-alias blocks outright. Used by the host list.
1239 // Full-pattern is checked first so pattern-browser deletes never
1240 // degenerate into partial token strips.
1241 let has_full_match = self
1242 .elements
1243 .iter()
1244 .any(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias));
1245
1246 // Capture the provider for orphaned-group cleanup before mutation.
1247 let provider_name = self.elements.iter().find_map(|e| match e {
1248 ConfigElement::HostBlock(b)
1249 if (has_full_match && b.host_pattern == alias)
1250 || (!has_full_match
1251 && b.host_pattern.split_whitespace().any(|t| t == alias)) =>
1252 {
1253 b.provider().map(|(name, _)| name)
1254 }
1255 _ => None,
1256 });
1257
1258 if has_full_match {
1259 // Remove every block whose full host_pattern equals the input
1260 // (duplicate-block invariant preserved, matches pre-refactor).
1261 self.elements.retain(|e| match e {
1262 ConfigElement::HostBlock(block) => block.host_pattern != alias,
1263 _ => true,
1264 });
1265 } else {
1266 // Token-aware: strip the alias from multi-alias blocks first,
1267 // then drop single-alias blocks whose sole token equals alias.
1268 for el in &mut self.elements {
1269 if let ConfigElement::HostBlock(block) = el {
1270 let tokens: Vec<&str> = block.host_pattern.split_whitespace().collect();
1271 if tokens.len() > 1 && tokens.contains(&alias) {
1272 let new_pattern = tokens
1273 .iter()
1274 .filter(|t| **t != alias)
1275 .copied()
1276 .collect::<Vec<_>>()
1277 .join(" ");
1278 block.host_pattern = new_pattern.clone();
1279 block.raw_host_line = format!("Host {}", new_pattern);
1280 }
1281 }
1282 }
1283 self.elements.retain(|e| match e {
1284 ConfigElement::HostBlock(block) => {
1285 let mut tokens = block.host_pattern.split_whitespace();
1286 !matches!(
1287 (tokens.next(), tokens.next()),
1288 (Some(first), None) if first == alias
1289 )
1290 }
1291 _ => true,
1292 });
1293 }
1294
1295 if let Some(name) = provider_name {
1296 self.remove_orphaned_group_header(&name);
1297 }
1298
1299 // Collapse consecutive blank lines left by deletion
1300 self.elements.dedup_by(|a, b| {
1301 matches!(
1302 (&*a, &*b),
1303 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1304 if x.trim().is_empty() && y.trim().is_empty()
1305 )
1306 });
1307 }
1308
1309 /// Delete a host and return the removed element and its position for undo.
1310 /// Does NOT collapse blank lines or remove group headers so the position
1311 /// stays valid for re-insertion via `insert_host_at()`.
1312 /// Orphaned group headers (if any) are cleaned up at next startup.
1313 ///
1314 /// For multi-alias blocks this returns `None`: undoable-delete of a
1315 /// single alias out of a shared `Host` line cannot be round-tripped via
1316 /// `insert_host_at` because sibling aliases would be lost. Callers
1317 /// should fall back to `delete_host` in that case (which strips only
1318 /// the requested token).
1319 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1320 // Two-mode match mirroring `delete_host`: full-pattern first (for
1321 // pattern-browser deletes where `alias` is the whole pattern
1322 // string), then token match. Undoable delete is only safe when
1323 // removing the entire block; token-strip on a multi-alias block is
1324 // therefore refused (returns `None`) because re-inserting the
1325 // whole element would not reverse a token strip.
1326 let full_pos = self
1327 .elements
1328 .iter()
1329 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias));
1330 let pos = if let Some(p) = full_pos {
1331 p
1332 } else {
1333 let token_pos = self.elements.iter().position(|e| match e {
1334 ConfigElement::HostBlock(b) => {
1335 b.host_pattern.split_whitespace().any(|t| t == alias)
1336 }
1337 _ => false,
1338 })?;
1339 if let ConfigElement::HostBlock(b) = &self.elements[token_pos] {
1340 if b.host_pattern.split_whitespace().count() > 1 {
1341 return None;
1342 }
1343 }
1344 token_pos
1345 };
1346 let element = self.elements.remove(pos);
1347 Some((element, pos))
1348 }
1349
1350 /// Insert a host block at a specific position (for undo).
1351 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1352 let pos = position.min(self.elements.len());
1353 self.elements.insert(pos, element);
1354 }
1355
1356 /// Find the position after the last HostBlock that belongs to a provider.
1357 /// Returns `None` if no hosts for this provider exist in the config.
1358 /// Used by the sync engine to insert new hosts adjacent to existing provider hosts.
1359 pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1360 let mut last_pos = None;
1361 for (i, element) in self.elements.iter().enumerate() {
1362 if let ConfigElement::HostBlock(block) = element {
1363 if let Some((name, _)) = block.provider() {
1364 if name == provider_name {
1365 last_pos = Some(i);
1366 }
1367 }
1368 }
1369 }
1370 // Return position after the last provider host
1371 last_pos.map(|p| p + 1)
1372 }
1373
1374 /// Swap two host blocks in the config by alias. Returns true if swap was performed.
1375 #[allow(dead_code)]
1376 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1377 let pos_a = self
1378 .elements
1379 .iter()
1380 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1381 let pos_b = self
1382 .elements
1383 .iter()
1384 .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1385 if let (Some(a), Some(b)) = (pos_a, pos_b) {
1386 if a == b {
1387 return false;
1388 }
1389 let (first, second) = (a.min(b), a.max(b));
1390
1391 // Strip trailing blanks from both blocks before swap
1392 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1393 block.pop_trailing_blanks();
1394 }
1395 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1396 block.pop_trailing_blanks();
1397 }
1398
1399 // Swap
1400 self.elements.swap(first, second);
1401
1402 // Add trailing blank to first block (separator between the two)
1403 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1404 block.ensure_trailing_blank();
1405 }
1406
1407 // Add trailing blank to second only if not the last element
1408 if second < self.elements.len() - 1 {
1409 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1410 block.ensure_trailing_blank();
1411 }
1412 }
1413
1414 return true;
1415 }
1416 false
1417 }
1418
1419 /// Convert a HostEntry into a new HostBlock with clean formatting.
1420 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1421 // Defense-in-depth: callers must validate before reaching here.
1422 // Newlines in values would inject extra SSH config directives.
1423 debug_assert!(
1424 !entry.alias.contains('\n') && !entry.alias.contains('\r'),
1425 "entry_to_block: alias contains newline"
1426 );
1427 debug_assert!(
1428 !entry.hostname.contains('\n') && !entry.hostname.contains('\r'),
1429 "entry_to_block: hostname contains newline"
1430 );
1431 debug_assert!(
1432 !entry.user.contains('\n') && !entry.user.contains('\r'),
1433 "entry_to_block: user contains newline"
1434 );
1435
1436 let mut directives = Vec::new();
1437
1438 if !entry.hostname.is_empty() {
1439 directives.push(Directive {
1440 key: "HostName".to_string(),
1441 value: entry.hostname.clone(),
1442 raw_line: format!(" HostName {}", entry.hostname),
1443 is_non_directive: false,
1444 });
1445 }
1446 if !entry.user.is_empty() {
1447 directives.push(Directive {
1448 key: "User".to_string(),
1449 value: entry.user.clone(),
1450 raw_line: format!(" User {}", entry.user),
1451 is_non_directive: false,
1452 });
1453 }
1454 if entry.port != 22 {
1455 directives.push(Directive {
1456 key: "Port".to_string(),
1457 value: entry.port.to_string(),
1458 raw_line: format!(" Port {}", entry.port),
1459 is_non_directive: false,
1460 });
1461 }
1462 if !entry.identity_file.is_empty() {
1463 directives.push(Directive {
1464 key: "IdentityFile".to_string(),
1465 value: entry.identity_file.clone(),
1466 raw_line: format!(" IdentityFile {}", entry.identity_file),
1467 is_non_directive: false,
1468 });
1469 }
1470 if !entry.proxy_jump.is_empty() {
1471 directives.push(Directive {
1472 key: "ProxyJump".to_string(),
1473 value: entry.proxy_jump.clone(),
1474 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
1475 is_non_directive: false,
1476 });
1477 }
1478
1479 HostBlock {
1480 host_pattern: entry.alias.clone(),
1481 raw_host_line: format!("Host {}", entry.alias),
1482 directives,
1483 }
1484 }
1485}
1486
1487#[cfg(test)]
1488#[path = "model_tests.rs"]
1489mod tests;