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