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