Skip to main content

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    /// True when a `ProxyCommand` other than `none` is set. Such a host is
75    /// reached through a helper process, so a direct TCP ping says nothing.
76    pub has_proxy_command: bool,
77    /// If this host comes from an included file, the file path.
78    pub source_file: Option<PathBuf>,
79    /// User-added tags from purple:tags comment.
80    pub tags: Vec<String>,
81    /// Provider-synced tags from purple:provider_tags comment.
82    pub provider_tags: Vec<String>,
83    /// Whether a purple:provider_tags comment exists (distinguishes "never migrated" from "empty").
84    pub has_provider_tags: bool,
85    /// Cloud provider label from purple:provider comment (e.g. "do", "vultr").
86    pub provider: Option<String>,
87    /// Provider config label from a 3-segment purple:provider marker
88    /// (`provider:label:server_id`). None for legacy 2-segment markers.
89    /// Used together with `provider` to resolve which labeled config a host
90    /// belongs to in multi-config setups.
91    pub provider_label: Option<String>,
92    /// Number of tunnel forwarding directives.
93    pub tunnel_count: u16,
94    /// Password source from purple:askpass comment (e.g. "keychain", "op://...", "pass:...").
95    pub askpass: Option<String>,
96    /// Vault SSH certificate signing role from purple:vault-ssh comment.
97    pub vault_ssh: Option<String>,
98    /// Optional Vault HTTP endpoint from purple:vault-addr comment. When
99    /// set, purple passes it as `VAULT_ADDR` to the `vault` subprocess for
100    /// this host's signing, overriding the parent shell. Empty = inherit env.
101    pub vault_addr: Option<String>,
102    /// CertificateFile directive value (e.g. "~/.ssh/my-cert.pub").
103    pub certificate_file: String,
104    /// Provider metadata from purple:meta comment (region, plan, etc.).
105    pub provider_meta: Vec<(String, String)>,
106    /// Unix timestamp when the host was marked stale (disappeared from provider sync).
107    pub stale: Option<u64>,
108    /// Last-synced provider username from purple:provider_user comment. Lets
109    /// sync detect a manual per-host User override (user != marker) and
110    /// propagate a changed provider user to hosts still on the old value.
111    pub provider_user: Option<String>,
112    /// Last-synced provider identity file from purple:provider_key comment.
113    /// Same override-detection role as provider_user for IdentityFile.
114    pub provider_key: Option<String>,
115}
116
117impl Default for HostEntry {
118    fn default() -> Self {
119        Self {
120            alias: String::new(),
121            hostname: String::new(),
122            user: String::new(),
123            port: 22,
124            identity_file: String::new(),
125            proxy_jump: String::new(),
126            has_proxy_command: false,
127            source_file: None,
128            tags: Vec::new(),
129            provider_tags: Vec::new(),
130            has_provider_tags: false,
131            provider: None,
132            provider_label: None,
133            tunnel_count: 0,
134            askpass: None,
135            vault_ssh: None,
136            vault_addr: None,
137            certificate_file: String::new(),
138            provider_meta: Vec::new(),
139            stale: None,
140            provider_user: None,
141            provider_key: None,
142        }
143    }
144}
145
146impl HostEntry {
147    /// Build the SSH command string for this host.
148    /// Includes `-F <config_path>` when the config is non-default so the alias
149    /// resolves correctly when pasted into a terminal.
150    /// Shell-quotes both the config path and alias to prevent injection.
151    pub fn ssh_command(
152        &self,
153        paths: Option<&crate::runtime::env::Paths>,
154        config_path: &std::path::Path,
155    ) -> String {
156        self.ssh_command_with("ssh", paths, config_path)
157    }
158
159    /// Same as `ssh_command`, with `program` (already shell-safe, e.g. `kitten ssh`)
160    /// in place of the leading `ssh`.
161    pub fn ssh_command_with(
162        &self,
163        program: &str,
164        paths: Option<&crate::runtime::env::Paths>,
165        config_path: &std::path::Path,
166    ) -> String {
167        let escaped = self.alias.replace('\'', "'\\''");
168        let default = paths
169            .map(|p| p.ssh_dir().join("config"))
170            .unwrap_or_default();
171        if config_path == default {
172            format!("{program} -- '{escaped}'")
173        } else {
174            let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
175            format!("{program} -F '{config_escaped}' -- '{escaped}'")
176        }
177    }
178}
179
180/// Convenience view for pattern Host blocks in the TUI.
181#[derive(Debug, Clone, Default)]
182pub struct PatternEntry {
183    pub pattern: String,
184    pub hostname: String,
185    pub user: String,
186    pub port: u16,
187    pub identity_file: String,
188    pub proxy_jump: String,
189    pub tags: Vec<String>,
190    pub askpass: Option<String>,
191    pub source_file: Option<PathBuf>,
192    /// All non-comment directives as key-value pairs for display.
193    pub directives: Vec<(String, String)>,
194}
195
196/// Inherited field hints from matching patterns. Each field is `Some((value,
197/// source_pattern))` when a pattern provides that directive, `None` otherwise.
198#[derive(Debug, Clone, Default)]
199pub struct InheritedHints {
200    pub proxy_jump: Option<(String, String)>,
201    pub user: Option<(String, String)>,
202    pub identity_file: Option<(String, String)>,
203}
204
205use super::pattern::apply_first_match_fields;
206/// Returns true if the host pattern contains wildcards, character classes,
207/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
208/// These are SSH match patterns, not concrete hosts.
209// Pattern-matching lives in `ssh_config::pattern`. These re-exports preserve
210// the old `ssh_config::model::*` import paths used across the codebase and in
211// the model_tests file mounted below.
212#[allow(unused_imports)]
213pub use super::pattern::{
214    host_pattern_matches, is_host_pattern, proxy_jump_contains_self, ssh_pattern_match,
215};
216
217/// True if a `CertificateFile` directive value points at purple's managed
218/// certificate directory. Recognises both tilde-prefixed and absolute paths
219/// (`~/.purple/certs/...`, `/home/user/.purple/certs/...`,
220/// `$HOME/.purple/certs/...`). Used by `set_host_certificate_file` so
221/// user-set custom CertificateFile entries are preserved across vault
222/// sign / unsign cycles.
223pub(super) fn is_purple_managed_cert_value(value: &str) -> bool {
224    let trimmed = value.trim();
225    // Strip surrounding double quotes; OpenSSH treats `"~/.purple/..."` and
226    // `~/.purple/...` as equivalent.
227    let unquoted = trimmed
228        .strip_prefix('"')
229        .and_then(|s| s.strip_suffix('"'))
230        .unwrap_or(trimmed);
231    unquoted.contains(".purple/certs/")
232}
233// Re-exported so the test file mounted below keeps working.
234#[allow(unused_imports)]
235pub(super) use super::repair::provider_group_display_name;
236
237impl SshConfigFile {
238    /// Get all host entries as convenience views (including from Include files).
239    /// Pattern-inherited directives (ProxyJump, User, IdentityFile) are merged
240    /// using SSH-faithful alias-only matching so indicators like ↗ reflect what
241    /// SSH will actually apply when connecting via `ssh <alias>`.
242    pub fn host_entries(&self) -> Vec<HostEntry> {
243        let mut entries = Vec::new();
244        Self::collect_host_entries(&self.elements, &mut entries);
245        self.apply_pattern_inheritance(&mut entries);
246        entries
247    }
248
249    /// Get a single host entry by alias without pattern inheritance applied.
250    /// Returns the raw directives from the host's own block only. Used by the
251    /// edit form so inherited values can be shown as dimmed placeholders.
252    pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
253        Self::find_raw_host_entry(&self.elements, alias)
254    }
255
256    fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
257        for e in elements {
258            match e {
259                ConfigElement::HostBlock(block)
260                    if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
261                {
262                    return Some(block.to_host_entry());
263                }
264                ConfigElement::Include(inc) => {
265                    for file in &inc.resolved_files {
266                        if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
267                            if found.source_file.is_none() {
268                                found.source_file = Some(file.path.clone());
269                            }
270                            return Some(found);
271                        }
272                    }
273                }
274                _ => {}
275            }
276        }
277        None
278    }
279
280    /// Apply SSH first-match-wins pattern inheritance to host entries.
281    /// Matches patterns against the alias only (SSH-faithful: `Host` patterns
282    /// match the token typed on the command line, not the resolved `Hostname`).
283    fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
284        // Patterns are pre-collected once. Host entries never contain pattern
285        // aliases: collect_host_entries skips is_host_pattern blocks.
286        let all_patterns = self.pattern_entries();
287        // Every ProxyCommand in the file, in the order ssh reads them. A
288        // ProxyCommand reaches a machine of its own, so whichever block
289        // states one first decides whether this host has a bastion in front
290        // of it. The host's own block is in there beside the patterns,
291        // because ssh gives it no precedence: an opt-out written as
292        // `ProxyCommand none` only takes effect above the pattern it opts
293        // out of.
294        let mut proxy_commands = Vec::new();
295        Self::collect_proxy_commands(&self.elements, &mut proxy_commands);
296        for entry in entries.iter_mut() {
297            entry.has_proxy_command = proxy_commands
298                .iter()
299                .find(|(pattern, _)| host_pattern_matches(pattern, &entry.alias))
300                .map(|(_, reaches_a_machine)| *reaches_a_machine)
301                .unwrap_or(false);
302            if !entry.proxy_jump.is_empty()
303                && !entry.user.is_empty()
304                && !entry.identity_file.is_empty()
305            {
306                continue;
307            }
308            for p in &all_patterns {
309                if !host_pattern_matches(&p.pattern, &entry.alias) {
310                    continue;
311                }
312                apply_first_match_fields(
313                    &mut entry.proxy_jump,
314                    &mut entry.user,
315                    &mut entry.identity_file,
316                    p,
317                );
318                if !entry.proxy_jump.is_empty()
319                    && !entry.user.is_empty()
320                    && !entry.identity_file.is_empty()
321                {
322                    break;
323                }
324            }
325        }
326    }
327
328    /// Compute pattern-provided field hints for a host alias. Returns first-match
329    /// values and their source patterns for ProxyJump, User and IdentityFile.
330    /// These are returned regardless of whether the host has its own values for
331    /// those fields. The caller (form rendering) decides visibility based on
332    /// whether the field is empty. Matches by alias only (SSH-faithful).
333    pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
334        let patterns = self.matching_patterns(alias);
335        let mut hints = InheritedHints::default();
336        for p in &patterns {
337            if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
338                hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
339            }
340            if hints.user.is_none() && !p.user.is_empty() {
341                hints.user = Some((p.user.clone(), p.pattern.clone()));
342            }
343            if hints.identity_file.is_none() && !p.identity_file.is_empty() {
344                hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
345            }
346            if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
347                break;
348            }
349        }
350        hints
351    }
352
353    /// Get all pattern entries as convenience views (including from Include files).
354    pub fn pattern_entries(&self) -> Vec<PatternEntry> {
355        let mut entries = Vec::new();
356        Self::collect_pattern_entries(&self.elements, &mut entries);
357        entries
358    }
359
360    /// Collect every `ProxyCommand` declaration in the file, in reading
361    /// order: the block's host pattern paired with whether its value
362    /// reaches a machine of its own. `none` is the opt-out spelling and
363    /// pairs with false. Concrete blocks and patterns go in the same list
364    /// because ssh reads them the same way, taking the first value it
365    /// obtains for the keyword.
366    fn collect_proxy_commands(elements: &[ConfigElement], out: &mut Vec<(String, bool)>) {
367        for e in elements {
368            match e {
369                ConfigElement::HostBlock(block) => {
370                    if let Some(d) = block
371                        .directives
372                        .iter()
373                        .find(|d| d.key.eq_ignore_ascii_case("ProxyCommand"))
374                    {
375                        out.push((
376                            block.host_pattern.clone(),
377                            !d.value.trim().eq_ignore_ascii_case("none"),
378                        ));
379                    }
380                }
381                ConfigElement::Include(include) => {
382                    for file in &include.resolved_files {
383                        Self::collect_proxy_commands(&file.elements, out);
384                    }
385                }
386                _ => {}
387            }
388        }
389    }
390
391    fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
392        for e in elements {
393            match e {
394                ConfigElement::HostBlock(block) => {
395                    if !is_host_pattern(&block.host_pattern) {
396                        continue;
397                    }
398                    entries.push(block.to_pattern_entry());
399                }
400                ConfigElement::Include(include) => {
401                    for file in &include.resolved_files {
402                        let start = entries.len();
403                        Self::collect_pattern_entries(&file.elements, entries);
404                        for entry in &mut entries[start..] {
405                            if entry.source_file.is_none() {
406                                entry.source_file = Some(file.path.clone());
407                            }
408                        }
409                    }
410                }
411                ConfigElement::GlobalLine(_) => {}
412            }
413        }
414    }
415
416    /// Find all pattern blocks that match a given host alias and hostname.
417    /// Returns entries in config order (first match first).
418    pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
419        let mut matches = Vec::new();
420        Self::collect_matching_patterns(&self.elements, alias, &mut matches);
421        matches
422    }
423
424    fn collect_matching_patterns(
425        elements: &[ConfigElement],
426        alias: &str,
427        matches: &mut Vec<PatternEntry>,
428    ) {
429        for e in elements {
430            match e {
431                ConfigElement::HostBlock(block) => {
432                    if !is_host_pattern(&block.host_pattern) {
433                        continue;
434                    }
435                    if host_pattern_matches(&block.host_pattern, alias) {
436                        matches.push(block.to_pattern_entry());
437                    }
438                }
439                ConfigElement::Include(include) => {
440                    for file in &include.resolved_files {
441                        let start = matches.len();
442                        Self::collect_matching_patterns(&file.elements, alias, matches);
443                        for entry in &mut matches[start..] {
444                            if entry.source_file.is_none() {
445                                entry.source_file = Some(file.path.clone());
446                            }
447                        }
448                    }
449                }
450                ConfigElement::GlobalLine(_) => {}
451            }
452        }
453    }
454
455    /// Collect all resolved Include file paths (recursively).
456    pub fn include_paths(&self) -> Vec<PathBuf> {
457        let mut paths = Vec::new();
458        Self::collect_include_paths(&self.elements, &mut paths);
459        paths
460    }
461
462    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
463        for e in elements {
464            if let ConfigElement::Include(include) = e {
465                for file in &include.resolved_files {
466                    paths.push(file.path.clone());
467                    Self::collect_include_paths(&file.elements, paths);
468                }
469            }
470        }
471    }
472
473    /// Collect parent directories of Include glob patterns.
474    /// When a file is added/removed under a glob dir, the directory's mtime changes.
475    /// Convenience wrapper that captures the process environment once for
476    /// `${VAR}` resolution; production paths call `include_glob_dirs_with`
477    /// with an injected lookup so they stay free of scattered ambient reads.
478    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
479        let env = crate::runtime::env::Env::from_process();
480        self.include_glob_dirs_with(&|n| env.var(n).map(str::to_string))
481    }
482
483    /// Like `include_glob_dirs` but resolves `${VAR}` from an injected lookup
484    /// instead of the process env, so tests control expansion deterministically.
485    pub fn include_glob_dirs_with(&self, lookup: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
486        let config_dir = self.path.parent();
487        let mut seen = std::collections::HashSet::new();
488        let mut dirs = Vec::new();
489        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs, lookup);
490        dirs
491    }
492
493    fn collect_include_glob_dirs(
494        elements: &[ConfigElement],
495        config_dir: Option<&std::path::Path>,
496        seen: &mut std::collections::HashSet<PathBuf>,
497        dirs: &mut Vec<PathBuf>,
498        lookup: &dyn Fn(&str) -> Option<String>,
499    ) {
500        for e in elements {
501            if let ConfigElement::Include(include) = e {
502                // Split respecting quoted paths (same as resolve_include does)
503                for single in Self::split_include_patterns(&include.pattern) {
504                    let expanded =
505                        Self::expand_env_vars_with(&Self::expand_tilde(single, lookup), lookup);
506                    let resolved = if expanded.starts_with('/') {
507                        PathBuf::from(&expanded)
508                    } else if let Some(dir) = config_dir {
509                        dir.join(&expanded)
510                    } else {
511                        continue;
512                    };
513                    if let Some(parent) = resolved.parent() {
514                        let parent = parent.to_path_buf();
515                        if seen.insert(parent.clone()) {
516                            dirs.push(parent);
517                        }
518                    }
519                }
520                // Recurse into resolved files
521                for file in &include.resolved_files {
522                    Self::collect_include_glob_dirs(
523                        &file.elements,
524                        file.path.parent(),
525                        seen,
526                        dirs,
527                        lookup,
528                    );
529                }
530            }
531        }
532    }
533
534    /// Remove `# purple:group <Name>` headers that have no corresponding
535    /// provider hosts. Returns the number of headers removed.
536    /// Recursively collect host entries from a list of elements.
537    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
538        for e in elements {
539            match e {
540                ConfigElement::HostBlock(block) => {
541                    if is_host_pattern(&block.host_pattern) {
542                        continue;
543                    }
544                    entries.push(block.to_host_entry());
545                }
546                ConfigElement::Include(include) => {
547                    for file in &include.resolved_files {
548                        let start = entries.len();
549                        Self::collect_host_entries(&file.elements, entries);
550                        for entry in &mut entries[start..] {
551                            if entry.source_file.is_none() {
552                                entry.source_file = Some(file.path.clone());
553                            }
554                        }
555                    }
556                }
557                ConfigElement::GlobalLine(_) => {}
558            }
559        }
560    }
561
562    /// Check if a host alias already exists (including in Include files).
563    /// Walks the element tree directly without building HostEntry structs.
564    pub fn has_host(&self, alias: &str) -> bool {
565        Self::has_host_in_elements(&self.elements, alias)
566    }
567
568    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
569        for e in elements {
570            match e {
571                ConfigElement::HostBlock(block) => {
572                    if pattern_contains_token(&block.host_pattern, alias) {
573                        return true;
574                    }
575                }
576                ConfigElement::Include(include) => {
577                    for file in &include.resolved_files {
578                        if Self::has_host_in_elements(&file.elements, alias) {
579                            return true;
580                        }
581                    }
582                }
583                ConfigElement::GlobalLine(_) => {}
584            }
585        }
586        false
587    }
588
589    /// Return the sibling aliases that share a `Host` block with `alias`.
590    ///
591    /// An empty vector means `alias` lives in its own single-alias block (or
592    /// is not present). A non-empty vector lists the other tokens in the
593    /// block in source order, so the UI can render indicators like `+N` or
594    /// spell the aliases out in a confirm dialog before a destructive
595    /// action. Does not recurse into `Include`d files: those are read-only
596    /// and their hosts cannot be edited from purple anyway.
597    pub fn siblings_of(&self, alias: &str) -> Vec<String> {
598        if alias.is_empty() {
599            return Vec::new();
600        }
601        self.elements
602            .iter()
603            .find_map(|el| match el {
604                ConfigElement::HostBlock(b) => {
605                    // Full-pattern match means the caller is acting on the
606                    // whole block (e.g. pattern browser delete of
607                    // `web-01 web-01.prod`). All tokens are the target, so
608                    // there are no "siblings" to preserve.
609                    if b.host_pattern == alias {
610                        return Some(Vec::new());
611                    }
612                    let tokens: Vec<String> = b
613                        .host_pattern
614                        .split_whitespace()
615                        .map(String::from)
616                        .collect();
617                    if tokens.iter().any(|t| t == alias) {
618                        Some(tokens.into_iter().filter(|t| t != alias).collect())
619                    } else {
620                        None
621                    }
622                }
623                _ => None,
624            })
625            .unwrap_or_default()
626    }
627
628    /// Find a mutable top-level `HostBlock` whose `host_pattern` contains
629    /// `alias` as one of its whitespace-separated tokens.
630    ///
631    /// Mirrors the matching used by read-path helpers like `has_host` and
632    /// `find_tunnel_directives`, so that any host visible in the TUI is also
633    /// addressable from write paths (`update_host`, `delete_host`,
634    /// `set_host_*`). Prior to this helper, writers compared the full
635    /// `host_pattern` for exact equality, which silently no-op'd on
636    /// multi-alias blocks like `Host web-01 web-01.prod 10.0.1.5` and
637    /// resulted in on-disk drift between the in-memory view and the config
638    /// file.
639    ///
640    /// Does not recurse into `Include`d files: those are read-only.
641    ///
642    /// A block matches when either (a) its full `host_pattern` equals
643    /// `alias` (used by the pattern browser for blocks like `web-* db-*`
644    /// or `web-01 web-01.prod` whose full pattern is the caller's key) or
645    /// (b) `alias` appears as one of the whitespace-separated tokens (used
646    /// by the host list for multi-alias blocks). The full-pattern match is
647    /// tried first so callers that pass a pattern string do not
648    /// accidentally trigger the token-strip path.
649    fn find_host_block_mut(&mut self, alias: &str) -> Option<&mut HostBlock> {
650        if alias.is_empty() {
651            return None;
652        }
653        self.elements.iter_mut().find_map(|el| match el {
654            ConfigElement::HostBlock(b)
655                if b.host_pattern == alias || pattern_contains_token(&b.host_pattern, alias) =>
656            {
657                Some(b)
658            }
659            _ => None,
660        })
661    }
662
663    /// Check if a host block with exactly this host_pattern exists (top-level only).
664    /// Unlike `has_host` which splits multi-host patterns and checks individual parts,
665    /// this matches the full `Host` line pattern string (e.g. "web-* db-*").
666    /// Does not search Include files (patterns from includes are read-only).
667    pub fn has_host_block(&self, pattern: &str) -> bool {
668        self.elements
669            .iter()
670            .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
671    }
672
673    /// Check if a host alias is from an included file (read-only).
674    /// Handles multi-pattern Host lines by splitting on whitespace.
675    pub fn is_included_host(&self, alias: &str) -> bool {
676        // Not in top-level elements → must be in an Include
677        for e in &self.elements {
678            match e {
679                ConfigElement::HostBlock(block) => {
680                    if pattern_contains_token(&block.host_pattern, alias) {
681                        return false;
682                    }
683                }
684                ConfigElement::Include(include) => {
685                    for file in &include.resolved_files {
686                        if Self::has_host_in_elements(&file.elements, alias) {
687                            return true;
688                        }
689                    }
690                }
691                ConfigElement::GlobalLine(_) => {}
692            }
693        }
694        false
695    }
696
697    /// Add a new host entry to the config.
698    /// Inserts before any trailing wildcard/pattern Host blocks (e.g. `Host *`)
699    /// so that SSH "first match wins" semantics are preserved. If wildcards are
700    /// only at the top of the file (acting as global defaults), appends at end.
701    pub fn add_host(&mut self, entry: &HostEntry) {
702        let block = Self::entry_to_block(entry);
703        let insert_pos = self.find_trailing_pattern_start();
704
705        if let Some(pos) = insert_pos {
706            // Insert before the trailing pattern group, with blank separators
707            let needs_blank_before = pos > 0
708                && !matches!(
709                    self.elements.get(pos - 1),
710                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
711                );
712            let mut idx = pos;
713            if needs_blank_before {
714                self.elements
715                    .insert(idx, ConfigElement::GlobalLine(String::new()));
716                idx += 1;
717            }
718            self.elements.insert(idx, ConfigElement::HostBlock(block));
719            // Ensure a blank separator after the new block (before the wildcard group)
720            let after = idx + 1;
721            if after < self.elements.len()
722                && !matches!(
723                    self.elements.get(after),
724                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
725                )
726            {
727                self.elements
728                    .insert(after, ConfigElement::GlobalLine(String::new()));
729            }
730        } else {
731            // No trailing patterns: append at end
732            if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
733                self.elements.push(ConfigElement::GlobalLine(String::new()));
734            }
735            self.elements.push(ConfigElement::HostBlock(block));
736        }
737    }
738
739    /// Find the start of a trailing group of wildcard/pattern Host blocks.
740    /// Scans backwards from the end, skipping GlobalLines (blanks/comments/Match).
741    /// Returns `None` if no trailing patterns exist (or if ALL hosts are patterns,
742    /// i.e. patterns start at position 0, in which case we append at end).
743    fn find_trailing_pattern_start(&self) -> Option<usize> {
744        let mut first_pattern_pos = None;
745        for i in (0..self.elements.len()).rev() {
746            match &self.elements[i] {
747                ConfigElement::HostBlock(block) => {
748                    if is_host_pattern(&block.host_pattern) {
749                        first_pattern_pos = Some(i);
750                    } else {
751                        // Found a concrete host: the trailing group starts after this
752                        break;
753                    }
754                }
755                ConfigElement::GlobalLine(_) => {
756                    // Blank lines, comments, Match blocks between patterns: keep scanning
757                    if first_pattern_pos.is_some() {
758                        first_pattern_pos = Some(i);
759                    }
760                }
761                ConfigElement::Include(_) => break,
762            }
763        }
764        // Don't return position 0: that means everything is patterns (or patterns at top)
765        first_pattern_pos.filter(|&pos| pos > 0)
766    }
767
768    /// Check if the last element already ends with a blank line.
769    pub fn last_element_has_trailing_blank(&self) -> bool {
770        match self.elements.last() {
771            Some(ConfigElement::HostBlock(block)) => block
772                .directives
773                .last()
774                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
775            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
776            _ => false,
777        }
778    }
779
780    /// Update an existing host entry by alias.
781    /// Merges changes into the existing block, preserving unknown directives.
782    ///
783    /// Alias matching uses whitespace-tokenized equality, so a host visible
784    /// under a multi-alias block like `Host web-01 web-01.prod` is reachable
785    /// from any of its aliases. Directives are shared across all tokens in
786    /// the block (per SSH semantics): updating `User` on `web-01.prod`
787    /// therefore also affects `web-01`.
788    ///
789    /// On rename of a multi-alias block only the matching token is replaced
790    /// in the `Host` line; sibling aliases are preserved verbatim.
791    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
792        let Some(block) = self.find_host_block_mut(old_alias) else {
793            return;
794        };
795
796        if entry.alias != old_alias {
797            // Sanitise the new alias before it flows into `raw_host_line`.
798            // A malicious provider response with `\n` in the alias would
799            // otherwise inject extra Host blocks into the user's config.
800            // entry_to_block already sanitises the add-host path; this
801            // mirrors it for the rename path.
802            let safe_alias = HostBlock::sanitize_raw_line_value(&entry.alias);
803            // Full-pattern match (pattern browser rename) replaces the whole
804            // `host_pattern` verbatim. Token match (host list rename on a
805            // multi-alias block) replaces only the selected token so
806            // siblings survive. Single-alias blocks are covered by the
807            // token path because `tokens == [old_alias]`.
808            let is_full_pattern_match = block.host_pattern == old_alias;
809            let new_pattern: String = if is_full_pattern_match {
810                safe_alias.to_string()
811            } else {
812                block
813                    .host_pattern
814                    .split_whitespace()
815                    .map(|t| {
816                        if t == old_alias {
817                            safe_alias.as_ref()
818                        } else {
819                            t
820                        }
821                    })
822                    .collect::<Vec<_>>()
823                    .join(" ")
824            };
825            block.host_pattern = new_pattern.clone();
826            block.raw_host_line = rebuild_host_line(&block.raw_host_line, &new_pattern);
827        }
828
829        // Merge known directives (update existing, add missing, remove empty)
830        Self::upsert_directive(block, "HostName", &entry.hostname);
831        Self::upsert_directive(block, "User", &entry.user);
832        if entry.port != 22 {
833            Self::upsert_directive(block, "Port", &entry.port.to_string());
834        } else {
835            // Port 22 is the SSH default: drop the explicit directive so
836            // the rendered block stays minimal. Route through
837            // `upsert_directive` with an empty value so the first-only
838            // semantics match every other key here; a separate `retain`
839            // would diverge from the cumulative-directive invariant.
840            Self::upsert_directive(block, "Port", "");
841        }
842        Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
843        Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
844    }
845
846    /// Update a directive in-place, add it if missing, or remove it if value is empty.
847    ///
848    /// When `value` is empty only the FIRST matching directive is removed.
849    /// OpenSSH treats some directives (`IdentityFile`, `CertificateFile`,
850    /// `LocalForward`, etc.) as cumulative: a host with three `IdentityFile`
851    /// lines is intentionally multi-key. Wiping all matching directives on
852    /// an empty form field would silently delete the user's other keys.
853    /// The form only edits the first occurrence (see `to_host_entry` which
854    /// reads `if entry.identity_file.is_empty()`), so the symmetric remove
855    /// only-first behaviour keeps the per-form-field invariant intact:
856    /// "what the user sees in the field is what the field controls".
857    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
858        Self::upsert_directive_impl(block, key, value, true);
859    }
860
861    /// `upsert_directive` for directives that take the rest of the line as a
862    /// command (`ProxyCommand` and friends): the value is written verbatim,
863    /// since ssh hands it to the shell as-is and quoting it whole would break it.
864    fn upsert_directive_raw(block: &mut HostBlock, key: &str, value: &str) {
865        Self::upsert_directive_impl(block, key, value, false);
866    }
867
868    fn upsert_directive_impl(block: &mut HostBlock, key: &str, value: &str, quote: bool) {
869        // Defence in depth: sanitise the value before interpolation. The
870        // provider-sync update path passes `remote.ip` directly to
871        // `update_host` -&gt; `upsert_directive`, so a self-hosted provider
872        // with TLS verification disabled (Proxmox, OCI) could supply a
873        // hostname containing `\n  ProxyCommand evil` and inject a real
874        // directive. `entry_to_block` (the add-host path) sanitises at
875        // construction; mirroring it here closes the symmetric edit path.
876        let value_owned = HostBlock::sanitize_raw_line_value(value);
877        let value = value_owned.as_ref();
878        if value.is_empty() {
879            if let Some(pos) = block
880                .directives
881                .iter()
882                .position(|d| !d.is_non_directive && d.key.eq_ignore_ascii_case(key))
883            {
884                block.directives.remove(pos);
885            }
886            return;
887        }
888        let indent = block.detect_indent();
889        for d in &mut block.directives {
890            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
891                // Only rebuild raw_line when value actually changed (preserves inline comments)
892                if d.value != value {
893                    d.value = value.to_string();
894                    // Detect separator style from original raw_line and preserve it.
895                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
896                    // Only considers '=' as separator if it appears before any
897                    // non-whitespace content (avoids matching '=' inside values
898                    // like "IdentityFile ~/.ssh/id=prod").
899                    let trimmed = d.raw_line.trim_start();
900                    let after_key = &trimmed[d.key.len()..];
901                    let sep = if after_key.trim_start().starts_with('=') {
902                        let eq_pos = after_key.find('=').unwrap();
903                        let after_eq = &after_key[eq_pos + 1..];
904                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
905                        after_key[..eq_pos + 1 + trailing_ws].to_string()
906                    } else {
907                        " ".to_string()
908                    };
909                    // Preserve inline comment from original raw_line (e.g. "# production")
910                    let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
911                    let rendered = if quote {
912                        HostBlock::render_value(value)
913                    } else {
914                        std::borrow::Cow::Borrowed(value)
915                    };
916                    d.raw_line =
917                        format!("{}{}{}{}{}", indent, d.key, sep, rendered, comment_suffix);
918                }
919                return;
920            }
921        }
922        // Not found, so insert before trailing blanks
923        let pos = block.content_end();
924        let rendered = if quote {
925            HostBlock::render_value(value)
926        } else {
927            std::borrow::Cow::Borrowed(value)
928        };
929        block.directives.insert(
930            pos,
931            Directive {
932                key: key.to_string(),
933                value: value.to_string(),
934                raw_line: format!("{}{} {}", indent, key, rendered),
935                is_non_directive: false,
936            },
937        );
938    }
939
940    /// Directives whose value is a command line ssh passes to the shell whole.
941    fn takes_command_line(key: &str) -> bool {
942        [
943            "ProxyCommand",
944            "LocalCommand",
945            "RemoteCommand",
946            "KnownHostsCommand",
947        ]
948        .iter()
949        .any(|k| k.eq_ignore_ascii_case(key))
950    }
951
952    /// Extract the inline comment suffix from a directive's raw line.
953    /// Returns the trailing portion (e.g. " # production") or empty string.
954    /// Respects double-quoted strings so that `#` inside quotes is not a comment.
955    fn extract_inline_comment(raw_line: &str, key: &str) -> String {
956        let trimmed = raw_line.trim_start();
957        if trimmed.len() <= key.len() {
958            return String::new();
959        }
960        // Skip past key and separator to reach the value portion
961        let after_key = &trimmed[key.len()..];
962        let rest = after_key.trim_start();
963        let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
964        // Scan for inline comment (# preceded by whitespace, outside quotes)
965        let bytes = rest.as_bytes();
966        let mut in_quote = false;
967        for i in 0..bytes.len() {
968            if bytes[i] == b'"' {
969                in_quote = !in_quote;
970            } else if !in_quote
971                && bytes[i] == b'#'
972                && i > 0
973                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
974            {
975                // Found comment start. The clean value ends before the whitespace preceding #.
976                let clean_end = rest[..i].trim_end().len();
977                return rest[clean_end..].to_string();
978            }
979        }
980        String::new()
981    }
982
983    /// Set provider on a host block by alias using a full ProviderConfigId.
984    /// Emits a 3-segment marker when the id has a label, 2-segment otherwise.
985    ///
986    /// Refuses pattern aliases and multi-alias blocks: claiming a sibling
987    /// alias as provider-owned cascades into stale-marking and bulk-purge,
988    /// which would silently delete the user's hand-curated entries.
989    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
990    pub fn set_host_provider_id(
991        &mut self,
992        alias: &str,
993        id: &crate::providers::config::ProviderConfigId,
994        server_id: &str,
995    ) -> bool {
996        if alias.is_empty() || is_host_pattern(alias) {
997            return false;
998        }
999        let Some(block) = self.find_host_block_mut(alias) else {
1000            return false;
1001        };
1002        if is_host_pattern(&block.host_pattern) {
1003            return false;
1004        }
1005        block.set_provider_id(id, server_id);
1006        true
1007    }
1008
1009    /// Rewrite every 2-segment legacy marker for `provider_name` to a
1010    /// 3-segment marker keyed to `(provider_name, label)`. Used by the
1011    /// lazy-migration flow so existing hosts of a now-labeled config stay
1012    /// owned (and don't get re-claimed or stale-marked) on the next sync.
1013    ///
1014    /// Only top-level host blocks are rewritten; Include files are read-only
1015    /// per the project's invariant. Returns the count of host blocks touched.
1016    pub fn rewrite_legacy_markers_to_label(&mut self, provider_name: &str, label: &str) -> usize {
1017        let new_id = crate::providers::config::ProviderConfigId::labeled(provider_name, label);
1018        let mut rewritten = 0usize;
1019        for element in &mut self.elements {
1020            if let ConfigElement::HostBlock(block) = element {
1021                let Some((id, server_id)) = block.provider_id() else {
1022                    continue;
1023                };
1024                if id.provider == provider_name && id.label.is_none() {
1025                    block.set_provider_id(&new_id, &server_id);
1026                    rewritten += 1;
1027                }
1028            }
1029        }
1030        rewritten
1031    }
1032
1033    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
1034    /// Searches both top-level elements and Include files so that provider hosts
1035    /// in included configs are recognized during sync (prevents duplicate additions).
1036    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
1037        let mut results = Vec::new();
1038        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1039        results
1040    }
1041
1042    /// Find hosts owned by an exact `ProviderConfigId`. Used during multi-config sync
1043    /// so two labeled configs of the same provider don't claim each other's hosts.
1044    /// Legacy 2-segment markers match a bare id (label=None) for backward compatibility.
1045    pub fn find_hosts_by_id(
1046        &self,
1047        id: &crate::providers::config::ProviderConfigId,
1048    ) -> Vec<(String, String)> {
1049        let mut results = Vec::new();
1050        Self::collect_provider_hosts_by_id(&self.elements, id, &mut results);
1051        results
1052    }
1053
1054    /// Like `find_hosts_by_provider`, but returns the FULL server_id from the
1055    /// raw marker (everything after the first colon), without trying to
1056    /// interpret the middle segment as a label. Used by sync of BARE configs
1057    /// so server_ids containing colons (Proxmox `qemu:300`) are matched
1058    /// against the API response one-to-one instead of being mis-parsed as
1059    /// labeled markers.
1060    pub fn find_hosts_by_provider_raw(&self, provider_name: &str) -> Vec<(String, String)> {
1061        let mut results = Vec::new();
1062        Self::collect_provider_hosts_raw(&self.elements, provider_name, &mut results);
1063        results
1064    }
1065
1066    fn collect_provider_hosts_raw(
1067        elements: &[ConfigElement],
1068        provider_name: &str,
1069        results: &mut Vec<(String, String)>,
1070    ) {
1071        for element in elements {
1072            match element {
1073                ConfigElement::HostBlock(block) => {
1074                    if let Some((name, server_id)) = block.provider_raw()
1075                        && name == provider_name
1076                    {
1077                        results.push((block.host_pattern.clone(), server_id));
1078                    }
1079                }
1080                ConfigElement::Include(include) => {
1081                    for file in &include.resolved_files {
1082                        Self::collect_provider_hosts_raw(&file.elements, provider_name, results);
1083                    }
1084                }
1085                ConfigElement::GlobalLine(_) => {}
1086            }
1087        }
1088    }
1089
1090    fn collect_provider_hosts(
1091        elements: &[ConfigElement],
1092        provider_name: &str,
1093        results: &mut Vec<(String, String)>,
1094    ) {
1095        for element in elements {
1096            match element {
1097                ConfigElement::HostBlock(block) => {
1098                    if let Some((name, id)) = block.provider()
1099                        && name == provider_name
1100                    {
1101                        results.push((block.host_pattern.clone(), id));
1102                    }
1103                }
1104                ConfigElement::Include(include) => {
1105                    for file in &include.resolved_files {
1106                        Self::collect_provider_hosts(&file.elements, provider_name, results);
1107                    }
1108                }
1109                ConfigElement::GlobalLine(_) => {}
1110            }
1111        }
1112    }
1113
1114    fn collect_provider_hosts_by_id(
1115        elements: &[ConfigElement],
1116        id: &crate::providers::config::ProviderConfigId,
1117        results: &mut Vec<(String, String)>,
1118    ) {
1119        for element in elements {
1120            match element {
1121                ConfigElement::HostBlock(block) => {
1122                    if let Some((host_id, server_id)) = block.provider_id()
1123                        && &host_id == id
1124                    {
1125                        results.push((block.host_pattern.clone(), server_id));
1126                    }
1127                }
1128                ConfigElement::Include(include) => {
1129                    for file in &include.resolved_files {
1130                        Self::collect_provider_hosts_by_id(&file.elements, id, results);
1131                    }
1132                }
1133                ConfigElement::GlobalLine(_) => {}
1134            }
1135        }
1136    }
1137
1138    /// Compare two directive values with whitespace normalization.
1139    /// Handles hand-edited configs with tabs or multiple spaces.
1140    fn values_match(a: &str, b: &str) -> bool {
1141        a.split_whitespace().eq(b.split_whitespace())
1142    }
1143
1144    /// Add a forwarding directive to a host block.
1145    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
1146    /// Uses split_whitespace matching for multi-pattern Host lines.
1147    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1148        for element in &mut self.elements {
1149            if let ConfigElement::HostBlock(block) = element
1150                && pattern_contains_token(&block.host_pattern, alias)
1151            {
1152                let indent = block.detect_indent();
1153                let pos = block.content_end();
1154                block.directives.insert(
1155                    pos,
1156                    Directive {
1157                        key: directive_key.to_string(),
1158                        value: value.to_string(),
1159                        raw_line: format!("{}{} {}", indent, directive_key, value),
1160                        is_non_directive: false,
1161                    },
1162                );
1163                return;
1164            }
1165        }
1166    }
1167
1168    /// Remove a specific forwarding directive from a host block.
1169    /// Matches key (case-insensitive) and value (whitespace-normalized).
1170    /// Uses split_whitespace matching for multi-pattern Host lines.
1171    /// Returns true if a directive was actually removed.
1172    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1173        for element in &mut self.elements {
1174            if let ConfigElement::HostBlock(block) = element
1175                && pattern_contains_token(&block.host_pattern, alias)
1176            {
1177                if let Some(pos) = block.directives.iter().position(|d| {
1178                    !d.is_non_directive
1179                        && d.key.eq_ignore_ascii_case(directive_key)
1180                        && Self::values_match(&d.value, value)
1181                }) {
1182                    block.directives.remove(pos);
1183                    return true;
1184                }
1185                return false;
1186            }
1187        }
1188        false
1189    }
1190
1191    /// Check if a host block has a specific forwarding directive.
1192    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
1193    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1194        for element in &self.elements {
1195            if let ConfigElement::HostBlock(block) = element
1196                && pattern_contains_token(&block.host_pattern, alias)
1197            {
1198                return block.directives.iter().any(|d| {
1199                    !d.is_non_directive
1200                        && d.key.eq_ignore_ascii_case(directive_key)
1201                        && Self::values_match(&d.value, value)
1202                });
1203            }
1204        }
1205        false
1206    }
1207
1208    /// Find tunnel directives for a host alias, searching all elements including
1209    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
1210    /// Host lines.
1211    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1212        Self::find_tunnel_directives_in(&self.elements, alias)
1213    }
1214
1215    fn find_tunnel_directives_in(
1216        elements: &[ConfigElement],
1217        alias: &str,
1218    ) -> Vec<crate::tunnel::TunnelRule> {
1219        for element in elements {
1220            match element {
1221                ConfigElement::HostBlock(block) => {
1222                    if pattern_contains_token(&block.host_pattern, alias) {
1223                        return block.tunnel_directives();
1224                    }
1225                }
1226                ConfigElement::Include(include) => {
1227                    for file in &include.resolved_files {
1228                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1229                        if !rules.is_empty() {
1230                            return rules;
1231                        }
1232                    }
1233                }
1234                ConfigElement::GlobalLine(_) => {}
1235            }
1236        }
1237        Vec::new()
1238    }
1239
1240    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
1241    pub fn deduplicate_alias(&self, base: &str) -> String {
1242        self.deduplicate_alias_excluding(base, None)
1243    }
1244
1245    /// Generate a unique alias, optionally excluding one alias from collision detection.
1246    /// Used during rename so the host being renamed doesn't collide with itself.
1247    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1248        let is_taken = |alias: &str| {
1249            if exclude == Some(alias) {
1250                return false;
1251            }
1252            self.has_host(alias)
1253        };
1254        if !is_taken(base) {
1255            return base.to_string();
1256        }
1257        for n in 2..=9999 {
1258            let candidate = format!("{}-{}", base, n);
1259            if !is_taken(&candidate) {
1260                return candidate;
1261            }
1262        }
1263        // Practically unreachable: fall back to PID-based suffix
1264        format!("{}-{}", base, std::process::id())
1265    }
1266
1267    /// Set tags on a host block by alias.
1268    ///
1269    /// Refuses pattern aliases and multi-alias blocks symmetric with the
1270    /// vault/certificate setters: a tag on a shared block silently applies to
1271    /// every sibling alias, which is rarely the user's intent.
1272    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1273    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) -> bool {
1274        if alias.is_empty() || is_host_pattern(alias) {
1275            return false;
1276        }
1277        let Some(block) = self.find_host_block_mut(alias) else {
1278            return false;
1279        };
1280        if is_host_pattern(&block.host_pattern) {
1281            return false;
1282        }
1283        block.set_tags(tags);
1284        true
1285    }
1286
1287    /// Set provider-synced tags on a host block by alias.
1288    ///
1289    /// Same multi-alias and pattern refusal as the other purple-marker
1290    /// setters. Provider tags drive sync decisions, so a wrong-block mutation
1291    /// can cascade into delete/stale.
1292    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1293    pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) -> bool {
1294        if alias.is_empty() || is_host_pattern(alias) {
1295            return false;
1296        }
1297        let Some(block) = self.find_host_block_mut(alias) else {
1298            return false;
1299        };
1300        if is_host_pattern(&block.host_pattern) {
1301            return false;
1302        }
1303        block.set_provider_tags(tags);
1304        true
1305    }
1306
1307    /// Record the last-synced provider username on a host block by alias.
1308    /// Empty value removes the marker. Same shared-block and pattern refusal
1309    /// as the other purple-marker setters: a wrong-block mutation would
1310    /// mis-track override state and let sync clobber a manual User edit.
1311    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1312    pub fn set_host_provider_user(&mut self, alias: &str, user: &str) -> bool {
1313        if alias.is_empty() || is_host_pattern(alias) {
1314            return false;
1315        }
1316        let Some(block) = self.find_host_block_mut(alias) else {
1317            return false;
1318        };
1319        if is_host_pattern(&block.host_pattern) {
1320            return false;
1321        }
1322        block.set_provider_user(user);
1323        true
1324    }
1325
1326    /// Record the last-synced provider identity file on a host block by alias.
1327    /// Empty value removes the marker. Same safety invariants as
1328    /// `set_host_provider_user`.
1329    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1330    pub fn set_host_provider_key(&mut self, alias: &str, key: &str) -> bool {
1331        if alias.is_empty() || is_host_pattern(alias) {
1332            return false;
1333        }
1334        let Some(block) = self.find_host_block_mut(alias) else {
1335            return false;
1336        };
1337        if is_host_pattern(&block.host_pattern) {
1338            return false;
1339        }
1340        block.set_provider_key(key);
1341        true
1342    }
1343
1344    /// Set askpass source on a host block by alias.
1345    ///
1346    /// Askpass is an authentication credential source; applying it to a
1347    /// sibling alias in a shared block would route the wrong credential.
1348    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1349    pub fn set_host_askpass(&mut self, alias: &str, source: &str) -> bool {
1350        if alias.is_empty() || is_host_pattern(alias) {
1351            return false;
1352        }
1353        let Some(block) = self.find_host_block_mut(alias) else {
1354            return false;
1355        };
1356        if is_host_pattern(&block.host_pattern) {
1357            return false;
1358        }
1359        block.set_askpass(source);
1360        true
1361    }
1362
1363    /// Set or remove the Vault SSH role comment on a host block by alias.
1364    /// Empty `role` removes the comment.
1365    ///
1366    /// Mirrors the safety invariants of `set_host_certificate_file` and
1367    /// `set_host_vault_addr`: wildcard aliases are refused so a `Host *.prod`
1368    /// pattern can never have a Vault role silently assigned to every host
1369    /// it resolves, and multi-alias blocks (`Host web-01 web-01.prod`) are
1370    /// refused so the role is never applied to sibling aliases the user did
1371    /// not authorise. Returns `true` on a successful mutation, `false` when
1372    /// the alias is invalid, missing, or lives in an Include file.
1373    ///
1374    /// Callers that run asynchronously (form submit handlers, sync workers)
1375    /// MUST check the return value so a silent config mutation failure is
1376    /// surfaced instead of pretending the role was wired up.
1377    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1378    pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) -> bool {
1379        if alias.is_empty() || is_host_pattern(alias) {
1380            return false;
1381        }
1382        let Some(block) = self.find_host_block_mut(alias) else {
1383            return false;
1384        };
1385        if is_host_pattern(&block.host_pattern) {
1386            return false;
1387        }
1388        block.set_vault_ssh(role);
1389        true
1390    }
1391
1392    /// Set or remove the Vault SSH endpoint comment on a host block by alias.
1393    /// Empty `url` removes the comment.
1394    ///
1395    /// Mirrors the safety invariants of `set_host_certificate_file`: wildcard
1396    /// aliases are refused to avoid accidentally applying a vault address to
1397    /// every host resolved through a pattern, and Match blocks are not
1398    /// touched (they live as inert `GlobalLines`). Returns `true` on a
1399    /// successful mutation, `false` when the alias is invalid or the block
1400    /// is not found.
1401    ///
1402    /// Callers that run asynchronously (e.g. form submit handlers that
1403    /// resolve the alias before writing) MUST check the return value so a
1404    /// silent config mutation failure is surfaced instead of pretending the
1405    /// vault address was wired up.
1406    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1407    pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
1408        // Same guard as `set_host_certificate_file`: refuse empty aliases
1409        // and any SSH pattern shape. `is_host_pattern` already covers
1410        // wildcards, negation and whitespace-separated multi-host forms.
1411        if alias.is_empty() || is_host_pattern(alias) {
1412            return false;
1413        }
1414        let Some(block) = self.find_host_block_mut(alias) else {
1415            return false;
1416        };
1417        // Defense in depth: refuse to mutate a block that is itself a
1418        // pattern or a multi-alias block (ExactAliasOnly policy). Writing a
1419        // vault endpoint onto such a block would apply to every sibling
1420        // alias and every host resolving through the pattern, which is
1421        // almost certainly not what the caller intends.
1422        if is_host_pattern(&block.host_pattern) {
1423            return false;
1424        }
1425        block.set_vault_addr(url);
1426        true
1427    }
1428
1429    /// Set or remove the CertificateFile directive on a host block by alias.
1430    /// Empty path removes the directive.
1431    /// Set the `CertificateFile` directive on the host block that matches
1432    /// `alias` exactly. Returns `true` if a matching block was found and
1433    /// updated, `false` if no top-level `HostBlock` matched (alias was
1434    /// renamed, deleted or lives only inside an `Include`d file).
1435    ///
1436    /// Only touches `CertificateFile` directives that are purple-managed
1437    /// (path contains `.purple/certs/`). User-set custom `CertificateFile`
1438    /// entries (e.g. a corporate or personal cert at `~/.ssh/corp-cert.pub`)
1439    /// are never modified or removed: empty-path clears only the purple
1440    /// managed line; non-empty path updates the purple-managed line in
1441    /// place or inserts a new one if absent. A host with both a corporate
1442    /// cert and a Vault-signed cert ends up with both lines present, in
1443    /// OpenSSH's documented cumulative semantics.
1444    ///
1445    /// Callers that run asynchronously (e.g. the Vault SSH bulk-sign worker)
1446    /// MUST check the return value so a silent config mutation failure is
1447    /// surfaced to the user instead of pretending the cert was wired up.
1448    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1449    pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
1450        // Defense in depth: refuse to mutate a host block when the requested
1451        // alias is empty or matches any SSH pattern shape (`*`, `?`, `[`,
1452        // leading `!`, or whitespace-separated multi-host form like
1453        // `Host web-* db-*`). Writing `CertificateFile` onto a pattern
1454        // block is almost never what a user intends and would affect every
1455        // host that resolves through that pattern. Reusing `is_host_pattern`
1456        // keeps this check in sync with the form-level pattern detection.
1457        if alias.is_empty() || is_host_pattern(alias) {
1458            return false;
1459        }
1460        let Some(block) = self.find_host_block_mut(alias) else {
1461            return false;
1462        };
1463        // Additionally refuse when the matched block is itself a pattern or
1464        // multi-alias block (ExactAliasOnly policy). The input `alias` may
1465        // be a plain token yet resolve into a block like `Host web-01
1466        // web-01.prod`, where writing `CertificateFile` would silently
1467        // affect sibling aliases.
1468        if is_host_pattern(&block.host_pattern) {
1469            return false;
1470        }
1471
1472        // Find the existing purple-managed CertificateFile entry, if any.
1473        let purple_pos = block.directives.iter().position(|d| {
1474            !d.is_non_directive
1475                && d.key.eq_ignore_ascii_case("CertificateFile")
1476                && is_purple_managed_cert_value(&d.value)
1477        });
1478
1479        if path.is_empty() {
1480            if let Some(pos) = purple_pos {
1481                block.directives.remove(pos);
1482            }
1483            return true;
1484        }
1485
1486        let sanitized = HostBlock::sanitize_raw_line_value(path);
1487        let indent = block.detect_indent();
1488        if let Some(pos) = purple_pos {
1489            let d = &mut block.directives[pos];
1490            if d.value != sanitized.as_ref() {
1491                d.value = sanitized.to_string();
1492                // Preserve separator style + inline comment in the same way
1493                // upsert_directive does for the single-line case.
1494                let trimmed = d.raw_line.trim_start();
1495                let after_key = &trimmed[d.key.len()..];
1496                let sep = if after_key.trim_start().starts_with('=') {
1497                    let eq_pos = after_key.find('=').unwrap();
1498                    let after_eq = &after_key[eq_pos + 1..];
1499                    let trailing_ws = after_eq.len() - after_eq.trim_start().len();
1500                    after_key[..eq_pos + 1 + trailing_ws].to_string()
1501                } else {
1502                    " ".to_string()
1503                };
1504                let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
1505                let rendered = HostBlock::render_value(&sanitized);
1506                d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, rendered, comment_suffix);
1507            }
1508        } else if is_purple_managed_cert_value(sanitized.as_ref()) {
1509            // Defensive gate: only insert a NEW CertificateFile line when
1510            // the caller's path is itself purple-managed. The rollback flow
1511            // in `app/hosts.rs` may pass `old_entry.certificate_file` which
1512            // could be a user-set custom path; inserting it here would
1513            // duplicate a user-managed entry. A non-purple-managed path
1514            // with no existing purple-managed line is a no-op.
1515            let pos = block.content_end();
1516            block.directives.insert(
1517                pos,
1518                Directive {
1519                    key: "CertificateFile".to_string(),
1520                    value: sanitized.to_string(),
1521                    raw_line: format!(
1522                        "{}CertificateFile {}",
1523                        indent,
1524                        HostBlock::render_value(&sanitized)
1525                    ),
1526                    is_non_directive: false,
1527                },
1528            );
1529        }
1530        true
1531    }
1532
1533    /// Set provider metadata on a host block by alias.
1534    ///
1535    /// Refuses pattern aliases and multi-alias blocks; same rationale as the
1536    /// other `# purple:*` setters.
1537    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1538    pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) -> bool {
1539        if alias.is_empty() || is_host_pattern(alias) {
1540            return false;
1541        }
1542        let Some(block) = self.find_host_block_mut(alias) else {
1543            return false;
1544        };
1545        if is_host_pattern(&block.host_pattern) {
1546            return false;
1547        }
1548        block.set_meta(meta);
1549        true
1550    }
1551
1552    /// First value of `key` on the host block for `alias`. None when the
1553    /// host or the directive is missing. Top-level blocks only.
1554    pub fn host_directive(&self, alias: &str, key: &str) -> Option<String> {
1555        if alias.is_empty() {
1556            return None;
1557        }
1558        self.elements.iter().find_map(|el| match el {
1559            ConfigElement::HostBlock(b)
1560                if b.host_pattern == alias || pattern_contains_token(&b.host_pattern, alias) =>
1561            {
1562                b.directives
1563                    .iter()
1564                    .find(|d| !d.is_non_directive && d.key.eq_ignore_ascii_case(key))
1565                    .map(|d| d.value.clone())
1566            }
1567            _ => None,
1568        })
1569    }
1570
1571    /// Set the first `key` directive on the host block for `alias`, adding it
1572    /// when missing and removing it when `value` is empty. Refuses pattern and
1573    /// multi-alias blocks like the other provider-managed setters.
1574    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1575    pub fn set_host_directive(&mut self, alias: &str, key: &str, value: &str) -> bool {
1576        if alias.is_empty() || is_host_pattern(alias) || key.trim().is_empty() {
1577            return false;
1578        }
1579        let Some(block) = self.find_host_block_mut(alias) else {
1580            return false;
1581        };
1582        if is_host_pattern(&block.host_pattern) {
1583            return false;
1584        }
1585        let key = key.trim();
1586        if Self::takes_command_line(key) {
1587            Self::upsert_directive_raw(block, key, value);
1588        } else {
1589            Self::upsert_directive(block, key, value);
1590        }
1591        true
1592    }
1593
1594    /// Mark a host as stale by alias.
1595    ///
1596    /// Stale markers drive the `X` purge flow which deletes the full block,
1597    /// so a wrong-block mutation here cascades into data loss for a sibling
1598    /// alias the user added by hand. Refuse pattern and multi-alias blocks.
1599    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1600    pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) -> bool {
1601        if alias.is_empty() || is_host_pattern(alias) {
1602            return false;
1603        }
1604        let Some(block) = self.find_host_block_mut(alias) else {
1605            return false;
1606        };
1607        if is_host_pattern(&block.host_pattern) {
1608            return false;
1609        }
1610        block.set_stale(timestamp);
1611        true
1612    }
1613
1614    /// Clear stale marking from a host by alias.
1615    ///
1616    /// Symmetric guard with `set_host_stale`. Clearing on a shared block is
1617    /// benign but the asymmetry would be confusing; reject for consistency.
1618    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1619    pub fn clear_host_stale(&mut self, alias: &str) -> bool {
1620        if alias.is_empty() || is_host_pattern(alias) {
1621            return false;
1622        }
1623        let Some(block) = self.find_host_block_mut(alias) else {
1624            return false;
1625        };
1626        if is_host_pattern(&block.host_pattern) {
1627            return false;
1628        }
1629        block.clear_stale();
1630        true
1631    }
1632
1633    /// Collect all stale hosts with their timestamps.
1634    pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1635        let mut result = Vec::new();
1636        for element in &self.elements {
1637            if let ConfigElement::HostBlock(block) = element
1638                && let Some(ts) = block.stale()
1639            {
1640                result.push((block.host_pattern.clone(), ts));
1641            }
1642        }
1643        result
1644    }
1645
1646    /// Delete a host entry by alias.
1647    ///
1648    /// For a single-alias block this removes the whole block (and cleans up
1649    /// any orphaned `# purple:group` header left behind). For a multi-alias
1650    /// block like `Host web-01 web-01.prod 10.0.1.5` only the matching
1651    /// alias token is stripped from the `Host` line; sibling aliases and
1652    /// all directives are preserved so that `delete_host("web-01.prod")`
1653    /// does not silently wipe configuration for `web-01` and `10.0.1.5`.
1654    ///
1655    /// Callers that want to remove the entire block regardless of sibling
1656    /// aliases should surface an explicit confirmation in the UI and then
1657    /// delete each sibling alias in turn.
1658    pub fn delete_host(&mut self, alias: &str) {
1659        // Two matching modes:
1660        //   - Full-pattern match: block.host_pattern == alias. Removes the
1661        //     entire block (plus duplicates). Used by the pattern browser,
1662        //     where `alias` is a full pattern string like `web-* db-*` or
1663        //     `web-01 web-01.prod`.
1664        //   - Token match: alias appears as one of the whitespace-separated
1665        //     tokens. Strips just that token from a multi-alias block and
1666        //     removes single-alias blocks outright. Used by the host list.
1667        // Full-pattern is checked first so pattern-browser deletes never
1668        // degenerate into partial token strips.
1669        let has_full_match = self
1670            .elements
1671            .iter()
1672            .any(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias));
1673
1674        // Capture the provider for orphaned-group cleanup before mutation.
1675        let provider_name = self.elements.iter().find_map(|e| match e {
1676            ConfigElement::HostBlock(b)
1677                if (has_full_match && b.host_pattern == alias)
1678                    || (!has_full_match && pattern_contains_token(&b.host_pattern, alias)) =>
1679            {
1680                b.provider().map(|(name, _)| name)
1681            }
1682            _ => None,
1683        });
1684
1685        if has_full_match {
1686            // Harvest trailing comments (column-0 `#` lines or section
1687            // headers) from each block we're about to delete, so they
1688            // survive the delete and re-attach to whatever follows.
1689            // Skip `# purple:*` metadata: that's bookkeeping owned by the
1690            // block being removed.
1691            let mut salvaged_comments: Vec<String> = Vec::new();
1692            for el in &mut self.elements {
1693                if let ConfigElement::HostBlock(block) = el
1694                    && block.host_pattern == alias
1695                {
1696                    let drain_from = {
1697                        let mut idx = block.directives.len();
1698                        while idx > 0 {
1699                            let d = &block.directives[idx - 1];
1700                            let is_user_comment = d.is_non_directive
1701                                && (d.raw_line.trim().is_empty()
1702                                    || (d.raw_line.trim().starts_with('#')
1703                                        && !d.raw_line.trim().starts_with("# purple:")));
1704                            if !is_user_comment {
1705                                break;
1706                            }
1707                            idx -= 1;
1708                        }
1709                        idx
1710                    };
1711                    for d in block.directives.drain(drain_from..) {
1712                        if !d.raw_line.trim().is_empty() {
1713                            salvaged_comments.push(d.raw_line);
1714                        }
1715                    }
1716                }
1717            }
1718            // Remove every block whose full host_pattern equals the input
1719            // (duplicate-block invariant preserved, matches pre-refactor).
1720            self.elements.retain(|e| match e {
1721                ConfigElement::HostBlock(block) => block.host_pattern != alias,
1722                _ => true,
1723            });
1724            // Re-emit salvaged comments as GlobalLines just before the next
1725            // remaining HostBlock, so a section-header lands above what
1726            // follows rather than vanishing with the preceding host.
1727            if !salvaged_comments.is_empty() {
1728                let next_host = self
1729                    .elements
1730                    .iter()
1731                    .position(|e| matches!(e, ConfigElement::HostBlock(_)));
1732                let insert_pos = next_host.unwrap_or(self.elements.len());
1733                for (offset, raw) in salvaged_comments.into_iter().enumerate() {
1734                    self.elements
1735                        .insert(insert_pos + offset, ConfigElement::GlobalLine(raw));
1736                }
1737            }
1738        }
1739        // Always run the token-strip pass too. A config can contain BOTH a
1740        // full-pattern block (`Host web-01`) AND a sibling block that carries
1741        // the same alias as one token of a multi-alias pattern (`Host web-01
1742        // staging`). Without this second pass, `delete_host("web-01")` would
1743        // remove the first block, leave the second untouched, and `ssh web-01`
1744        // would silently re-route to staging's HostName. The strip is a no-op
1745        // when no token-only sibling exists.
1746        for el in &mut self.elements {
1747            if let ConfigElement::HostBlock(block) = el {
1748                let tokens: Vec<&str> = block.host_pattern.split_whitespace().collect();
1749                if tokens.len() > 1 && tokens.contains(&alias) {
1750                    let new_pattern = tokens
1751                        .iter()
1752                        .filter(|t| **t != alias)
1753                        .copied()
1754                        .collect::<Vec<_>>()
1755                        .join(" ");
1756                    block.host_pattern = new_pattern.clone();
1757                    block.raw_host_line = rebuild_host_line(&block.raw_host_line, &new_pattern);
1758                }
1759            }
1760        }
1761        self.elements.retain(|e| match e {
1762            ConfigElement::HostBlock(block) => {
1763                let mut tokens = block.host_pattern.split_whitespace();
1764                !matches!(
1765                    (tokens.next(), tokens.next()),
1766                    (Some(first), None) if first == alias
1767                )
1768            }
1769            _ => true,
1770        });
1771
1772        if let Some(name) = provider_name {
1773            self.remove_orphaned_group_header(&name);
1774        }
1775
1776        // Collapse consecutive blank lines left by deletion
1777        self.elements.dedup_by(|a, b| {
1778            matches!(
1779                (&*a, &*b),
1780                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1781                if x.trim().is_empty() && y.trim().is_empty()
1782            )
1783        });
1784    }
1785
1786    /// Delete a host and return the removed element and its position for undo.
1787    /// Does NOT collapse blank lines or remove group headers so the position
1788    /// stays valid for re-insertion via `insert_host_at()`.
1789    /// Orphaned group headers (if any) are cleaned up at next startup.
1790    ///
1791    /// For multi-alias blocks this returns `None`: undoable-delete of a
1792    /// single alias out of a shared `Host` line cannot be round-tripped via
1793    /// `insert_host_at` because sibling aliases would be lost. Callers
1794    /// should fall back to `delete_host` in that case (which strips only
1795    /// the requested token).
1796    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1797        // Two-mode match mirroring `delete_host`: full-pattern first (for
1798        // pattern-browser deletes where `alias` is the whole pattern
1799        // string), then token match. Undoable delete is only safe when
1800        // removing the entire block; token-strip on a multi-alias block is
1801        // therefore refused (returns `None`) because re-inserting the
1802        // whole element would not reverse a token strip.
1803        let full_pos = self
1804            .elements
1805            .iter()
1806            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias));
1807        let pos = if let Some(p) = full_pos {
1808            p
1809        } else {
1810            let token_pos = self.elements.iter().position(|e| match e {
1811                ConfigElement::HostBlock(b) => pattern_contains_token(&b.host_pattern, alias),
1812                _ => false,
1813            })?;
1814            if let ConfigElement::HostBlock(b) = &self.elements[token_pos]
1815                && b.host_pattern.split_whitespace().count() > 1
1816            {
1817                return None;
1818            }
1819            token_pos
1820        };
1821        let element = self.elements.remove(pos);
1822        Some((element, pos))
1823    }
1824
1825    /// Insert a host block at a specific position (for undo).
1826    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1827        let pos = position.min(self.elements.len());
1828        self.elements.insert(pos, element);
1829    }
1830
1831    /// Find the position after the last HostBlock that belongs to a provider.
1832    /// Returns `None` if no hosts for this provider exist in the config.
1833    /// Used by the sync engine to insert new hosts adjacent to existing provider hosts.
1834    pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1835        let mut last_pos = None;
1836        for (i, element) in self.elements.iter().enumerate() {
1837            if let ConfigElement::HostBlock(block) = element
1838                && let Some((name, _)) = block.provider()
1839                && name == provider_name
1840            {
1841                last_pos = Some(i);
1842            }
1843        }
1844        // Return position after the last provider host
1845        last_pos.map(|p| p + 1)
1846    }
1847
1848    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
1849    #[allow(dead_code)]
1850    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1851        let pos_a = self
1852            .elements
1853            .iter()
1854            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1855        let pos_b = self
1856            .elements
1857            .iter()
1858            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1859        if let (Some(a), Some(b)) = (pos_a, pos_b) {
1860            if a == b {
1861                return false;
1862            }
1863            let (first, second) = (a.min(b), a.max(b));
1864
1865            // Strip trailing blanks from both blocks before swap
1866            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1867                block.pop_trailing_blanks();
1868            }
1869            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1870                block.pop_trailing_blanks();
1871            }
1872
1873            // Swap
1874            self.elements.swap(first, second);
1875
1876            // Add trailing blank to first block (separator between the two)
1877            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1878                block.ensure_trailing_blank();
1879            }
1880
1881            // Add trailing blank to second only if not the last element
1882            if second < self.elements.len() - 1
1883                && let ConfigElement::HostBlock(block) = &mut self.elements[second]
1884            {
1885                block.ensure_trailing_blank();
1886            }
1887
1888            return true;
1889        }
1890        false
1891    }
1892
1893    /// Convert a HostEntry into a new HostBlock with clean formatting.
1894    ///
1895    /// Every value that ends up inside a `raw_line` is routed through
1896    /// `HostBlock::sanitize_raw_line_value`. A `\n` or `\r` in `alias`,
1897    /// `hostname`, `user`, `identity_file` or `proxy_jump` would otherwise
1898    /// split the rendered line and inject extra SSH config directives, for
1899    /// example a provider API returning `name = "evil\n  ProxyJump bad"`
1900    /// would land as a real ProxyJump directive in the user's config. The
1901    /// previous `debug_assert!` guards were stripped from release builds,
1902    /// so the sanitiser is the only release-mode defence.
1903    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1904        let alias = HostBlock::sanitize_raw_line_value(&entry.alias);
1905        let hostname = HostBlock::sanitize_raw_line_value(&entry.hostname);
1906        let user = HostBlock::sanitize_raw_line_value(&entry.user);
1907        let identity_file = HostBlock::sanitize_raw_line_value(&entry.identity_file);
1908        let proxy_jump = HostBlock::sanitize_raw_line_value(&entry.proxy_jump);
1909
1910        let mut directives = Vec::new();
1911
1912        if !hostname.is_empty() {
1913            directives.push(Directive {
1914                key: "HostName".to_string(),
1915                value: hostname.to_string(),
1916                raw_line: format!("  HostName {}", HostBlock::render_value(&hostname)),
1917                is_non_directive: false,
1918            });
1919        }
1920        if !user.is_empty() {
1921            directives.push(Directive {
1922                key: "User".to_string(),
1923                value: user.to_string(),
1924                raw_line: format!("  User {}", HostBlock::render_value(&user)),
1925                is_non_directive: false,
1926            });
1927        }
1928        if entry.port != 22 {
1929            directives.push(Directive {
1930                key: "Port".to_string(),
1931                value: entry.port.to_string(),
1932                raw_line: format!("  Port {}", entry.port),
1933                is_non_directive: false,
1934            });
1935        }
1936        if !identity_file.is_empty() {
1937            directives.push(Directive {
1938                key: "IdentityFile".to_string(),
1939                value: identity_file.to_string(),
1940                raw_line: format!("  IdentityFile {}", HostBlock::render_value(&identity_file)),
1941                is_non_directive: false,
1942            });
1943        }
1944        if !proxy_jump.is_empty() {
1945            directives.push(Directive {
1946                key: "ProxyJump".to_string(),
1947                value: proxy_jump.to_string(),
1948                raw_line: format!("  ProxyJump {}", HostBlock::render_value(&proxy_jump)),
1949                is_non_directive: false,
1950            });
1951        }
1952
1953        HostBlock {
1954            host_pattern: alias.to_string(),
1955            raw_host_line: format!("Host {}", alias),
1956            directives,
1957        }
1958    }
1959}
1960
1961/// Check whether `host_pattern` contains `alias` as one of its
1962/// whitespace-separated tokens, with quote-stripping. OpenSSH accepts
1963/// `Host "alpha"` as `Host alpha`; without quote-stripping the stored pattern
1964/// `"alpha"` (with literal quote characters) would never match the typed
1965/// alias `alpha`, leaving the block unreachable to the mutation API.
1966pub(super) fn pattern_contains_token(host_pattern: &str, alias: &str) -> bool {
1967    host_pattern.split_whitespace().any(|t| {
1968        let unquoted = if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
1969            &t[1..t.len() - 1]
1970        } else {
1971            t
1972        };
1973        unquoted == alias
1974    })
1975}
1976
1977/// Rebuild a `Host` line with a new pattern, preserving the original line's
1978/// keyword form (`Host` vs `HOST`, with or without `=`), separator (space vs
1979/// tab) and trailing inline comment. Used by delete-token and rename paths
1980/// so that an unrelated edit on a multi-alias block never silently drops the
1981/// inline comment or tab style the user typed.
1982///
1983/// Falls back to `format!("Host {}", new_pattern)` when the original line
1984/// is too short or malformed to deconstruct.
1985pub(super) fn rebuild_host_line(original: &str, new_pattern: &str) -> String {
1986    // Find the position of the inline comment (if any). Inline comments on
1987    // SSH config lines start with a `#` preceded by whitespace, OUTSIDE any
1988    // quoted string. This mirrors `strip_inline_comment` in parser.rs.
1989    let (body, suffix) = {
1990        let bytes = original.as_bytes();
1991        let mut in_quote = false;
1992        let mut comment_start: Option<usize> = None;
1993        for i in 0..bytes.len() {
1994            if bytes[i] == b'"' {
1995                in_quote = !in_quote;
1996            } else if !in_quote
1997                && bytes[i] == b'#'
1998                && i > 0
1999                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
2000            {
2001                comment_start = Some(i - 1); // include the leading whitespace
2002                break;
2003            }
2004        }
2005        match comment_start {
2006            Some(idx) => (
2007                original[..idx].trim_end_matches([' ', '\t']),
2008                &original[idx..],
2009            ),
2010            None => (original.trim_end_matches([' ', '\t']), ""),
2011        }
2012    };
2013
2014    // Split body into keyword + separator + (existing pattern, which we drop).
2015    // Accept tab or space and optional `=`, matching parse_host_line.
2016    let bytes = body.as_bytes();
2017    if bytes.len() < 5 || !bytes[..4].eq_ignore_ascii_case(b"host") {
2018        return format!("Host {}", new_pattern);
2019    }
2020    let sep = bytes[4];
2021    if !sep.is_ascii_whitespace() && sep != b'=' {
2022        return format!("Host {}", new_pattern);
2023    }
2024
2025    // Preserve the original keyword casing (`Host` vs `HOST` vs `host`).
2026    let keyword = &body[..4];
2027
2028    // Capture the original separator span between keyword and pattern so a
2029    // tab-separated `Host\tweb-01` stays tab-separated and `Host=foo` stays
2030    // equals-separated.
2031    let after_keyword = &body[4..];
2032    let pattern_start = after_keyword
2033        .char_indices()
2034        .find(|(_, c)| !c.is_whitespace() && *c != '=')
2035        .map(|(i, _)| i)
2036        .unwrap_or(after_keyword.len());
2037    let separator = &after_keyword[..pattern_start];
2038
2039    format!("{}{}{}{}", keyword, separator, new_pattern, suffix)
2040}
2041
2042#[cfg(test)]
2043#[path = "model_tests.rs"]
2044mod tests;