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    /// Set user tags on a host block. Replaces existing purple:tags comment or adds one.
499    pub fn set_tags(&mut self, tags: &[String]) {
500        let indent = self.detect_indent();
501        self.directives.retain(|d| {
502            !(d.is_non_directive && {
503                let t = d.raw_line.trim();
504                t == "# purple:tags" || t.starts_with("# purple:tags ")
505            })
506        });
507        let sanitized: Vec<String> = tags
508            .iter()
509            .map(|t| Self::sanitize_tag(t))
510            .filter(|t| !t.is_empty())
511            .collect();
512        if !sanitized.is_empty() {
513            let pos = self.content_end();
514            self.directives.insert(
515                pos,
516                Directive {
517                    key: String::new(),
518                    value: String::new(),
519                    raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
520                    is_non_directive: true,
521                },
522            );
523        }
524    }
525
526    /// Set provider-synced tags. Replaces existing purple:provider_tags comment.
527    /// Always writes the comment (even when empty) as a migration sentinel.
528    pub fn set_provider_tags(&mut self, tags: &[String]) {
529        let indent = self.detect_indent();
530        self.directives.retain(|d| {
531            !(d.is_non_directive && {
532                let t = d.raw_line.trim();
533                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
534            })
535        });
536        let sanitized: Vec<String> = tags
537            .iter()
538            .map(|t| Self::sanitize_tag(t))
539            .filter(|t| !t.is_empty())
540            .collect();
541        let raw = if sanitized.is_empty() {
542            format!("{}# purple:provider_tags", indent)
543        } else {
544            format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
545        };
546        let pos = self.content_end();
547        self.directives.insert(
548            pos,
549            Directive {
550                key: String::new(),
551                value: String::new(),
552                raw_line: raw,
553                is_non_directive: true,
554            },
555        );
556    }
557
558    /// Extract a convenience HostEntry view from this block.
559    pub fn to_host_entry(&self) -> HostEntry {
560        let mut entry = HostEntry {
561            alias: self.host_pattern.clone(),
562            port: 22,
563            ..Default::default()
564        };
565        for d in &self.directives {
566            if d.is_non_directive {
567                continue;
568            }
569            if d.key.eq_ignore_ascii_case("hostname") {
570                entry.hostname = d.value.clone();
571            } else if d.key.eq_ignore_ascii_case("user") {
572                entry.user = d.value.clone();
573            } else if d.key.eq_ignore_ascii_case("port") {
574                entry.port = d.value.parse().unwrap_or(22);
575            } else if d.key.eq_ignore_ascii_case("identityfile") {
576                if entry.identity_file.is_empty() {
577                    entry.identity_file = d.value.clone();
578                }
579            } else if d.key.eq_ignore_ascii_case("proxyjump") {
580                entry.proxy_jump = d.value.clone();
581            } else if d.key.eq_ignore_ascii_case("certificatefile")
582                && entry.certificate_file.is_empty()
583            {
584                entry.certificate_file = d.value.clone();
585            }
586        }
587        entry.tags = self.tags();
588        entry.provider_tags = self.provider_tags();
589        entry.has_provider_tags = self.has_provider_tags_comment();
590        if let Some((id, _)) = self.provider_id() {
591            entry.provider = Some(id.provider);
592            entry.provider_label = id.label;
593        }
594        entry.tunnel_count = self.tunnel_count();
595        entry.askpass = self.askpass();
596        entry.vault_ssh = self.vault_ssh();
597        entry.vault_addr = self.vault_addr();
598        entry.provider_meta = self.meta();
599        entry.stale = self.stale();
600        entry
601    }
602
603    /// Extract a convenience PatternEntry view from this block.
604    pub fn to_pattern_entry(&self) -> PatternEntry {
605        let mut entry = PatternEntry {
606            pattern: self.host_pattern.clone(),
607            hostname: String::new(),
608            user: String::new(),
609            port: 22,
610            identity_file: String::new(),
611            proxy_jump: String::new(),
612            tags: self.tags(),
613            askpass: self.askpass(),
614            source_file: None,
615            directives: Vec::new(),
616        };
617        for d in &self.directives {
618            if d.is_non_directive {
619                continue;
620            }
621            match d.key.to_ascii_lowercase().as_str() {
622                "hostname" => entry.hostname = d.value.clone(),
623                "user" => entry.user = d.value.clone(),
624                "port" => entry.port = d.value.parse().unwrap_or(22),
625                "identityfile" if entry.identity_file.is_empty() => {
626                    entry.identity_file = d.value.clone();
627                }
628                "proxyjump" => entry.proxy_jump = d.value.clone(),
629                _ => {}
630            }
631            entry.directives.push((d.key.clone(), d.value.clone()));
632        }
633        entry
634    }
635
636    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
637    pub fn tunnel_count(&self) -> u16 {
638        let count = self
639            .directives
640            .iter()
641            .filter(|d| {
642                !d.is_non_directive
643                    && (d.key.eq_ignore_ascii_case("localforward")
644                        || d.key.eq_ignore_ascii_case("remoteforward")
645                        || d.key.eq_ignore_ascii_case("dynamicforward"))
646            })
647            .count();
648        count.min(u16::MAX as usize) as u16
649    }
650
651    /// Check if this block has any tunnel forwarding directives.
652    #[allow(dead_code)]
653    pub fn has_tunnels(&self) -> bool {
654        self.directives.iter().any(|d| {
655            !d.is_non_directive
656                && (d.key.eq_ignore_ascii_case("localforward")
657                    || d.key.eq_ignore_ascii_case("remoteforward")
658                    || d.key.eq_ignore_ascii_case("dynamicforward"))
659        })
660    }
661
662    /// Extract tunnel rules from forwarding directives.
663    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
664        self.directives
665            .iter()
666            .filter(|d| !d.is_non_directive)
667            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
668            .collect()
669    }
670}