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