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    pub fn provider(&self) -> Option<(String, String)> {
107        for d in &self.directives {
108            if d.is_non_directive {
109                let trimmed = d.raw_line.trim();
110                if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
111                    if let Some((name, id)) = rest.split_once(':') {
112                        return Some((name.trim().to_string(), id.trim().to_string()));
113                    }
114                }
115            }
116        }
117        None
118    }
119
120    /// Set provider on a host block. Replaces existing purple:provider comment or adds one.
121    pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
122        let indent = self.detect_indent();
123        self.directives.retain(|d| {
124            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
125        });
126        let pos = self.content_end();
127        self.directives.insert(
128            pos,
129            Directive {
130                key: String::new(),
131                value: String::new(),
132                raw_line: format!(
133                    "{}# purple:provider {}:{}",
134                    indent, provider_name, server_id
135                ),
136                is_non_directive: true,
137            },
138        );
139    }
140
141    /// Extract askpass source from purple:askpass comment in directives.
142    pub fn askpass(&self) -> Option<String> {
143        for d in &self.directives {
144            if d.is_non_directive {
145                let trimmed = d.raw_line.trim();
146                if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
147                    let val = rest.trim();
148                    if !val.is_empty() {
149                        return Some(val.to_string());
150                    }
151                }
152            }
153        }
154        None
155    }
156
157    /// Extract vault-ssh role from purple:vault-ssh comment.
158    pub fn vault_ssh(&self) -> Option<String> {
159        for d in &self.directives {
160            if d.is_non_directive {
161                let trimmed = d.raw_line.trim();
162                if let Some(rest) = trimmed.strip_prefix("# purple:vault-ssh ") {
163                    let val = rest.trim();
164                    if !val.is_empty() && crate::vault_ssh::is_valid_role(val) {
165                        return Some(val.to_string());
166                    }
167                }
168            }
169        }
170        None
171    }
172
173    /// Set vault-ssh role. Replaces existing comment or adds one. Empty string removes.
174    pub fn set_vault_ssh(&mut self, role: &str) {
175        let indent = self.detect_indent();
176        self.directives.retain(|d| {
177            !(d.is_non_directive && {
178                let t = d.raw_line.trim();
179                t == "# purple:vault-ssh" || t.starts_with("# purple:vault-ssh ")
180            })
181        });
182        if !role.is_empty() {
183            let pos = self.content_end();
184            self.directives.insert(
185                pos,
186                Directive {
187                    key: String::new(),
188                    value: String::new(),
189                    raw_line: format!("{}# purple:vault-ssh {}", indent, role),
190                    is_non_directive: true,
191                },
192            );
193        }
194    }
195
196    /// Extract the Vault SSH endpoint from a `# purple:vault-addr` comment.
197    /// Returns None when the comment is absent, blank or contains an invalid
198    /// URL value. Validation is intentionally minimal: we reject empty,
199    /// whitespace-containing and control-character values but otherwise let
200    /// the Vault CLI surface its own error on typos.
201    pub fn vault_addr(&self) -> Option<String> {
202        for d in &self.directives {
203            if d.is_non_directive {
204                let trimmed = d.raw_line.trim();
205                if let Some(rest) = trimmed.strip_prefix("# purple:vault-addr ") {
206                    let val = rest.trim();
207                    if !val.is_empty() && crate::vault_ssh::is_valid_vault_addr(val) {
208                        return Some(val.to_string());
209                    }
210                }
211            }
212        }
213        None
214    }
215
216    /// Set vault-addr endpoint. Replaces existing comment or adds one. Empty
217    /// string removes. Caller is expected to have validated the URL upstream
218    /// (e.g. via `is_valid_vault_addr`) — this function does not re-validate.
219    pub fn set_vault_addr(&mut self, url: &str) {
220        let indent = self.detect_indent();
221        self.directives.retain(|d| {
222            !(d.is_non_directive && {
223                let t = d.raw_line.trim();
224                t == "# purple:vault-addr" || t.starts_with("# purple:vault-addr ")
225            })
226        });
227        if !url.is_empty() {
228            let pos = self.content_end();
229            self.directives.insert(
230                pos,
231                Directive {
232                    key: String::new(),
233                    value: String::new(),
234                    raw_line: format!("{}# purple:vault-addr {}", indent, url),
235                    is_non_directive: true,
236                },
237            );
238        }
239    }
240
241    /// Set askpass source on a host block. Replaces existing purple:askpass comment or adds one.
242    /// Pass an empty string to remove the comment.
243    pub fn set_askpass(&mut self, source: &str) {
244        let indent = self.detect_indent();
245        self.directives.retain(|d| {
246            !(d.is_non_directive && {
247                let t = d.raw_line.trim();
248                t == "# purple:askpass" || t.starts_with("# purple:askpass ")
249            })
250        });
251        if !source.is_empty() {
252            let pos = self.content_end();
253            self.directives.insert(
254                pos,
255                Directive {
256                    key: String::new(),
257                    value: String::new(),
258                    raw_line: format!("{}# purple:askpass {}", indent, source),
259                    is_non_directive: true,
260                },
261            );
262        }
263    }
264
265    /// Extract provider metadata from purple:meta comment in directives.
266    /// Format: `# purple:meta key=value,key=value`
267    pub fn meta(&self) -> Vec<(String, String)> {
268        for d in &self.directives {
269            if d.is_non_directive {
270                let trimmed = d.raw_line.trim();
271                if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
272                    return rest
273                        .split(',')
274                        .filter_map(|pair| {
275                            let (k, v) = pair.split_once('=')?;
276                            let k = k.trim();
277                            let v = v.trim();
278                            if k.is_empty() {
279                                None
280                            } else {
281                                Some((k.to_string(), v.to_string()))
282                            }
283                        })
284                        .collect();
285                }
286            }
287        }
288        Vec::new()
289    }
290
291    /// Set provider metadata on a host block. Replaces existing purple:meta comment or adds one.
292    /// Pass an empty slice to remove the comment.
293    pub fn set_meta(&mut self, meta: &[(String, String)]) {
294        let indent = self.detect_indent();
295        self.directives.retain(|d| {
296            !(d.is_non_directive && {
297                let t = d.raw_line.trim();
298                t == "# purple:meta" || t.starts_with("# purple:meta ")
299            })
300        });
301        if !meta.is_empty() {
302            let encoded: Vec<String> = meta
303                .iter()
304                .map(|(k, v)| {
305                    let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
306                    let clean_v = Self::sanitize_tag(&v.replace(',', ""));
307                    format!("{}={}", clean_k, clean_v)
308                })
309                .collect();
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:meta {}", indent, encoded.join(",")),
317                    is_non_directive: true,
318                },
319            );
320        }
321    }
322
323    /// Extract stale timestamp from purple:stale comment in directives.
324    /// Returns `None` if absent or malformed.
325    pub fn stale(&self) -> Option<u64> {
326        for d in &self.directives {
327            if d.is_non_directive {
328                let trimmed = d.raw_line.trim();
329                if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
330                    return rest.trim().parse::<u64>().ok();
331                }
332            }
333        }
334        None
335    }
336
337    /// Mark a host block as stale with a unix timestamp.
338    /// Replaces existing purple:stale comment or adds one.
339    pub fn set_stale(&mut self, timestamp: u64) {
340        let indent = self.detect_indent();
341        self.clear_stale();
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:stale {}", indent, timestamp),
349                is_non_directive: true,
350            },
351        );
352    }
353
354    /// Remove stale marking from a host block.
355    pub fn clear_stale(&mut self) {
356        self.directives.retain(|d| {
357            !(d.is_non_directive && {
358                let t = d.raw_line.trim();
359                t == "# purple:stale" || t.starts_with("# purple:stale ")
360            })
361        });
362    }
363
364    /// Sanitize a tag value: strip control characters, commas (delimiter),
365    /// and Unicode format/bidi override characters. Truncate to 128 chars.
366    pub(super) fn sanitize_tag(tag: &str) -> String {
367        tag.chars()
368            .filter(|c| {
369                !c.is_control()
370                    && *c != ','
371                    && !('\u{200B}'..='\u{200F}').contains(c) // zero-width, bidi marks
372                    && !('\u{202A}'..='\u{202E}').contains(c) // bidi embedding/override
373                    && !('\u{2066}'..='\u{2069}').contains(c) // bidi isolate
374                    && *c != '\u{FEFF}' // BOM/zero-width no-break space
375            })
376            .take(128)
377            .collect()
378    }
379
380    /// Set user tags on a host block. Replaces existing purple:tags comment or adds one.
381    pub fn set_tags(&mut self, tags: &[String]) {
382        let indent = self.detect_indent();
383        self.directives.retain(|d| {
384            !(d.is_non_directive && {
385                let t = d.raw_line.trim();
386                t == "# purple:tags" || t.starts_with("# purple:tags ")
387            })
388        });
389        let sanitized: Vec<String> = tags
390            .iter()
391            .map(|t| Self::sanitize_tag(t))
392            .filter(|t| !t.is_empty())
393            .collect();
394        if !sanitized.is_empty() {
395            let pos = self.content_end();
396            self.directives.insert(
397                pos,
398                Directive {
399                    key: String::new(),
400                    value: String::new(),
401                    raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
402                    is_non_directive: true,
403                },
404            );
405        }
406    }
407
408    /// Set provider-synced tags. Replaces existing purple:provider_tags comment.
409    /// Always writes the comment (even when empty) as a migration sentinel.
410    pub fn set_provider_tags(&mut self, tags: &[String]) {
411        let indent = self.detect_indent();
412        self.directives.retain(|d| {
413            !(d.is_non_directive && {
414                let t = d.raw_line.trim();
415                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
416            })
417        });
418        let sanitized: Vec<String> = tags
419            .iter()
420            .map(|t| Self::sanitize_tag(t))
421            .filter(|t| !t.is_empty())
422            .collect();
423        let raw = if sanitized.is_empty() {
424            format!("{}# purple:provider_tags", indent)
425        } else {
426            format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
427        };
428        let pos = self.content_end();
429        self.directives.insert(
430            pos,
431            Directive {
432                key: String::new(),
433                value: String::new(),
434                raw_line: raw,
435                is_non_directive: true,
436            },
437        );
438    }
439
440    /// Extract a convenience HostEntry view from this block.
441    pub fn to_host_entry(&self) -> HostEntry {
442        let mut entry = HostEntry {
443            alias: self.host_pattern.clone(),
444            port: 22,
445            ..Default::default()
446        };
447        for d in &self.directives {
448            if d.is_non_directive {
449                continue;
450            }
451            if d.key.eq_ignore_ascii_case("hostname") {
452                entry.hostname = d.value.clone();
453            } else if d.key.eq_ignore_ascii_case("user") {
454                entry.user = d.value.clone();
455            } else if d.key.eq_ignore_ascii_case("port") {
456                entry.port = d.value.parse().unwrap_or(22);
457            } else if d.key.eq_ignore_ascii_case("identityfile") {
458                if entry.identity_file.is_empty() {
459                    entry.identity_file = d.value.clone();
460                }
461            } else if d.key.eq_ignore_ascii_case("proxyjump") {
462                entry.proxy_jump = d.value.clone();
463            } else if d.key.eq_ignore_ascii_case("certificatefile")
464                && entry.certificate_file.is_empty()
465            {
466                entry.certificate_file = d.value.clone();
467            }
468        }
469        entry.tags = self.tags();
470        entry.provider_tags = self.provider_tags();
471        entry.has_provider_tags = self.has_provider_tags_comment();
472        entry.provider = self.provider().map(|(name, _)| name);
473        entry.tunnel_count = self.tunnel_count();
474        entry.askpass = self.askpass();
475        entry.vault_ssh = self.vault_ssh();
476        entry.vault_addr = self.vault_addr();
477        entry.provider_meta = self.meta();
478        entry.stale = self.stale();
479        entry
480    }
481
482    /// Extract a convenience PatternEntry view from this block.
483    pub fn to_pattern_entry(&self) -> PatternEntry {
484        let mut entry = PatternEntry {
485            pattern: self.host_pattern.clone(),
486            hostname: String::new(),
487            user: String::new(),
488            port: 22,
489            identity_file: String::new(),
490            proxy_jump: String::new(),
491            tags: self.tags(),
492            askpass: self.askpass(),
493            source_file: None,
494            directives: Vec::new(),
495        };
496        for d in &self.directives {
497            if d.is_non_directive {
498                continue;
499            }
500            match d.key.to_ascii_lowercase().as_str() {
501                "hostname" => entry.hostname = d.value.clone(),
502                "user" => entry.user = d.value.clone(),
503                "port" => entry.port = d.value.parse().unwrap_or(22),
504                "identityfile" if entry.identity_file.is_empty() => {
505                    entry.identity_file = d.value.clone();
506                }
507                "proxyjump" => entry.proxy_jump = d.value.clone(),
508                _ => {}
509            }
510            entry.directives.push((d.key.clone(), d.value.clone()));
511        }
512        entry
513    }
514
515    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
516    pub fn tunnel_count(&self) -> u16 {
517        let count = self
518            .directives
519            .iter()
520            .filter(|d| {
521                !d.is_non_directive
522                    && (d.key.eq_ignore_ascii_case("localforward")
523                        || d.key.eq_ignore_ascii_case("remoteforward")
524                        || d.key.eq_ignore_ascii_case("dynamicforward"))
525            })
526            .count();
527        count.min(u16::MAX as usize) as u16
528    }
529
530    /// Check if this block has any tunnel forwarding directives.
531    #[allow(dead_code)]
532    pub fn has_tunnels(&self) -> bool {
533        self.directives.iter().any(|d| {
534            !d.is_non_directive
535                && (d.key.eq_ignore_ascii_case("localforward")
536                    || d.key.eq_ignore_ascii_case("remoteforward")
537                    || d.key.eq_ignore_ascii_case("dynamicforward"))
538        })
539    }
540
541    /// Extract tunnel rules from forwarding directives.
542    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
543        self.directives
544            .iter()
545            .filter(|d| !d.is_non_directive)
546            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
547            .collect()
548    }
549}