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