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    /// Number of tunnel forwarding directives.
85    pub tunnel_count: u16,
86    /// Password source from purple:askpass comment (e.g. "keychain", "op://...", "pass:...").
87    pub askpass: Option<String>,
88    /// Vault SSH certificate signing role from purple:vault-ssh comment.
89    pub vault_ssh: Option<String>,
90    /// Optional Vault HTTP endpoint from purple:vault-addr comment. When
91    /// set, purple passes it as `VAULT_ADDR` to the `vault` subprocess for
92    /// this host's signing, overriding the parent shell. Empty = inherit env.
93    pub vault_addr: Option<String>,
94    /// CertificateFile directive value (e.g. "~/.ssh/my-cert.pub").
95    pub certificate_file: String,
96    /// Provider metadata from purple:meta comment (region, plan, etc.).
97    pub provider_meta: Vec<(String, String)>,
98    /// Unix timestamp when the host was marked stale (disappeared from provider sync).
99    pub stale: Option<u64>,
100}
101
102impl Default for HostEntry {
103    fn default() -> Self {
104        Self {
105            alias: String::new(),
106            hostname: String::new(),
107            user: String::new(),
108            port: 22,
109            identity_file: String::new(),
110            proxy_jump: String::new(),
111            source_file: None,
112            tags: Vec::new(),
113            provider_tags: Vec::new(),
114            has_provider_tags: false,
115            provider: None,
116            tunnel_count: 0,
117            askpass: None,
118            vault_ssh: None,
119            vault_addr: None,
120            certificate_file: String::new(),
121            provider_meta: Vec::new(),
122            stale: None,
123        }
124    }
125}
126
127impl HostEntry {
128    /// Build the SSH command string for this host.
129    /// Includes `-F <config_path>` when the config is non-default so the alias
130    /// resolves correctly when pasted into a terminal.
131    /// Shell-quotes both the config path and alias to prevent injection.
132    pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
133        let escaped = self.alias.replace('\'', "'\\''");
134        let default = dirs::home_dir()
135            .map(|h| h.join(".ssh/config"))
136            .unwrap_or_default();
137        if config_path == default {
138            format!("ssh -- '{}'", escaped)
139        } else {
140            let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
141            format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
142        }
143    }
144}
145
146/// Convenience view for pattern Host blocks in the TUI.
147#[derive(Debug, Clone, Default)]
148pub struct PatternEntry {
149    pub pattern: String,
150    pub hostname: String,
151    pub user: String,
152    pub port: u16,
153    pub identity_file: String,
154    pub proxy_jump: String,
155    pub tags: Vec<String>,
156    pub askpass: Option<String>,
157    pub source_file: Option<PathBuf>,
158    /// All non-comment directives as key-value pairs for display.
159    pub directives: Vec<(String, String)>,
160}
161
162/// Inherited field hints from matching patterns. Each field is `Some((value,
163/// source_pattern))` when a pattern provides that directive, `None` otherwise.
164#[derive(Debug, Clone, Default)]
165pub struct InheritedHints {
166    pub proxy_jump: Option<(String, String)>,
167    pub user: Option<(String, String)>,
168    pub identity_file: Option<(String, String)>,
169}
170
171use super::pattern::apply_first_match_fields;
172/// Returns true if the host pattern contains wildcards, character classes,
173/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
174/// These are SSH match patterns, not concrete hosts.
175// Pattern-matching lives in `ssh_config::pattern`. These re-exports preserve
176// the old `ssh_config::model::*` import paths used across the codebase and in
177// the model_tests file mounted below.
178#[allow(unused_imports)]
179pub use super::pattern::{
180    host_pattern_matches, is_host_pattern, proxy_jump_contains_self, ssh_pattern_match,
181};
182// Re-exported so the test file mounted below keeps working.
183#[allow(unused_imports)]
184pub(super) use super::repair::provider_group_display_name;
185
186impl SshConfigFile {
187    /// Get all host entries as convenience views (including from Include files).
188    /// Pattern-inherited directives (ProxyJump, User, IdentityFile) are merged
189    /// using SSH-faithful alias-only matching so indicators like ↗ reflect what
190    /// SSH will actually apply when connecting via `ssh <alias>`.
191    pub fn host_entries(&self) -> Vec<HostEntry> {
192        let mut entries = Vec::new();
193        Self::collect_host_entries(&self.elements, &mut entries);
194        self.apply_pattern_inheritance(&mut entries);
195        entries
196    }
197
198    /// Get a single host entry by alias without pattern inheritance applied.
199    /// Returns the raw directives from the host's own block only. Used by the
200    /// edit form so inherited values can be shown as dimmed placeholders.
201    pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
202        Self::find_raw_host_entry(&self.elements, alias)
203    }
204
205    fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
206        for e in elements {
207            match e {
208                ConfigElement::HostBlock(block)
209                    if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
210                {
211                    return Some(block.to_host_entry());
212                }
213                ConfigElement::Include(inc) => {
214                    for file in &inc.resolved_files {
215                        if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
216                            if found.source_file.is_none() {
217                                found.source_file = Some(file.path.clone());
218                            }
219                            return Some(found);
220                        }
221                    }
222                }
223                _ => {}
224            }
225        }
226        None
227    }
228
229    /// Apply SSH first-match-wins pattern inheritance to host entries.
230    /// Matches patterns against the alias only (SSH-faithful: `Host` patterns
231    /// match the token typed on the command line, not the resolved `Hostname`).
232    fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
233        // Patterns are pre-collected once. Host entries never contain pattern
234        // aliases — collect_host_entries skips is_host_pattern blocks.
235        let all_patterns = self.pattern_entries();
236        for entry in entries.iter_mut() {
237            if !entry.proxy_jump.is_empty()
238                && !entry.user.is_empty()
239                && !entry.identity_file.is_empty()
240            {
241                continue;
242            }
243            for p in &all_patterns {
244                if !host_pattern_matches(&p.pattern, &entry.alias) {
245                    continue;
246                }
247                apply_first_match_fields(
248                    &mut entry.proxy_jump,
249                    &mut entry.user,
250                    &mut entry.identity_file,
251                    p,
252                );
253                if !entry.proxy_jump.is_empty()
254                    && !entry.user.is_empty()
255                    && !entry.identity_file.is_empty()
256                {
257                    break;
258                }
259            }
260        }
261    }
262
263    /// Compute pattern-provided field hints for a host alias. Returns first-match
264    /// values and their source patterns for ProxyJump, User and IdentityFile.
265    /// These are returned regardless of whether the host has its own values for
266    /// those fields. The caller (form rendering) decides visibility based on
267    /// whether the field is empty. Matches by alias only (SSH-faithful).
268    pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
269        let patterns = self.matching_patterns(alias);
270        let mut hints = InheritedHints::default();
271        for p in &patterns {
272            if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
273                hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
274            }
275            if hints.user.is_none() && !p.user.is_empty() {
276                hints.user = Some((p.user.clone(), p.pattern.clone()));
277            }
278            if hints.identity_file.is_none() && !p.identity_file.is_empty() {
279                hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
280            }
281            if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
282                break;
283            }
284        }
285        hints
286    }
287
288    /// Get all pattern entries as convenience views (including from Include files).
289    pub fn pattern_entries(&self) -> Vec<PatternEntry> {
290        let mut entries = Vec::new();
291        Self::collect_pattern_entries(&self.elements, &mut entries);
292        entries
293    }
294
295    fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
296        for e in elements {
297            match e {
298                ConfigElement::HostBlock(block) => {
299                    if !is_host_pattern(&block.host_pattern) {
300                        continue;
301                    }
302                    entries.push(block.to_pattern_entry());
303                }
304                ConfigElement::Include(include) => {
305                    for file in &include.resolved_files {
306                        let start = entries.len();
307                        Self::collect_pattern_entries(&file.elements, entries);
308                        for entry in &mut entries[start..] {
309                            if entry.source_file.is_none() {
310                                entry.source_file = Some(file.path.clone());
311                            }
312                        }
313                    }
314                }
315                ConfigElement::GlobalLine(_) => {}
316            }
317        }
318    }
319
320    /// Find all pattern blocks that match a given host alias and hostname.
321    /// Returns entries in config order (first match first).
322    pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
323        let mut matches = Vec::new();
324        Self::collect_matching_patterns(&self.elements, alias, &mut matches);
325        matches
326    }
327
328    fn collect_matching_patterns(
329        elements: &[ConfigElement],
330        alias: &str,
331        matches: &mut Vec<PatternEntry>,
332    ) {
333        for e in elements {
334            match e {
335                ConfigElement::HostBlock(block) => {
336                    if !is_host_pattern(&block.host_pattern) {
337                        continue;
338                    }
339                    if host_pattern_matches(&block.host_pattern, alias) {
340                        matches.push(block.to_pattern_entry());
341                    }
342                }
343                ConfigElement::Include(include) => {
344                    for file in &include.resolved_files {
345                        let start = matches.len();
346                        Self::collect_matching_patterns(&file.elements, alias, matches);
347                        for entry in &mut matches[start..] {
348                            if entry.source_file.is_none() {
349                                entry.source_file = Some(file.path.clone());
350                            }
351                        }
352                    }
353                }
354                ConfigElement::GlobalLine(_) => {}
355            }
356        }
357    }
358
359    /// Collect all resolved Include file paths (recursively).
360    pub fn include_paths(&self) -> Vec<PathBuf> {
361        let mut paths = Vec::new();
362        Self::collect_include_paths(&self.elements, &mut paths);
363        paths
364    }
365
366    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
367        for e in elements {
368            if let ConfigElement::Include(include) = e {
369                for file in &include.resolved_files {
370                    paths.push(file.path.clone());
371                    Self::collect_include_paths(&file.elements, paths);
372                }
373            }
374        }
375    }
376
377    /// Collect parent directories of Include glob patterns.
378    /// When a file is added/removed under a glob dir, the directory's mtime changes.
379    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
380        let config_dir = self.path.parent();
381        let mut seen = std::collections::HashSet::new();
382        let mut dirs = Vec::new();
383        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
384        dirs
385    }
386
387    fn collect_include_glob_dirs(
388        elements: &[ConfigElement],
389        config_dir: Option<&std::path::Path>,
390        seen: &mut std::collections::HashSet<PathBuf>,
391        dirs: &mut Vec<PathBuf>,
392    ) {
393        for e in elements {
394            if let ConfigElement::Include(include) = e {
395                // Split respecting quoted paths (same as resolve_include does)
396                for single in Self::split_include_patterns(&include.pattern) {
397                    let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
398                    let resolved = if expanded.starts_with('/') {
399                        PathBuf::from(&expanded)
400                    } else if let Some(dir) = config_dir {
401                        dir.join(&expanded)
402                    } else {
403                        continue;
404                    };
405                    if let Some(parent) = resolved.parent() {
406                        let parent = parent.to_path_buf();
407                        if seen.insert(parent.clone()) {
408                            dirs.push(parent);
409                        }
410                    }
411                }
412                // Recurse into resolved files
413                for file in &include.resolved_files {
414                    Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
415                }
416            }
417        }
418    }
419
420    /// Remove `# purple:group <Name>` headers that have no corresponding
421    /// provider hosts. Returns the number of headers removed.
422    /// Recursively collect host entries from a list of elements.
423    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
424        for e in elements {
425            match e {
426                ConfigElement::HostBlock(block) => {
427                    if is_host_pattern(&block.host_pattern) {
428                        continue;
429                    }
430                    entries.push(block.to_host_entry());
431                }
432                ConfigElement::Include(include) => {
433                    for file in &include.resolved_files {
434                        let start = entries.len();
435                        Self::collect_host_entries(&file.elements, entries);
436                        for entry in &mut entries[start..] {
437                            if entry.source_file.is_none() {
438                                entry.source_file = Some(file.path.clone());
439                            }
440                        }
441                    }
442                }
443                ConfigElement::GlobalLine(_) => {}
444            }
445        }
446    }
447
448    /// Check if a host alias already exists (including in Include files).
449    /// Walks the element tree directly without building HostEntry structs.
450    pub fn has_host(&self, alias: &str) -> bool {
451        Self::has_host_in_elements(&self.elements, alias)
452    }
453
454    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
455        for e in elements {
456            match e {
457                ConfigElement::HostBlock(block) => {
458                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
459                        return true;
460                    }
461                }
462                ConfigElement::Include(include) => {
463                    for file in &include.resolved_files {
464                        if Self::has_host_in_elements(&file.elements, alias) {
465                            return true;
466                        }
467                    }
468                }
469                ConfigElement::GlobalLine(_) => {}
470            }
471        }
472        false
473    }
474
475    /// Check if a host block with exactly this host_pattern exists (top-level only).
476    /// Unlike `has_host` which splits multi-host patterns and checks individual parts,
477    /// this matches the full `Host` line pattern string (e.g. "web-* db-*").
478    /// Does not search Include files (patterns from includes are read-only).
479    pub fn has_host_block(&self, pattern: &str) -> bool {
480        self.elements
481            .iter()
482            .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
483    }
484
485    /// Check if a host alias is from an included file (read-only).
486    /// Handles multi-pattern Host lines by splitting on whitespace.
487    pub fn is_included_host(&self, alias: &str) -> bool {
488        // Not in top-level elements → must be in an Include
489        for e in &self.elements {
490            match e {
491                ConfigElement::HostBlock(block) => {
492                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
493                        return false;
494                    }
495                }
496                ConfigElement::Include(include) => {
497                    for file in &include.resolved_files {
498                        if Self::has_host_in_elements(&file.elements, alias) {
499                            return true;
500                        }
501                    }
502                }
503                ConfigElement::GlobalLine(_) => {}
504            }
505        }
506        false
507    }
508
509    /// Add a new host entry to the config.
510    /// Inserts before any trailing wildcard/pattern Host blocks (e.g. `Host *`)
511    /// so that SSH "first match wins" semantics are preserved. If wildcards are
512    /// only at the top of the file (acting as global defaults), appends at end.
513    pub fn add_host(&mut self, entry: &HostEntry) {
514        let block = Self::entry_to_block(entry);
515        let insert_pos = self.find_trailing_pattern_start();
516
517        if let Some(pos) = insert_pos {
518            // Insert before the trailing pattern group, with blank separators
519            let needs_blank_before = pos > 0
520                && !matches!(
521                    self.elements.get(pos - 1),
522                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
523                );
524            let mut idx = pos;
525            if needs_blank_before {
526                self.elements
527                    .insert(idx, ConfigElement::GlobalLine(String::new()));
528                idx += 1;
529            }
530            self.elements.insert(idx, ConfigElement::HostBlock(block));
531            // Ensure a blank separator after the new block (before the wildcard group)
532            let after = idx + 1;
533            if after < self.elements.len()
534                && !matches!(
535                    self.elements.get(after),
536                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
537                )
538            {
539                self.elements
540                    .insert(after, ConfigElement::GlobalLine(String::new()));
541            }
542        } else {
543            // No trailing patterns: append at end
544            if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
545                self.elements.push(ConfigElement::GlobalLine(String::new()));
546            }
547            self.elements.push(ConfigElement::HostBlock(block));
548        }
549    }
550
551    /// Find the start of a trailing group of wildcard/pattern Host blocks.
552    /// Scans backwards from the end, skipping GlobalLines (blanks/comments/Match).
553    /// Returns `None` if no trailing patterns exist (or if ALL hosts are patterns,
554    /// i.e. patterns start at position 0 — in that case we append at end).
555    fn find_trailing_pattern_start(&self) -> Option<usize> {
556        let mut first_pattern_pos = None;
557        for i in (0..self.elements.len()).rev() {
558            match &self.elements[i] {
559                ConfigElement::HostBlock(block) => {
560                    if is_host_pattern(&block.host_pattern) {
561                        first_pattern_pos = Some(i);
562                    } else {
563                        // Found a concrete host: the trailing group starts after this
564                        break;
565                    }
566                }
567                ConfigElement::GlobalLine(_) => {
568                    // Blank lines, comments, Match blocks between patterns: keep scanning
569                    if first_pattern_pos.is_some() {
570                        first_pattern_pos = Some(i);
571                    }
572                }
573                ConfigElement::Include(_) => break,
574            }
575        }
576        // Don't return position 0 — that means everything is patterns (or patterns at top)
577        first_pattern_pos.filter(|&pos| pos > 0)
578    }
579
580    /// Check if the last element already ends with a blank line.
581    pub fn last_element_has_trailing_blank(&self) -> bool {
582        match self.elements.last() {
583            Some(ConfigElement::HostBlock(block)) => block
584                .directives
585                .last()
586                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
587            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
588            _ => false,
589        }
590    }
591
592    /// Update an existing host entry by alias.
593    /// Merges changes into the existing block, preserving unknown directives.
594    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
595        for element in &mut self.elements {
596            if let ConfigElement::HostBlock(block) = element {
597                if block.host_pattern == old_alias {
598                    // Update host pattern (preserve raw_host_line when alias unchanged)
599                    if entry.alias != block.host_pattern {
600                        block.host_pattern = entry.alias.clone();
601                        block.raw_host_line = format!("Host {}", entry.alias);
602                    }
603
604                    // Merge known directives (update existing, add missing, remove empty)
605                    Self::upsert_directive(block, "HostName", &entry.hostname);
606                    Self::upsert_directive(block, "User", &entry.user);
607                    if entry.port != 22 {
608                        Self::upsert_directive(block, "Port", &entry.port.to_string());
609                    } else {
610                        // Remove explicit Port 22 (it's the default)
611                        block
612                            .directives
613                            .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
614                    }
615                    Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
616                    Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
617                    return;
618                }
619            }
620        }
621    }
622
623    /// Update a directive in-place, add it if missing, or remove it if value is empty.
624    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
625        if value.is_empty() {
626            block
627                .directives
628                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
629            return;
630        }
631        let indent = block.detect_indent();
632        for d in &mut block.directives {
633            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
634                // Only rebuild raw_line when value actually changed (preserves inline comments)
635                if d.value != value {
636                    d.value = value.to_string();
637                    // Detect separator style from original raw_line and preserve it.
638                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
639                    // Only considers '=' as separator if it appears before any
640                    // non-whitespace content (avoids matching '=' inside values
641                    // like "IdentityFile ~/.ssh/id=prod").
642                    let trimmed = d.raw_line.trim_start();
643                    let after_key = &trimmed[d.key.len()..];
644                    let sep = if after_key.trim_start().starts_with('=') {
645                        let eq_pos = after_key.find('=').unwrap();
646                        let after_eq = &after_key[eq_pos + 1..];
647                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
648                        after_key[..eq_pos + 1 + trailing_ws].to_string()
649                    } else {
650                        " ".to_string()
651                    };
652                    // Preserve inline comment from original raw_line (e.g. "# production")
653                    let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
654                    d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
655                }
656                return;
657            }
658        }
659        // Not found — insert before trailing blanks
660        let pos = block.content_end();
661        block.directives.insert(
662            pos,
663            Directive {
664                key: key.to_string(),
665                value: value.to_string(),
666                raw_line: format!("{}{} {}", indent, key, value),
667                is_non_directive: false,
668            },
669        );
670    }
671
672    /// Extract the inline comment suffix from a directive's raw line.
673    /// Returns the trailing portion (e.g. " # production") or empty string.
674    /// Respects double-quoted strings so that `#` inside quotes is not a comment.
675    fn extract_inline_comment(raw_line: &str, key: &str) -> String {
676        let trimmed = raw_line.trim_start();
677        if trimmed.len() <= key.len() {
678            return String::new();
679        }
680        // Skip past key and separator to reach the value portion
681        let after_key = &trimmed[key.len()..];
682        let rest = after_key.trim_start();
683        let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
684        // Scan for inline comment (# preceded by whitespace, outside quotes)
685        let bytes = rest.as_bytes();
686        let mut in_quote = false;
687        for i in 0..bytes.len() {
688            if bytes[i] == b'"' {
689                in_quote = !in_quote;
690            } else if !in_quote
691                && bytes[i] == b'#'
692                && i > 0
693                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
694            {
695                // Found comment start. The clean value ends before the whitespace preceding #.
696                let clean_end = rest[..i].trim_end().len();
697                return rest[clean_end..].to_string();
698            }
699        }
700        String::new()
701    }
702
703    /// Set provider on a host block by alias.
704    pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
705        for element in &mut self.elements {
706            if let ConfigElement::HostBlock(block) = element {
707                if block.host_pattern == alias {
708                    block.set_provider(provider_name, server_id);
709                    return;
710                }
711            }
712        }
713    }
714
715    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
716    /// Searches both top-level elements and Include files so that provider hosts
717    /// in included configs are recognized during sync (prevents duplicate additions).
718    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
719        let mut results = Vec::new();
720        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
721        results
722    }
723
724    fn collect_provider_hosts(
725        elements: &[ConfigElement],
726        provider_name: &str,
727        results: &mut Vec<(String, String)>,
728    ) {
729        for element in elements {
730            match element {
731                ConfigElement::HostBlock(block) => {
732                    if let Some((name, id)) = block.provider() {
733                        if name == provider_name {
734                            results.push((block.host_pattern.clone(), id));
735                        }
736                    }
737                }
738                ConfigElement::Include(include) => {
739                    for file in &include.resolved_files {
740                        Self::collect_provider_hosts(&file.elements, provider_name, results);
741                    }
742                }
743                ConfigElement::GlobalLine(_) => {}
744            }
745        }
746    }
747
748    /// Compare two directive values with whitespace normalization.
749    /// Handles hand-edited configs with tabs or multiple spaces.
750    fn values_match(a: &str, b: &str) -> bool {
751        a.split_whitespace().eq(b.split_whitespace())
752    }
753
754    /// Add a forwarding directive to a host block.
755    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
756    /// Uses split_whitespace matching for multi-pattern Host lines.
757    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
758        for element in &mut self.elements {
759            if let ConfigElement::HostBlock(block) = element {
760                if block.host_pattern.split_whitespace().any(|p| p == alias) {
761                    let indent = block.detect_indent();
762                    let pos = block.content_end();
763                    block.directives.insert(
764                        pos,
765                        Directive {
766                            key: directive_key.to_string(),
767                            value: value.to_string(),
768                            raw_line: format!("{}{} {}", indent, directive_key, value),
769                            is_non_directive: false,
770                        },
771                    );
772                    return;
773                }
774            }
775        }
776    }
777
778    /// Remove a specific forwarding directive from a host block.
779    /// Matches key (case-insensitive) and value (whitespace-normalized).
780    /// Uses split_whitespace matching for multi-pattern Host lines.
781    /// Returns true if a directive was actually removed.
782    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
783        for element in &mut self.elements {
784            if let ConfigElement::HostBlock(block) = element {
785                if block.host_pattern.split_whitespace().any(|p| p == alias) {
786                    if let Some(pos) = block.directives.iter().position(|d| {
787                        !d.is_non_directive
788                            && d.key.eq_ignore_ascii_case(directive_key)
789                            && Self::values_match(&d.value, value)
790                    }) {
791                        block.directives.remove(pos);
792                        return true;
793                    }
794                    return false;
795                }
796            }
797        }
798        false
799    }
800
801    /// Check if a host block has a specific forwarding directive.
802    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
803    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
804        for element in &self.elements {
805            if let ConfigElement::HostBlock(block) = element {
806                if block.host_pattern.split_whitespace().any(|p| p == alias) {
807                    return block.directives.iter().any(|d| {
808                        !d.is_non_directive
809                            && d.key.eq_ignore_ascii_case(directive_key)
810                            && Self::values_match(&d.value, value)
811                    });
812                }
813            }
814        }
815        false
816    }
817
818    /// Find tunnel directives for a host alias, searching all elements including
819    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
820    /// Host lines.
821    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
822        Self::find_tunnel_directives_in(&self.elements, alias)
823    }
824
825    fn find_tunnel_directives_in(
826        elements: &[ConfigElement],
827        alias: &str,
828    ) -> Vec<crate::tunnel::TunnelRule> {
829        for element in elements {
830            match element {
831                ConfigElement::HostBlock(block) => {
832                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
833                        return block.tunnel_directives();
834                    }
835                }
836                ConfigElement::Include(include) => {
837                    for file in &include.resolved_files {
838                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
839                        if !rules.is_empty() {
840                            return rules;
841                        }
842                    }
843                }
844                ConfigElement::GlobalLine(_) => {}
845            }
846        }
847        Vec::new()
848    }
849
850    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
851    pub fn deduplicate_alias(&self, base: &str) -> String {
852        self.deduplicate_alias_excluding(base, None)
853    }
854
855    /// Generate a unique alias, optionally excluding one alias from collision detection.
856    /// Used during rename so the host being renamed doesn't collide with itself.
857    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
858        let is_taken = |alias: &str| {
859            if exclude == Some(alias) {
860                return false;
861            }
862            self.has_host(alias)
863        };
864        if !is_taken(base) {
865            return base.to_string();
866        }
867        for n in 2..=9999 {
868            let candidate = format!("{}-{}", base, n);
869            if !is_taken(&candidate) {
870                return candidate;
871            }
872        }
873        // Practically unreachable: fall back to PID-based suffix
874        format!("{}-{}", base, std::process::id())
875    }
876
877    /// Set tags on a host block by alias.
878    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
879        for element in &mut self.elements {
880            if let ConfigElement::HostBlock(block) = element {
881                if block.host_pattern == alias {
882                    block.set_tags(tags);
883                    return;
884                }
885            }
886        }
887    }
888
889    /// Set provider-synced tags on a host block by alias.
890    pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
891        for element in &mut self.elements {
892            if let ConfigElement::HostBlock(block) = element {
893                if block.host_pattern == alias {
894                    block.set_provider_tags(tags);
895                    return;
896                }
897            }
898        }
899    }
900
901    /// Set askpass source on a host block by alias.
902    pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
903        for element in &mut self.elements {
904            if let ConfigElement::HostBlock(block) = element {
905                if block.host_pattern == alias {
906                    block.set_askpass(source);
907                    return;
908                }
909            }
910        }
911    }
912
913    /// Set vault-ssh role on a host block by alias.
914    pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) {
915        for element in &mut self.elements {
916            if let ConfigElement::HostBlock(block) = element {
917                if block.host_pattern == alias {
918                    block.set_vault_ssh(role);
919                    return;
920                }
921            }
922        }
923    }
924
925    /// Set or remove the Vault SSH endpoint comment on a host block by alias.
926    /// Empty `url` removes the comment.
927    ///
928    /// Mirrors the safety invariants of `set_host_certificate_file`: wildcard
929    /// aliases are refused to avoid accidentally applying a vault address to
930    /// every host resolved through a pattern, and Match blocks are not
931    /// touched (they live as inert `GlobalLines`). Returns `true` on a
932    /// successful mutation, `false` when the alias is invalid or the block
933    /// is not found.
934    ///
935    /// Callers that run asynchronously (e.g. form submit handlers that
936    /// resolve the alias before writing) MUST check the return value so a
937    /// silent config mutation failure is surfaced instead of pretending the
938    /// vault address was wired up.
939    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
940    pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
941        // Same guard as `set_host_certificate_file`: refuse empty aliases
942        // and any SSH pattern shape. `is_host_pattern` already covers
943        // wildcards, negation and whitespace-separated multi-host forms.
944        if alias.is_empty() || is_host_pattern(alias) {
945            return false;
946        }
947        for element in &mut self.elements {
948            if let ConfigElement::HostBlock(block) = element {
949                if block.host_pattern == alias {
950                    block.set_vault_addr(url);
951                    return true;
952                }
953            }
954        }
955        false
956    }
957
958    /// Set or remove the CertificateFile directive on a host block by alias.
959    /// Empty path removes the directive.
960    /// Set the `CertificateFile` directive on the host block that matches
961    /// `alias` exactly. Returns `true` if a matching block was found and
962    /// updated, `false` if no top-level `HostBlock` matched (alias was
963    /// renamed, deleted or lives only inside an `Include`d file).
964    ///
965    /// Callers that run asynchronously (e.g. the Vault SSH bulk-sign worker)
966    /// MUST check the return value so a silent config mutation failure is
967    /// surfaced to the user instead of pretending the cert was wired up.
968    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
969    pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
970        // Defense in depth: refuse to mutate a host block when the requested
971        // alias is empty or matches any SSH pattern shape (`*`, `?`, `[`,
972        // leading `!`, or whitespace-separated multi-host form like
973        // `Host web-* db-*`). Writing `CertificateFile` onto a pattern
974        // block is almost never what a user intends and would affect every
975        // host that resolves through that pattern. Reusing `is_host_pattern`
976        // keeps this check in sync with the form-level pattern detection.
977        if alias.is_empty() || is_host_pattern(alias) {
978            return false;
979        }
980        for element in &mut self.elements {
981            if let ConfigElement::HostBlock(block) = element {
982                if block.host_pattern == alias {
983                    Self::upsert_directive(block, "CertificateFile", path);
984                    return true;
985                }
986            }
987        }
988        false
989    }
990
991    /// Set provider metadata on a host block by alias.
992    pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
993        for element in &mut self.elements {
994            if let ConfigElement::HostBlock(block) = element {
995                if block.host_pattern == alias {
996                    block.set_meta(meta);
997                    return;
998                }
999            }
1000        }
1001    }
1002
1003    /// Mark a host as stale by alias.
1004    pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1005        for element in &mut self.elements {
1006            if let ConfigElement::HostBlock(block) = element {
1007                if block.host_pattern == alias {
1008                    block.set_stale(timestamp);
1009                    return;
1010                }
1011            }
1012        }
1013    }
1014
1015    /// Clear stale marking from a host by alias.
1016    pub fn clear_host_stale(&mut self, alias: &str) {
1017        for element in &mut self.elements {
1018            if let ConfigElement::HostBlock(block) = element {
1019                if block.host_pattern == alias {
1020                    block.clear_stale();
1021                    return;
1022                }
1023            }
1024        }
1025    }
1026
1027    /// Collect all stale hosts with their timestamps.
1028    pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1029        let mut result = Vec::new();
1030        for element in &self.elements {
1031            if let ConfigElement::HostBlock(block) = element {
1032                if let Some(ts) = block.stale() {
1033                    result.push((block.host_pattern.clone(), ts));
1034                }
1035            }
1036        }
1037        result
1038    }
1039
1040    /// Delete a host entry by alias.
1041    pub fn delete_host(&mut self, alias: &str) {
1042        // Before deletion, check if this host belongs to a provider so we can
1043        // clean up an orphaned group header afterwards.
1044        let provider_name = self.elements.iter().find_map(|e| {
1045            if let ConfigElement::HostBlock(b) = e {
1046                if b.host_pattern == alias {
1047                    return b.provider().map(|(name, _)| name);
1048                }
1049            }
1050            None
1051        });
1052
1053        self.elements.retain(|e| match e {
1054            ConfigElement::HostBlock(block) => block.host_pattern != alias,
1055            _ => true,
1056        });
1057
1058        // Remove orphaned group header if no hosts remain for the provider.
1059        if let Some(name) = provider_name {
1060            self.remove_orphaned_group_header(&name);
1061        }
1062
1063        // Collapse consecutive blank lines left by deletion
1064        self.elements.dedup_by(|a, b| {
1065            matches!(
1066                (&*a, &*b),
1067                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1068                if x.trim().is_empty() && y.trim().is_empty()
1069            )
1070        });
1071    }
1072
1073    /// Delete a host and return the removed element and its position for undo.
1074    /// Does NOT collapse blank lines or remove group headers so the position
1075    /// stays valid for re-insertion via `insert_host_at()`.
1076    /// Orphaned group headers (if any) are cleaned up at next startup.
1077    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1078        let pos = self
1079            .elements
1080            .iter()
1081            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
1082        let element = self.elements.remove(pos);
1083        Some((element, pos))
1084    }
1085
1086    /// Insert a host block at a specific position (for undo).
1087    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1088        let pos = position.min(self.elements.len());
1089        self.elements.insert(pos, element);
1090    }
1091
1092    /// Find the position after the last HostBlock that belongs to a provider.
1093    /// Returns `None` if no hosts for this provider exist in the config.
1094    /// Used by the sync engine to insert new hosts adjacent to existing provider hosts.
1095    pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1096        let mut last_pos = None;
1097        for (i, element) in self.elements.iter().enumerate() {
1098            if let ConfigElement::HostBlock(block) = element {
1099                if let Some((name, _)) = block.provider() {
1100                    if name == provider_name {
1101                        last_pos = Some(i);
1102                    }
1103                }
1104            }
1105        }
1106        // Return position after the last provider host
1107        last_pos.map(|p| p + 1)
1108    }
1109
1110    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
1111    #[allow(dead_code)]
1112    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1113        let pos_a = self
1114            .elements
1115            .iter()
1116            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1117        let pos_b = self
1118            .elements
1119            .iter()
1120            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1121        if let (Some(a), Some(b)) = (pos_a, pos_b) {
1122            if a == b {
1123                return false;
1124            }
1125            let (first, second) = (a.min(b), a.max(b));
1126
1127            // Strip trailing blanks from both blocks before swap
1128            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1129                block.pop_trailing_blanks();
1130            }
1131            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1132                block.pop_trailing_blanks();
1133            }
1134
1135            // Swap
1136            self.elements.swap(first, second);
1137
1138            // Add trailing blank to first block (separator between the two)
1139            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1140                block.ensure_trailing_blank();
1141            }
1142
1143            // Add trailing blank to second only if not the last element
1144            if second < self.elements.len() - 1 {
1145                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1146                    block.ensure_trailing_blank();
1147                }
1148            }
1149
1150            return true;
1151        }
1152        false
1153    }
1154
1155    /// Convert a HostEntry into a new HostBlock with clean formatting.
1156    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1157        // Defense-in-depth: callers must validate before reaching here.
1158        // Newlines in values would inject extra SSH config directives.
1159        debug_assert!(
1160            !entry.alias.contains('\n') && !entry.alias.contains('\r'),
1161            "entry_to_block: alias contains newline"
1162        );
1163        debug_assert!(
1164            !entry.hostname.contains('\n') && !entry.hostname.contains('\r'),
1165            "entry_to_block: hostname contains newline"
1166        );
1167        debug_assert!(
1168            !entry.user.contains('\n') && !entry.user.contains('\r'),
1169            "entry_to_block: user contains newline"
1170        );
1171
1172        let mut directives = Vec::new();
1173
1174        if !entry.hostname.is_empty() {
1175            directives.push(Directive {
1176                key: "HostName".to_string(),
1177                value: entry.hostname.clone(),
1178                raw_line: format!("  HostName {}", entry.hostname),
1179                is_non_directive: false,
1180            });
1181        }
1182        if !entry.user.is_empty() {
1183            directives.push(Directive {
1184                key: "User".to_string(),
1185                value: entry.user.clone(),
1186                raw_line: format!("  User {}", entry.user),
1187                is_non_directive: false,
1188            });
1189        }
1190        if entry.port != 22 {
1191            directives.push(Directive {
1192                key: "Port".to_string(),
1193                value: entry.port.to_string(),
1194                raw_line: format!("  Port {}", entry.port),
1195                is_non_directive: false,
1196            });
1197        }
1198        if !entry.identity_file.is_empty() {
1199            directives.push(Directive {
1200                key: "IdentityFile".to_string(),
1201                value: entry.identity_file.clone(),
1202                raw_line: format!("  IdentityFile {}", entry.identity_file),
1203                is_non_directive: false,
1204            });
1205        }
1206        if !entry.proxy_jump.is_empty() {
1207            directives.push(Directive {
1208                key: "ProxyJump".to_string(),
1209                value: entry.proxy_jump.clone(),
1210                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
1211                is_non_directive: false,
1212            });
1213        }
1214
1215        HostBlock {
1216            host_pattern: entry.alias.clone(),
1217            raw_host_line: format!("Host {}", entry.alias),
1218            directives,
1219        }
1220    }
1221}
1222
1223#[cfg(test)]
1224#[path = "model_tests.rs"]
1225mod tests;