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