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