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