Skip to main content

purple_ssh/ssh_config/
host_block.rs

1//! Accessors, mutators and converters for a single `Host <pattern>` block.
2//!
3//! Everything that reads or writes `# purple:*` comment metadata, SSH
4//! directives, or round-trip formatting for one block lives here. The
5//! type definition itself and the rest of the file-level model stay in
6//! [`super::model`].
7
8use super::model::{Directive, HostBlock, HostEntry, PatternEntry};
9
10impl HostBlock {
11    /// Index of the first trailing blank line (for inserting content before separators).
12    pub(super) fn content_end(&self) -> usize {
13        let mut pos = self.directives.len();
14        while pos > 0 {
15            if self.directives[pos - 1].is_non_directive
16                && self.directives[pos - 1].raw_line.trim().is_empty()
17            {
18                pos -= 1;
19            } else {
20                break;
21            }
22        }
23        pos
24    }
25
26    /// Remove and return trailing blank lines.
27    #[allow(dead_code)]
28    pub(super) fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
29        let end = self.content_end();
30        self.directives.drain(end..).collect()
31    }
32
33    /// Ensure exactly one trailing blank line.
34    #[allow(dead_code)]
35    pub(super) fn ensure_trailing_blank(&mut self) {
36        self.pop_trailing_blanks();
37        self.directives.push(Directive {
38            key: String::new(),
39            value: String::new(),
40            raw_line: String::new(),
41            is_non_directive: true,
42        });
43    }
44
45    /// Detect indentation used by existing directives (falls back to "  ").
46    pub(super) fn detect_indent(&self) -> String {
47        for d in &self.directives {
48            if !d.is_non_directive && !d.raw_line.is_empty() {
49                let trimmed = d.raw_line.trim_start();
50                let indent_len = d.raw_line.len() - trimmed.len();
51                if indent_len > 0 {
52                    return d.raw_line[..indent_len].to_string();
53                }
54            }
55        }
56        "  ".to_string()
57    }
58
59    /// Extract tags from purple:tags comment in directives.
60    pub fn tags(&self) -> Vec<String> {
61        for d in &self.directives {
62            if d.is_non_directive {
63                let trimmed = d.raw_line.trim();
64                if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
65                    return rest
66                        .split(',')
67                        .map(|t| t.trim().to_string())
68                        .filter(|t| !t.is_empty())
69                        .collect();
70                }
71            }
72        }
73        Vec::new()
74    }
75
76    /// Extract provider-synced tags from purple:provider_tags comment.
77    pub fn provider_tags(&self) -> Vec<String> {
78        for d in &self.directives {
79            if d.is_non_directive {
80                let trimmed = d.raw_line.trim();
81                if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
82                    return rest
83                        .split(',')
84                        .map(|t| t.trim().to_string())
85                        .filter(|t| !t.is_empty())
86                        .collect();
87                }
88            }
89        }
90        Vec::new()
91    }
92
93    /// Check if a purple:provider_tags comment exists (even if empty).
94    /// Used to distinguish "never migrated" from "migrated with no tags".
95    pub fn has_provider_tags_comment(&self) -> bool {
96        self.directives.iter().any(|d| {
97            d.is_non_directive && {
98                let t = d.raw_line.trim();
99                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
100            }
101        })
102    }
103
104    /// Extract the last-synced provider username from a purple:provider_user
105    /// comment. Returns `None` when absent or empty. The `_user`/`_key` suffix
106    /// (no space) keeps these clear of the `# purple:provider ` marker parser.
107    pub fn provider_user(&self) -> Option<String> {
108        self.single_value_marker("# purple:provider_user")
109    }
110
111    /// Extract the last-synced provider identity file from a
112    /// purple:provider_key comment. Returns `None` when absent or empty.
113    pub fn provider_key(&self) -> Option<String> {
114        self.single_value_marker("# purple:provider_key")
115    }
116
117    /// Read a single-value `# purple:<name> <value>` comment, trimmed.
118    /// Returns `None` when the comment is absent or carries no value.
119    fn single_value_marker(&self, prefix: &str) -> Option<String> {
120        let with_space = format!("{} ", prefix);
121        for d in &self.directives {
122            if !d.is_non_directive {
123                continue;
124            }
125            let trimmed = d.raw_line.trim();
126            if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
127                let value = rest.trim();
128                if !value.is_empty() {
129                    return Some(value.to_string());
130                }
131            }
132        }
133        None
134    }
135
136    /// Extract provider info from purple:provider comment in directives.
137    /// Returns (provider_name, server_id), e.g. ("digitalocean", "412345678").
138    /// Label is dropped here; use `provider_id()` for the full identifier.
139    pub fn provider(&self) -> Option<(String, String)> {
140        self.provider_id()
141            .map(|(id, server_id)| (id.provider, server_id))
142    }
143
144    /// Raw 2-segment interpretation of the purple:provider marker.
145    /// Splits on the FIRST colon only and returns (provider_name, full_tail).
146    /// For `proxmox:qemu:300` this yields `("proxmox", "qemu:300")`, NOT
147    /// `("proxmox:qemu", "300")` like the label-aware `provider_id()` would.
148    ///
149    /// Use this when you need to claim or compare every marker for a provider
150    /// regardless of whether the middle segment happens to look like a label.
151    /// Server_ids containing colons (Proxmox `qemu:N`, OCI compartment paths)
152    /// otherwise produce false labeled-marker interpretations.
153    pub fn provider_raw(&self) -> Option<(String, String)> {
154        for d in &self.directives {
155            if !d.is_non_directive {
156                continue;
157            }
158            let trimmed = d.raw_line.trim();
159            let rest = match trimmed.strip_prefix("# purple:provider ") {
160                Some(r) => r.trim(),
161                None => continue,
162            };
163            let (provider, server_id) = rest.split_once(':')?;
164            let provider = provider.trim();
165            let server_id = server_id.trim();
166            if provider.is_empty() || server_id.is_empty() {
167                return None;
168            }
169            return Some((provider.to_string(), server_id.to_string()));
170        }
171        None
172    }
173
174    /// Extract provider info as `(ProviderConfigId, server_id)`.
175    /// Supports both 2-segment legacy markers (`provider:server_id`) and
176    /// 3-segment labeled markers (`provider:label:server_id`).
177    pub fn provider_id(&self) -> Option<(crate::providers::config::ProviderConfigId, String)> {
178        for d in &self.directives {
179            if !d.is_non_directive {
180                continue;
181            }
182            let trimmed = d.raw_line.trim();
183            let rest = match trimmed.strip_prefix("# purple:provider ") {
184                Some(r) => r.trim(),
185                None => continue,
186            };
187            // splitn(3) so a server_id containing ':' (rare, but possible)
188            // ends up wholly in the last segment.
189            let parts: Vec<&str> = rest.splitn(3, ':').collect();
190            return match parts.as_slice() {
191                [provider, server_id] => {
192                    let provider = provider.trim();
193                    let server_id = server_id.trim();
194                    if provider.is_empty() || server_id.is_empty() {
195                        return None;
196                    }
197                    Some((
198                        crate::providers::config::ProviderConfigId::bare(provider),
199                        server_id.to_string(),
200                    ))
201                }
202                [provider, label, server_id] => {
203                    let label = label.trim();
204                    let provider = provider.trim();
205                    let server_id = server_id.trim();
206                    // Empty provider or empty server_id: malformed marker.
207                    // Returning None makes the host appear unowned, which is
208                    // safer than guessing an interpretation that could let
209                    // sync claim or delete the wrong host.
210                    if provider.is_empty() || server_id.is_empty() {
211                        return None;
212                    }
213                    if crate::providers::config::validate_label(label).is_ok() {
214                        // Well-formed 3-segment labeled marker.
215                        Some((
216                            crate::providers::config::ProviderConfigId::labeled(provider, label),
217                            server_id.to_string(),
218                        ))
219                    } else if label.is_empty() {
220                        // Empty middle (e.g. `aws::123`) cannot be either a
221                        // valid labeled marker or a legacy 2-segment one.
222                        // Treat as malformed.
223                        None
224                    } else {
225                        // Middle has content but isn't a valid label
226                        // (e.g. `azure:RES:i-12345`): legacy interpretation
227                        // with the embedded colon kept in server_id.
228                        Some((
229                            crate::providers::config::ProviderConfigId::bare(provider),
230                            format!("{}:{}", label, server_id),
231                        ))
232                    }
233                }
234                _ => None,
235            };
236        }
237        None
238    }
239
240    /// Set provider on a host block using a full ProviderConfigId.
241    /// Emits 2-segment marker if `id.label` is None, 3-segment if Some.
242    pub fn set_provider_id(
243        &mut self,
244        id: &crate::providers::config::ProviderConfigId,
245        server_id: &str,
246    ) {
247        // Sanitise the server_id before interpolation — a provider API
248        // returning `123\n  ProxyJump attacker` would otherwise inject a
249        // ProxyJump directive into the user's config.
250        let server_id = Self::sanitize_raw_line_value(server_id);
251        let indent = self.detect_indent();
252        self.directives.retain(|d| {
253            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
254        });
255        let pos = self.content_end();
256        self.directives.insert(
257            pos,
258            Directive {
259                key: String::new(),
260                value: String::new(),
261                raw_line: format!("{}# purple:provider {}:{}", indent, id, server_id),
262                is_non_directive: true,
263            },
264        );
265    }
266
267    /// Extract askpass source from purple:askpass comment in directives.
268    pub fn askpass(&self) -> Option<String> {
269        for d in &self.directives {
270            if d.is_non_directive {
271                let trimmed = d.raw_line.trim();
272                if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
273                    let val = rest.trim();
274                    if !val.is_empty() {
275                        return Some(val.to_string());
276                    }
277                }
278            }
279        }
280        None
281    }
282
283    /// Extract vault-ssh role from purple:vault-ssh comment.
284    pub fn vault_ssh(&self) -> Option<String> {
285        for d in &self.directives {
286            if d.is_non_directive {
287                let trimmed = d.raw_line.trim();
288                if let Some(rest) = trimmed.strip_prefix("# purple:vault-ssh ") {
289                    let val = rest.trim();
290                    if !val.is_empty() && crate::vault_ssh::is_valid_role(val) {
291                        return Some(val.to_string());
292                    }
293                }
294            }
295        }
296        None
297    }
298
299    /// Set vault-ssh role. Replaces existing comment or adds one. Empty string removes.
300    pub fn set_vault_ssh(&mut self, role: &str) {
301        let role = Self::sanitize_raw_line_value(role);
302        let indent = self.detect_indent();
303        self.directives.retain(|d| {
304            !(d.is_non_directive && {
305                let t = d.raw_line.trim();
306                t == "# purple:vault-ssh" || t.starts_with("# purple:vault-ssh ")
307            })
308        });
309        if !role.is_empty() {
310            let pos = self.content_end();
311            self.directives.insert(
312                pos,
313                Directive {
314                    key: String::new(),
315                    value: String::new(),
316                    raw_line: format!("{}# purple:vault-ssh {}", indent, role),
317                    is_non_directive: true,
318                },
319            );
320        }
321    }
322
323    /// Extract the Vault SSH endpoint from a `# purple:vault-addr` comment.
324    /// Returns None when the comment is absent, blank or contains an invalid
325    /// URL value. Validation is intentionally minimal: we reject empty,
326    /// whitespace-containing and control-character values but otherwise let
327    /// the Vault CLI surface its own error on typos.
328    pub fn vault_addr(&self) -> Option<String> {
329        for d in &self.directives {
330            if d.is_non_directive {
331                let trimmed = d.raw_line.trim();
332                if let Some(rest) = trimmed.strip_prefix("# purple:vault-addr ") {
333                    let val = rest.trim();
334                    if !val.is_empty() && crate::vault_ssh::is_valid_vault_addr(val) {
335                        return Some(val.to_string());
336                    }
337                }
338            }
339        }
340        None
341    }
342
343    /// Set vault-addr endpoint. Replaces existing comment or adds one. Empty
344    /// string removes. Caller is expected to have validated the URL upstream
345    /// (e.g. via `is_valid_vault_addr`) — this function does not re-validate.
346    pub fn set_vault_addr(&mut self, url: &str) {
347        let url = Self::sanitize_raw_line_value(url);
348        let indent = self.detect_indent();
349        self.directives.retain(|d| {
350            !(d.is_non_directive && {
351                let t = d.raw_line.trim();
352                t == "# purple:vault-addr" || t.starts_with("# purple:vault-addr ")
353            })
354        });
355        if !url.is_empty() {
356            let pos = self.content_end();
357            self.directives.insert(
358                pos,
359                Directive {
360                    key: String::new(),
361                    value: String::new(),
362                    raw_line: format!("{}# purple:vault-addr {}", indent, url),
363                    is_non_directive: true,
364                },
365            );
366        }
367    }
368
369    /// Set askpass source on a host block. Replaces existing purple:askpass comment or adds one.
370    /// Pass an empty string to remove the comment.
371    pub fn set_askpass(&mut self, source: &str) {
372        let source = Self::sanitize_raw_line_value(source);
373        let indent = self.detect_indent();
374        self.directives.retain(|d| {
375            !(d.is_non_directive && {
376                let t = d.raw_line.trim();
377                t == "# purple:askpass" || t.starts_with("# purple:askpass ")
378            })
379        });
380        if !source.is_empty() {
381            let pos = self.content_end();
382            self.directives.insert(
383                pos,
384                Directive {
385                    key: String::new(),
386                    value: String::new(),
387                    raw_line: format!("{}# purple:askpass {}", indent, source),
388                    is_non_directive: true,
389                },
390            );
391        }
392    }
393
394    /// Extract provider metadata from purple:meta comment in directives.
395    /// Format: `# purple:meta key=value,key=value`
396    pub fn meta(&self) -> Vec<(String, String)> {
397        for d in &self.directives {
398            if d.is_non_directive {
399                let trimmed = d.raw_line.trim();
400                if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
401                    return rest
402                        .split(',')
403                        .filter_map(|pair| {
404                            let (k, v) = pair.split_once('=')?;
405                            let k = k.trim();
406                            let v = v.trim();
407                            if k.is_empty() {
408                                None
409                            } else {
410                                Some((k.to_string(), v.to_string()))
411                            }
412                        })
413                        .collect();
414                }
415            }
416        }
417        Vec::new()
418    }
419
420    /// Set provider metadata on a host block. Replaces existing purple:meta comment or adds one.
421    /// Pass an empty slice to remove the comment.
422    pub fn set_meta(&mut self, meta: &[(String, String)]) {
423        let indent = self.detect_indent();
424        self.directives.retain(|d| {
425            !(d.is_non_directive && {
426                let t = d.raw_line.trim();
427                t == "# purple:meta" || t.starts_with("# purple:meta ")
428            })
429        });
430        if !meta.is_empty() {
431            let encoded: Vec<String> = meta
432                .iter()
433                .map(|(k, v)| {
434                    let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
435                    let clean_v = Self::sanitize_tag(&v.replace(',', ""));
436                    format!("{}={}", clean_k, clean_v)
437                })
438                .collect();
439            let pos = self.content_end();
440            self.directives.insert(
441                pos,
442                Directive {
443                    key: String::new(),
444                    value: String::new(),
445                    raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
446                    is_non_directive: true,
447                },
448            );
449        }
450    }
451
452    /// Extract stale timestamp from purple:stale comment in directives.
453    /// Returns `None` if absent or malformed.
454    pub fn stale(&self) -> Option<u64> {
455        for d in &self.directives {
456            if d.is_non_directive {
457                let trimmed = d.raw_line.trim();
458                if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
459                    return rest.trim().parse::<u64>().ok();
460                }
461            }
462        }
463        None
464    }
465
466    /// Mark a host block as stale with a unix timestamp.
467    /// Replaces existing purple:stale comment or adds one.
468    pub fn set_stale(&mut self, timestamp: u64) {
469        let indent = self.detect_indent();
470        self.clear_stale();
471        let pos = self.content_end();
472        self.directives.insert(
473            pos,
474            Directive {
475                key: String::new(),
476                value: String::new(),
477                raw_line: format!("{}# purple:stale {}", indent, timestamp),
478                is_non_directive: true,
479            },
480        );
481    }
482
483    /// Remove stale marking from a host block.
484    pub fn clear_stale(&mut self) {
485        self.directives.retain(|d| {
486            !(d.is_non_directive && {
487                let t = d.raw_line.trim();
488                t == "# purple:stale" || t.starts_with("# purple:stale ")
489            })
490        });
491    }
492
493    /// Sanitize a tag value: strip control characters, commas (delimiter),
494    /// and Unicode format/bidi override characters. Truncate to 128 chars.
495    pub(super) fn sanitize_tag(tag: &str) -> String {
496        tag.chars()
497            .filter(|c| {
498                !c.is_control()
499                    && *c != ','
500                    && !('\u{200B}'..='\u{200F}').contains(c) // zero-width, bidi marks
501                    && !('\u{202A}'..='\u{202E}').contains(c) // bidi embedding/override
502                    && !('\u{2066}'..='\u{2069}').contains(c) // bidi isolate
503                    && *c != '\u{FEFF}' // BOM/zero-width no-break space
504            })
505            .take(128)
506            .collect()
507    }
508
509    /// Strip line-breaking characters from any value that gets interpolated
510    /// into a `raw_line`. A `\n`, `\r` or `\0` in a provider-supplied
511    /// `server_id`, a user-typed askpass URI, or a Vault role would otherwise
512    /// split one line into multiple SSH config directives (directive
513    /// injection). All setters that format user-controlled bytes into
514    /// `raw_line` must route the value through this helper first.
515    ///
516    /// Returns the input unchanged when no offending byte is present so the
517    /// common case incurs no allocation. Logs a warning when a substitution
518    /// happens. The substitution is silent for the user-facing flow but
519    /// surfaces in the log file for forensics.
520    pub(super) fn sanitize_raw_line_value(s: &str) -> std::borrow::Cow<'_, str> {
521        if !s.contains(['\n', '\r', '\0']) {
522            return std::borrow::Cow::Borrowed(s);
523        }
524        log::warn!(
525            "[purple] sanitized line-breaking characters from value before writing to ssh_config"
526        );
527        std::borrow::Cow::Owned(s.replace(['\n', '\r', '\0'], " "))
528    }
529
530    /// Render a single-argument directive value for interpolation into a
531    /// `raw_line`. OpenSSH carries an argument containing spaces by wrapping
532    /// it in double quotes (ssh_config(5): "Arguments may optionally be
533    /// enclosed in double quotes"). Without quoting, `~/my key/id` writes as
534    /// three tokens and `~/id #note` loses its tail to inline-comment
535    /// stripping on the next parse. A value that itself contains a `"` is also
536    /// quoted, with embedded `\` and `"` backslash-escaped, so a single-token
537    /// value containing both whitespace and a quote round-trips faithfully
538    /// instead of being emitted unquoted and split by OpenSSH.
539    /// `parser::strip_surrounding_quotes` is the inverse. Only for single-token
540    /// directives (HostName, User, IdentityFile, ProxyJump, CertificateFile);
541    /// multi-arg directives like LocalForward must not be routed through here.
542    pub(super) fn render_value(value: &str) -> std::borrow::Cow<'_, str> {
543        if value.chars().any(char::is_whitespace) || value.contains('"') {
544            let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
545            std::borrow::Cow::Owned(format!("\"{escaped}\""))
546        } else {
547            std::borrow::Cow::Borrowed(value)
548        }
549    }
550
551    /// Set user tags on a host block. Replaces existing purple:tags comment or adds one.
552    pub fn set_tags(&mut self, tags: &[String]) {
553        let indent = self.detect_indent();
554        self.directives.retain(|d| {
555            !(d.is_non_directive && {
556                let t = d.raw_line.trim();
557                t == "# purple:tags" || t.starts_with("# purple:tags ")
558            })
559        });
560        let sanitized: Vec<String> = tags
561            .iter()
562            .map(|t| Self::sanitize_tag(t))
563            .filter(|t| !t.is_empty())
564            .collect();
565        if !sanitized.is_empty() {
566            let pos = self.content_end();
567            self.directives.insert(
568                pos,
569                Directive {
570                    key: String::new(),
571                    value: String::new(),
572                    raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
573                    is_non_directive: true,
574                },
575            );
576        }
577    }
578
579    /// Set provider-synced tags. Replaces existing purple:provider_tags comment.
580    /// Always writes the comment (even when empty) as a migration sentinel.
581    pub fn set_provider_tags(&mut self, tags: &[String]) {
582        let indent = self.detect_indent();
583        self.directives.retain(|d| {
584            !(d.is_non_directive && {
585                let t = d.raw_line.trim();
586                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
587            })
588        });
589        let sanitized: Vec<String> = tags
590            .iter()
591            .map(|t| Self::sanitize_tag(t))
592            .filter(|t| !t.is_empty())
593            .collect();
594        let raw = if sanitized.is_empty() {
595            format!("{}# purple:provider_tags", indent)
596        } else {
597            format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
598        };
599        let pos = self.content_end();
600        self.directives.insert(
601            pos,
602            Directive {
603                key: String::new(),
604                value: String::new(),
605                raw_line: raw,
606                is_non_directive: true,
607            },
608        );
609    }
610
611    /// Set the last-synced provider username marker. Empty value removes it.
612    pub fn set_provider_user(&mut self, user: &str) {
613        self.set_single_value_marker("# purple:provider_user", user);
614    }
615
616    /// Set the last-synced provider identity file marker. Empty value removes it.
617    pub fn set_provider_key(&mut self, key: &str) {
618        self.set_single_value_marker("# purple:provider_key", key);
619    }
620
621    /// Replace (or, when `value` is empty, remove) a single-value
622    /// `# purple:<name> <value>` comment. Routes the value through
623    /// `sanitize_raw_line_value` so a stray newline cannot inject directives.
624    fn set_single_value_marker(&mut self, prefix: &str, value: &str) {
625        let indent = self.detect_indent();
626        let with_space = format!("{} ", prefix);
627        self.directives.retain(|d| {
628            !(d.is_non_directive && {
629                let t = d.raw_line.trim();
630                t == prefix || t.starts_with(with_space.as_str())
631            })
632        });
633        let value = Self::sanitize_raw_line_value(value.trim());
634        if !value.is_empty() {
635            let pos = self.content_end();
636            self.directives.insert(
637                pos,
638                Directive {
639                    key: String::new(),
640                    value: String::new(),
641                    raw_line: format!("{}{} {}", indent, prefix, value),
642                    is_non_directive: true,
643                },
644            );
645        }
646    }
647
648    /// Extract a convenience HostEntry view from this block.
649    ///
650    /// Matches OpenSSH `ssh_config(5)`: "Unless noted otherwise, for each
651    /// parameter, the first obtained value will be used." Duplicate
652    /// HostName/User/Port/ProxyJump entries keep the FIRST value seen.
653    pub fn to_host_entry(&self) -> HostEntry {
654        let mut entry = HostEntry {
655            alias: self.host_pattern.clone(),
656            port: 22,
657            ..Default::default()
658        };
659        let mut port_seen = false;
660        let mut proxy_command_seen = false;
661        for d in &self.directives {
662            if d.is_non_directive {
663                continue;
664            }
665            if d.key.eq_ignore_ascii_case("hostname") {
666                if entry.hostname.is_empty() {
667                    entry.hostname = d.value.clone();
668                }
669            } else if d.key.eq_ignore_ascii_case("user") {
670                if entry.user.is_empty() {
671                    entry.user = d.value.clone();
672                }
673            } else if d.key.eq_ignore_ascii_case("port") {
674                if !port_seen {
675                    entry.port = d.value.parse().unwrap_or(22);
676                    port_seen = true;
677                }
678            } else if d.key.eq_ignore_ascii_case("identityfile") {
679                if entry.identity_file.is_empty() {
680                    entry.identity_file = d.value.clone();
681                }
682            } else if d.key.eq_ignore_ascii_case("proxyjump") {
683                if entry.proxy_jump.is_empty() {
684                    entry.proxy_jump = d.value.clone();
685                }
686            } else if d.key.eq_ignore_ascii_case("proxycommand") {
687                if !proxy_command_seen {
688                    entry.has_proxy_command = !d.value.trim().eq_ignore_ascii_case("none");
689                    proxy_command_seen = true;
690                }
691            } else if d.key.eq_ignore_ascii_case("certificatefile")
692                && entry.certificate_file.is_empty()
693            {
694                entry.certificate_file = d.value.clone();
695            }
696        }
697        entry.tags = self.tags();
698        entry.provider_tags = self.provider_tags();
699        entry.has_provider_tags = self.has_provider_tags_comment();
700        if let Some((id, _)) = self.provider_id() {
701            entry.provider = Some(id.provider);
702            entry.provider_label = id.label;
703        }
704        entry.tunnel_count = self.tunnel_count();
705        entry.askpass = self.askpass();
706        entry.vault_ssh = self.vault_ssh();
707        entry.vault_addr = self.vault_addr();
708        entry.provider_meta = self.meta();
709        entry.stale = self.stale();
710        entry.provider_user = self.provider_user();
711        entry.provider_key = self.provider_key();
712        entry
713    }
714
715    /// Extract a convenience PatternEntry view from this block.
716    pub fn to_pattern_entry(&self) -> PatternEntry {
717        let mut entry = PatternEntry {
718            pattern: self.host_pattern.clone(),
719            hostname: String::new(),
720            user: String::new(),
721            port: 22,
722            identity_file: String::new(),
723            proxy_jump: String::new(),
724            tags: self.tags(),
725            askpass: self.askpass(),
726            source_file: None,
727            directives: Vec::new(),
728        };
729        let mut port_seen = false;
730        for d in &self.directives {
731            if d.is_non_directive {
732                continue;
733            }
734            match d.key.to_ascii_lowercase().as_str() {
735                "hostname" if entry.hostname.is_empty() => entry.hostname = d.value.clone(),
736                "user" if entry.user.is_empty() => entry.user = d.value.clone(),
737                "port" if !port_seen => {
738                    entry.port = d.value.parse().unwrap_or(22);
739                    port_seen = true;
740                }
741                "identityfile" if entry.identity_file.is_empty() => {
742                    entry.identity_file = d.value.clone();
743                }
744                "proxyjump" if entry.proxy_jump.is_empty() => entry.proxy_jump = d.value.clone(),
745                _ => {}
746            }
747            entry.directives.push((d.key.clone(), d.value.clone()));
748        }
749        entry
750    }
751
752    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
753    pub fn tunnel_count(&self) -> u16 {
754        let count = self
755            .directives
756            .iter()
757            .filter(|d| {
758                !d.is_non_directive
759                    && (d.key.eq_ignore_ascii_case("localforward")
760                        || d.key.eq_ignore_ascii_case("remoteforward")
761                        || d.key.eq_ignore_ascii_case("dynamicforward"))
762            })
763            .count();
764        count.min(u16::MAX as usize) as u16
765    }
766
767    /// Check if this block has any tunnel forwarding directives.
768    #[allow(dead_code)]
769    pub fn has_tunnels(&self) -> bool {
770        self.directives.iter().any(|d| {
771            !d.is_non_directive
772                && (d.key.eq_ignore_ascii_case("localforward")
773                    || d.key.eq_ignore_ascii_case("remoteforward")
774                    || d.key.eq_ignore_ascii_case("dynamicforward"))
775        })
776    }
777
778    /// Extract tunnel rules from forwarding directives.
779    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
780        self.directives
781            .iter()
782            .filter(|d| !d.is_non_directive)
783            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
784            .collect()
785    }
786}