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        let indent = self.detect_indent();
216        self.directives.retain(|d| {
217            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
218        });
219        let pos = self.content_end();
220        self.directives.insert(
221            pos,
222            Directive {
223                key: String::new(),
224                value: String::new(),
225                raw_line: format!("{}# purple:provider {}:{}", indent, id, server_id),
226                is_non_directive: true,
227            },
228        );
229    }
230
231    /// Extract askpass source from purple:askpass comment in directives.
232    pub fn askpass(&self) -> Option<String> {
233        for d in &self.directives {
234            if d.is_non_directive {
235                let trimmed = d.raw_line.trim();
236                if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
237                    let val = rest.trim();
238                    if !val.is_empty() {
239                        return Some(val.to_string());
240                    }
241                }
242            }
243        }
244        None
245    }
246
247    /// Extract vault-ssh role from purple:vault-ssh comment.
248    pub fn vault_ssh(&self) -> Option<String> {
249        for d in &self.directives {
250            if d.is_non_directive {
251                let trimmed = d.raw_line.trim();
252                if let Some(rest) = trimmed.strip_prefix("# purple:vault-ssh ") {
253                    let val = rest.trim();
254                    if !val.is_empty() && crate::vault_ssh::is_valid_role(val) {
255                        return Some(val.to_string());
256                    }
257                }
258            }
259        }
260        None
261    }
262
263    /// Set vault-ssh role. Replaces existing comment or adds one. Empty string removes.
264    pub fn set_vault_ssh(&mut self, role: &str) {
265        let indent = self.detect_indent();
266        self.directives.retain(|d| {
267            !(d.is_non_directive && {
268                let t = d.raw_line.trim();
269                t == "# purple:vault-ssh" || t.starts_with("# purple:vault-ssh ")
270            })
271        });
272        if !role.is_empty() {
273            let pos = self.content_end();
274            self.directives.insert(
275                pos,
276                Directive {
277                    key: String::new(),
278                    value: String::new(),
279                    raw_line: format!("{}# purple:vault-ssh {}", indent, role),
280                    is_non_directive: true,
281                },
282            );
283        }
284    }
285
286    /// Extract the Vault SSH endpoint from a `# purple:vault-addr` comment.
287    /// Returns None when the comment is absent, blank or contains an invalid
288    /// URL value. Validation is intentionally minimal: we reject empty,
289    /// whitespace-containing and control-character values but otherwise let
290    /// the Vault CLI surface its own error on typos.
291    pub fn vault_addr(&self) -> Option<String> {
292        for d in &self.directives {
293            if d.is_non_directive {
294                let trimmed = d.raw_line.trim();
295                if let Some(rest) = trimmed.strip_prefix("# purple:vault-addr ") {
296                    let val = rest.trim();
297                    if !val.is_empty() && crate::vault_ssh::is_valid_vault_addr(val) {
298                        return Some(val.to_string());
299                    }
300                }
301            }
302        }
303        None
304    }
305
306    /// Set vault-addr endpoint. Replaces existing comment or adds one. Empty
307    /// string removes. Caller is expected to have validated the URL upstream
308    /// (e.g. via `is_valid_vault_addr`) — this function does not re-validate.
309    pub fn set_vault_addr(&mut self, url: &str) {
310        let indent = self.detect_indent();
311        self.directives.retain(|d| {
312            !(d.is_non_directive && {
313                let t = d.raw_line.trim();
314                t == "# purple:vault-addr" || t.starts_with("# purple:vault-addr ")
315            })
316        });
317        if !url.is_empty() {
318            let pos = self.content_end();
319            self.directives.insert(
320                pos,
321                Directive {
322                    key: String::new(),
323                    value: String::new(),
324                    raw_line: format!("{}# purple:vault-addr {}", indent, url),
325                    is_non_directive: true,
326                },
327            );
328        }
329    }
330
331    /// Set askpass source on a host block. Replaces existing purple:askpass comment or adds one.
332    /// Pass an empty string to remove the comment.
333    pub fn set_askpass(&mut self, source: &str) {
334        let indent = self.detect_indent();
335        self.directives.retain(|d| {
336            !(d.is_non_directive && {
337                let t = d.raw_line.trim();
338                t == "# purple:askpass" || t.starts_with("# purple:askpass ")
339            })
340        });
341        if !source.is_empty() {
342            let pos = self.content_end();
343            self.directives.insert(
344                pos,
345                Directive {
346                    key: String::new(),
347                    value: String::new(),
348                    raw_line: format!("{}# purple:askpass {}", indent, source),
349                    is_non_directive: true,
350                },
351            );
352        }
353    }
354
355    /// Extract provider metadata from purple:meta comment in directives.
356    /// Format: `# purple:meta key=value,key=value`
357    pub fn meta(&self) -> Vec<(String, String)> {
358        for d in &self.directives {
359            if d.is_non_directive {
360                let trimmed = d.raw_line.trim();
361                if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
362                    return rest
363                        .split(',')
364                        .filter_map(|pair| {
365                            let (k, v) = pair.split_once('=')?;
366                            let k = k.trim();
367                            let v = v.trim();
368                            if k.is_empty() {
369                                None
370                            } else {
371                                Some((k.to_string(), v.to_string()))
372                            }
373                        })
374                        .collect();
375                }
376            }
377        }
378        Vec::new()
379    }
380
381    /// Set provider metadata on a host block. Replaces existing purple:meta comment or adds one.
382    /// Pass an empty slice to remove the comment.
383    pub fn set_meta(&mut self, meta: &[(String, String)]) {
384        let indent = self.detect_indent();
385        self.directives.retain(|d| {
386            !(d.is_non_directive && {
387                let t = d.raw_line.trim();
388                t == "# purple:meta" || t.starts_with("# purple:meta ")
389            })
390        });
391        if !meta.is_empty() {
392            let encoded: Vec<String> = meta
393                .iter()
394                .map(|(k, v)| {
395                    let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
396                    let clean_v = Self::sanitize_tag(&v.replace(',', ""));
397                    format!("{}={}", clean_k, clean_v)
398                })
399                .collect();
400            let pos = self.content_end();
401            self.directives.insert(
402                pos,
403                Directive {
404                    key: String::new(),
405                    value: String::new(),
406                    raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
407                    is_non_directive: true,
408                },
409            );
410        }
411    }
412
413    /// Extract stale timestamp from purple:stale comment in directives.
414    /// Returns `None` if absent or malformed.
415    pub fn stale(&self) -> Option<u64> {
416        for d in &self.directives {
417            if d.is_non_directive {
418                let trimmed = d.raw_line.trim();
419                if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
420                    return rest.trim().parse::<u64>().ok();
421                }
422            }
423        }
424        None
425    }
426
427    /// Mark a host block as stale with a unix timestamp.
428    /// Replaces existing purple:stale comment or adds one.
429    pub fn set_stale(&mut self, timestamp: u64) {
430        let indent = self.detect_indent();
431        self.clear_stale();
432        let pos = self.content_end();
433        self.directives.insert(
434            pos,
435            Directive {
436                key: String::new(),
437                value: String::new(),
438                raw_line: format!("{}# purple:stale {}", indent, timestamp),
439                is_non_directive: true,
440            },
441        );
442    }
443
444    /// Remove stale marking from a host block.
445    pub fn clear_stale(&mut self) {
446        self.directives.retain(|d| {
447            !(d.is_non_directive && {
448                let t = d.raw_line.trim();
449                t == "# purple:stale" || t.starts_with("# purple:stale ")
450            })
451        });
452    }
453
454    /// Sanitize a tag value: strip control characters, commas (delimiter),
455    /// and Unicode format/bidi override characters. Truncate to 128 chars.
456    pub(super) fn sanitize_tag(tag: &str) -> String {
457        tag.chars()
458            .filter(|c| {
459                !c.is_control()
460                    && *c != ','
461                    && !('\u{200B}'..='\u{200F}').contains(c) // zero-width, bidi marks
462                    && !('\u{202A}'..='\u{202E}').contains(c) // bidi embedding/override
463                    && !('\u{2066}'..='\u{2069}').contains(c) // bidi isolate
464                    && *c != '\u{FEFF}' // BOM/zero-width no-break space
465            })
466            .take(128)
467            .collect()
468    }
469
470    /// Set user tags on a host block. Replaces existing purple:tags comment or adds one.
471    pub fn set_tags(&mut self, tags: &[String]) {
472        let indent = self.detect_indent();
473        self.directives.retain(|d| {
474            !(d.is_non_directive && {
475                let t = d.raw_line.trim();
476                t == "# purple:tags" || t.starts_with("# purple:tags ")
477            })
478        });
479        let sanitized: Vec<String> = tags
480            .iter()
481            .map(|t| Self::sanitize_tag(t))
482            .filter(|t| !t.is_empty())
483            .collect();
484        if !sanitized.is_empty() {
485            let pos = self.content_end();
486            self.directives.insert(
487                pos,
488                Directive {
489                    key: String::new(),
490                    value: String::new(),
491                    raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
492                    is_non_directive: true,
493                },
494            );
495        }
496    }
497
498    /// Set provider-synced tags. Replaces existing purple:provider_tags comment.
499    /// Always writes the comment (even when empty) as a migration sentinel.
500    pub fn set_provider_tags(&mut self, tags: &[String]) {
501        let indent = self.detect_indent();
502        self.directives.retain(|d| {
503            !(d.is_non_directive && {
504                let t = d.raw_line.trim();
505                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
506            })
507        });
508        let sanitized: Vec<String> = tags
509            .iter()
510            .map(|t| Self::sanitize_tag(t))
511            .filter(|t| !t.is_empty())
512            .collect();
513        let raw = if sanitized.is_empty() {
514            format!("{}# purple:provider_tags", indent)
515        } else {
516            format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
517        };
518        let pos = self.content_end();
519        self.directives.insert(
520            pos,
521            Directive {
522                key: String::new(),
523                value: String::new(),
524                raw_line: raw,
525                is_non_directive: true,
526            },
527        );
528    }
529
530    /// Extract a convenience HostEntry view from this block.
531    pub fn to_host_entry(&self) -> HostEntry {
532        let mut entry = HostEntry {
533            alias: self.host_pattern.clone(),
534            port: 22,
535            ..Default::default()
536        };
537        for d in &self.directives {
538            if d.is_non_directive {
539                continue;
540            }
541            if d.key.eq_ignore_ascii_case("hostname") {
542                entry.hostname = d.value.clone();
543            } else if d.key.eq_ignore_ascii_case("user") {
544                entry.user = d.value.clone();
545            } else if d.key.eq_ignore_ascii_case("port") {
546                entry.port = d.value.parse().unwrap_or(22);
547            } else if d.key.eq_ignore_ascii_case("identityfile") {
548                if entry.identity_file.is_empty() {
549                    entry.identity_file = d.value.clone();
550                }
551            } else if d.key.eq_ignore_ascii_case("proxyjump") {
552                entry.proxy_jump = d.value.clone();
553            } else if d.key.eq_ignore_ascii_case("certificatefile")
554                && entry.certificate_file.is_empty()
555            {
556                entry.certificate_file = d.value.clone();
557            }
558        }
559        entry.tags = self.tags();
560        entry.provider_tags = self.provider_tags();
561        entry.has_provider_tags = self.has_provider_tags_comment();
562        if let Some((id, _)) = self.provider_id() {
563            entry.provider = Some(id.provider);
564            entry.provider_label = id.label;
565        }
566        entry.tunnel_count = self.tunnel_count();
567        entry.askpass = self.askpass();
568        entry.vault_ssh = self.vault_ssh();
569        entry.vault_addr = self.vault_addr();
570        entry.provider_meta = self.meta();
571        entry.stale = self.stale();
572        entry
573    }
574
575    /// Extract a convenience PatternEntry view from this block.
576    pub fn to_pattern_entry(&self) -> PatternEntry {
577        let mut entry = PatternEntry {
578            pattern: self.host_pattern.clone(),
579            hostname: String::new(),
580            user: String::new(),
581            port: 22,
582            identity_file: String::new(),
583            proxy_jump: String::new(),
584            tags: self.tags(),
585            askpass: self.askpass(),
586            source_file: None,
587            directives: Vec::new(),
588        };
589        for d in &self.directives {
590            if d.is_non_directive {
591                continue;
592            }
593            match d.key.to_ascii_lowercase().as_str() {
594                "hostname" => entry.hostname = d.value.clone(),
595                "user" => entry.user = d.value.clone(),
596                "port" => entry.port = d.value.parse().unwrap_or(22),
597                "identityfile" if entry.identity_file.is_empty() => {
598                    entry.identity_file = d.value.clone();
599                }
600                "proxyjump" => entry.proxy_jump = d.value.clone(),
601                _ => {}
602            }
603            entry.directives.push((d.key.clone(), d.value.clone()));
604        }
605        entry
606    }
607
608    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
609    pub fn tunnel_count(&self) -> u16 {
610        let count = self
611            .directives
612            .iter()
613            .filter(|d| {
614                !d.is_non_directive
615                    && (d.key.eq_ignore_ascii_case("localforward")
616                        || d.key.eq_ignore_ascii_case("remoteforward")
617                        || d.key.eq_ignore_ascii_case("dynamicforward"))
618            })
619            .count();
620        count.min(u16::MAX as usize) as u16
621    }
622
623    /// Check if this block has any tunnel forwarding directives.
624    #[allow(dead_code)]
625    pub fn has_tunnels(&self) -> bool {
626        self.directives.iter().any(|d| {
627            !d.is_non_directive
628                && (d.key.eq_ignore_ascii_case("localforward")
629                    || d.key.eq_ignore_ascii_case("remoteforward")
630                    || d.key.eq_ignore_ascii_case("dynamicforward"))
631        })
632    }
633
634    /// Extract tunnel rules from forwarding directives.
635    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
636        self.directives
637            .iter()
638            .filter(|d| !d.is_non_directive)
639            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
640            .collect()
641    }
642}