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