Skip to main content

purple_ssh/
vault_ssh.rs

1use anyhow::{Context, Result};
2use log::{debug, error, info};
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6/// Result of a certificate signing operation.
7#[derive(Debug)]
8pub struct SignResult {
9    pub cert_path: PathBuf,
10}
11
12/// Certificate validity status.
13#[derive(Debug, Clone, PartialEq)]
14pub enum CertStatus {
15    Valid {
16        expires_at: i64,
17        remaining_secs: i64,
18        /// Total certificate validity window in seconds (to - from), used by
19        /// the UI to compute proportional freshness thresholds.
20        total_secs: i64,
21    },
22    Expired,
23    Missing,
24    Invalid(String),
25}
26
27/// Minimum remaining seconds before a cert needs renewal (5 minutes).
28pub const RENEWAL_THRESHOLD_SECS: i64 = 300;
29
30/// TTL (in seconds) for the in-memory cert status cache before we re-run
31/// `ssh-keygen -L` against an on-disk certificate. Distinct from
32/// `RENEWAL_THRESHOLD_SECS`: this controls how often we *re-check* a cert's
33/// validity, while `RENEWAL_THRESHOLD_SECS` is the minimum lifetime below which
34/// we actually request a new signature from Vault.
35pub const CERT_STATUS_CACHE_TTL_SECS: u64 = 300;
36
37/// Shorter TTL for cached `CertStatus::Invalid` entries produced by check
38/// failures (e.g. unresolvable cert path). Error entries use this backoff
39/// instead of the 5-minute re-check TTL so transient errors recover quickly
40/// without hammering the background check thread on every poll tick.
41pub const CERT_ERROR_BACKOFF_SECS: u64 = 30;
42
43/// Validate a Vault SSH role path. Accepts ASCII alphanumerics plus `/`, `_` and `-`.
44/// Rejects empty strings and values longer than 128 chars.
45pub fn is_valid_role(s: &str) -> bool {
46    !s.is_empty()
47        && s.len() <= 128
48        && s.chars()
49            .all(|c| c.is_ascii_alphanumeric() || c == '/' || c == '_' || c == '-')
50}
51
52/// Validate a `VAULT_ADDR` value passed to the Vault CLI as an env var.
53///
54/// Intentionally minimal: reject empty, control characters and whitespace.
55/// We do NOT try to parse the URL here — a typo just produces a Vault CLI
56/// error, which is fine. The 512-byte ceiling prevents a pathological config
57/// line from ballooning the environment block.
58pub fn is_valid_vault_addr(s: &str) -> bool {
59    let trimmed = s.trim();
60    !trimmed.is_empty()
61        && trimmed.len() <= 512
62        && !trimmed.chars().any(|c| c.is_control() || c.is_whitespace())
63}
64
65/// Normalize a vault address so bare IPs and hostnames work.
66/// Prepends `https://` when no scheme is present and appends a default
67/// port when none is specified: `:80` for `http://`, `:443` for
68/// `https://`, `:8200` for bare hostnames (Vault's default). The
69/// default scheme is `https://` because production Vault always uses
70/// TLS. Dev-mode users can set `http://` explicitly.
71pub fn normalize_vault_addr(s: &str) -> String {
72    let trimmed = s.trim();
73    // Case-insensitive scheme detection.
74    let lower = trimmed.to_ascii_lowercase();
75    let (with_scheme, scheme_len) = if lower.starts_with("http://") || lower.starts_with("https://")
76    {
77        let len = if lower.starts_with("https://") { 8 } else { 7 };
78        (trimmed.to_string(), len)
79    } else if trimmed.contains("://") {
80        // Unknown scheme (ftp://, etc.) — return as-is, let the CLI error.
81        return trimmed.to_string();
82    } else {
83        (format!("https://{}", trimmed), 8)
84    };
85    // Extract the authority (host[:port]) portion, ignoring any path/query.
86    let after_scheme = &with_scheme[scheme_len..];
87    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
88    // IPv6 addresses use [::1]:port syntax. A colon inside brackets is not a
89    // port separator.
90    let has_port = if let Some(bracket_end) = authority.rfind(']') {
91        authority[bracket_end..].contains(':')
92    } else {
93        authority.contains(':')
94    };
95    if has_port {
96        with_scheme
97    } else {
98        // Use the scheme's standard port when the user typed an explicit scheme,
99        // otherwise fall back to Vault's default port (8200).
100        let default_port = if lower.starts_with("http://") {
101            80
102        } else if lower.starts_with("https://") {
103            443
104        } else {
105            8200
106        };
107        let path_start = scheme_len + authority.len();
108        format!(
109            "{}:{}{}",
110            &with_scheme[..path_start],
111            default_port,
112            &with_scheme[path_start..]
113        )
114    }
115}
116
117/// Scrub a raw Vault CLI stderr for display. Drops lines containing credential-like
118/// tokens (token, secret, x-vault-, cookie, authorization), joins the rest with spaces
119/// and truncates to 200 chars.
120pub fn scrub_vault_stderr(raw: &str) -> String {
121    let filtered: String = raw
122        .lines()
123        .filter(|line| {
124            let lower = line.to_ascii_lowercase();
125            !(lower.contains("token")
126                || lower.contains("secret")
127                || lower.contains("x-vault-")
128                || lower.contains("cookie")
129                || lower.contains("authorization"))
130        })
131        .collect::<Vec<_>>()
132        .join(" ");
133    let trimmed = filtered.trim();
134    if trimmed.is_empty() {
135        return "Vault SSH signing failed. Check vault status and policy".to_string();
136    }
137    if trimmed.chars().count() > 200 {
138        trimmed.chars().take(200).collect::<String>() + "..."
139    } else {
140        trimmed.to_string()
141    }
142}
143
144/// Return the certificate path for a given alias: `~/.purple/certs/<alias>-cert.pub`
145pub fn cert_path_for(alias: &str) -> Result<PathBuf> {
146    anyhow::ensure!(
147        !alias.is_empty()
148            && !alias.contains('/')
149            && !alias.contains('\\')
150            && !alias.contains(':')
151            && !alias.contains('\0')
152            && !alias.contains(".."),
153        "Invalid alias for cert path: '{}'",
154        alias
155    );
156    let dir = dirs::home_dir()
157        .context("Could not determine home directory")?
158        .join(".purple/certs");
159    Ok(dir.join(format!("{}-cert.pub", alias)))
160}
161
162/// Resolve the actual certificate file path for a host.
163/// Priority: CertificateFile directive > purple's default cert path.
164pub fn resolve_cert_path(alias: &str, certificate_file: &str) -> Result<PathBuf> {
165    if !certificate_file.is_empty() {
166        let expanded = if let Some(rest) = certificate_file.strip_prefix("~/") {
167            if let Some(home) = dirs::home_dir() {
168                home.join(rest)
169            } else {
170                PathBuf::from(certificate_file)
171            }
172        } else {
173            PathBuf::from(certificate_file)
174        };
175        Ok(expanded)
176    } else {
177        cert_path_for(alias)
178    }
179}
180
181/// Sign an SSH public key via Vault SSH secrets engine.
182/// Runs: `vault write -field=signed_key <role> public_key=@<pubkey_path>`
183/// Writes the signed certificate to `~/.purple/certs/<alias>-cert.pub`.
184///
185/// When `vault_addr` is `Some`, it is set as the `VAULT_ADDR` env var on the
186/// `vault` subprocess, overriding whatever the parent shell has configured.
187/// When `None`, the subprocess inherits the parent's env (current behavior).
188/// This lets purple users configure Vault address at the provider or host
189/// level without needing to launch purple from a pre-exported shell.
190pub fn sign_certificate(
191    role: &str,
192    pubkey_path: &Path,
193    alias: &str,
194    vault_addr: Option<&str>,
195) -> Result<SignResult> {
196    if !pubkey_path.exists() {
197        anyhow::bail!(
198            "Public key not found: {}. Set IdentityFile on the host or ensure ~/.ssh/id_ed25519.pub exists.",
199            pubkey_path.display()
200        );
201    }
202
203    if !is_valid_role(role) {
204        anyhow::bail!("Invalid Vault SSH role: '{}'", role);
205    }
206
207    let cert_dest = cert_path_for(alias)?;
208
209    if let Some(parent) = cert_dest.parent() {
210        std::fs::create_dir_all(parent)
211            .with_context(|| format!("Failed to create {}", parent.display()))?;
212    }
213
214    // The Vault CLI receives the public key path as a UTF-8 argument. `Path::display()`
215    // is lossy on non-UTF8 paths and could produce a mangled path Vault would then fail
216    // to read. Require a valid UTF-8 path and fail fast with a clear message.
217    let pubkey_str = pubkey_path.to_str().context(
218        "public key path contains non-UTF8 bytes; vault CLI requires a valid UTF-8 path",
219    )?;
220    // The Vault CLI parses arguments as `key=value` KV pairs. A path containing
221    // `=` would be split mid-argument and produce a cryptic parse error. The
222    // check runs on the already-resolved (tilde-expanded) path because that is
223    // exactly the byte sequence the CLI will see. A user with a `$HOME` path
224    // that itself contains `=` will hit this early; the error message reports
225    // the expanded path so they can rename the offending directory.
226    if pubkey_str.contains('=') {
227        anyhow::bail!(
228            "Public key path '{}' contains '=' which is not supported by the Vault CLI argument format. Rename the key file or directory.",
229            pubkey_str
230        );
231    }
232    let pubkey_arg = format!("public_key=@{}", pubkey_str);
233    debug!(
234        "[external] Vault sign request: addr={} role={}",
235        vault_addr.unwrap_or("<env>"),
236        role
237    );
238    let mut cmd = Command::new("vault");
239    cmd.args(["write", "-field=signed_key", role, &pubkey_arg]);
240    // Override VAULT_ADDR for this subprocess only when a value was resolved
241    // from config. Otherwise leave the env untouched so `vault` keeps using
242    // whatever the parent shell (or `~/.vault-token`) provides. The caller
243    // (typically `resolve_vault_addr`) is expected to have validated and
244    // trimmed the value already — re-checking here is cheap belt-and-braces
245    // for callers that construct the `Option<&str>` manually.
246    if let Some(addr) = vault_addr {
247        anyhow::ensure!(
248            is_valid_vault_addr(addr),
249            "Invalid VAULT_ADDR '{}' for role '{}'. Check the Vault SSH Address field.",
250            addr,
251            role
252        );
253        cmd.env("VAULT_ADDR", addr);
254    }
255    let mut child = cmd
256        .stdout(std::process::Stdio::piped())
257        .stderr(std::process::Stdio::piped())
258        .spawn()
259        .context("Failed to run vault CLI. Is vault installed and in PATH?")?;
260
261    // Drain both pipes on background threads to prevent pipe-buffer deadlock.
262    // Without this, the vault CLI can block writing to a full stderr pipe
263    // (64 KB) while we poll try_wait, causing a false timeout.
264    let stdout_handle = child.stdout.take();
265    let stderr_handle = child.stderr.take();
266    let stdout_thread = std::thread::spawn(move || -> Vec<u8> {
267        let mut buf = Vec::new();
268        if let Some(mut h) = stdout_handle {
269            if let Err(e) = std::io::Read::read_to_end(&mut h, &mut buf) {
270                log::warn!("[external] Failed to read vault stdout pipe: {e}");
271            }
272        }
273        buf
274    });
275    let stderr_thread = std::thread::spawn(move || -> Vec<u8> {
276        let mut buf = Vec::new();
277        if let Some(mut h) = stderr_handle {
278            if let Err(e) = std::io::Read::read_to_end(&mut h, &mut buf) {
279                log::warn!("[external] Failed to read vault stderr pipe: {e}");
280            }
281        }
282        buf
283    });
284
285    // Wait up to 30 seconds for the vault CLI to complete. Without a timeout
286    // the thread blocks indefinitely when the Vault server is unreachable
287    // (e.g. wrong address, firewall, TLS handshake hanging).
288    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
289    let status = loop {
290        match child.try_wait() {
291            Ok(Some(s)) => break s,
292            Ok(None) => {
293                if std::time::Instant::now() >= deadline {
294                    let _ = child.kill();
295                    let _ = child.wait();
296                    // The pipe-drain threads (stdout_thread, stderr_thread)
297                    // are dropped without joining here. This is intentional:
298                    // kill() closes the child's pipe ends, so read_to_end
299                    // returns immediately and the threads self-terminate.
300                    error!(
301                        "[external] Vault unreachable: {}: timed out after 30s",
302                        vault_addr.unwrap_or("<env>")
303                    );
304                    anyhow::bail!("Vault SSH timed out. Server unreachable.");
305                }
306                std::thread::sleep(std::time::Duration::from_millis(100));
307            }
308            Err(e) => {
309                let _ = child.kill();
310                let _ = child.wait();
311                anyhow::bail!("Failed to wait for vault CLI: {}", e);
312            }
313        }
314    };
315
316    let stdout_bytes = stdout_thread.join().unwrap_or_default();
317    let stderr_bytes = stderr_thread.join().unwrap_or_default();
318    let output = std::process::Output {
319        status,
320        stdout: stdout_bytes,
321        stderr: stderr_bytes,
322    };
323
324    if !output.status.success() {
325        let stderr = String::from_utf8_lossy(&output.stderr);
326        if stderr.contains("permission denied") || stderr.contains("403") {
327            error!(
328                "[external] Vault auth failed: permission denied (role={} addr={})",
329                role,
330                vault_addr.unwrap_or("<env>")
331            );
332            anyhow::bail!("Vault SSH permission denied. Check token and policy.");
333        }
334        if stderr.contains("missing client token") || stderr.contains("token expired") {
335            error!(
336                "[external] Vault auth failed: token missing or expired (role={} addr={})",
337                role,
338                vault_addr.unwrap_or("<env>")
339            );
340            anyhow::bail!("Vault SSH token missing or expired. Run `vault login`.");
341        }
342        // Check "connection refused" before "dial tcp" because Go's
343        // refused-connection error contains both substrings.
344        if stderr.contains("connection refused") {
345            error!(
346                "[external] Vault unreachable: {}: connection refused",
347                vault_addr.unwrap_or("<env>")
348            );
349            anyhow::bail!("Vault SSH connection refused.");
350        }
351        if stderr.contains("i/o timeout") || stderr.contains("dial tcp") {
352            error!(
353                "[external] Vault unreachable: {}: connection timed out",
354                vault_addr.unwrap_or("<env>")
355            );
356            anyhow::bail!("Vault SSH connection timed out.");
357        }
358        if stderr.contains("no such host") {
359            error!(
360                "[external] Vault unreachable: {}: no such host",
361                vault_addr.unwrap_or("<env>")
362            );
363            anyhow::bail!("Vault SSH host not found.");
364        }
365        if stderr.contains("server gave HTTP response to HTTPS client") {
366            error!(
367                "[external] Vault unreachable: {}: server returned HTTP on HTTPS connection",
368                vault_addr.unwrap_or("<env>")
369            );
370            anyhow::bail!("Vault SSH server uses HTTP, not HTTPS. Set address to http://.");
371        }
372        if stderr.contains("certificate signed by unknown authority")
373            || stderr.contains("tls:")
374            || stderr.contains("x509:")
375        {
376            error!(
377                "[external] Vault unreachable: {}: TLS error",
378                vault_addr.unwrap_or("<env>")
379            );
380            anyhow::bail!("Vault SSH TLS error. Check certificate or use http://.");
381        }
382        error!(
383            "[external] Vault SSH signing failed: {}",
384            scrub_vault_stderr(&stderr)
385        );
386        anyhow::bail!("Vault SSH failed: {}", scrub_vault_stderr(&stderr));
387    }
388
389    let signed_key = String::from_utf8_lossy(&output.stdout).trim().to_string();
390    if signed_key.is_empty() {
391        anyhow::bail!("Vault returned empty certificate for role '{}'", role);
392    }
393
394    crate::fs_util::atomic_write(&cert_dest, signed_key.as_bytes())
395        .with_context(|| format!("Failed to write certificate to {}", cert_dest.display()))?;
396
397    info!("Vault SSH certificate signed for {}", alias);
398    Ok(SignResult {
399        cert_path: cert_dest,
400    })
401}
402
403/// Check the validity of an SSH certificate file via `ssh-keygen -L`.
404///
405/// Timezone note: `ssh-keygen -L` outputs local civil time, which `parse_ssh_datetime`
406/// converts to pseudo-epoch seconds. Rather than comparing against UTC `now` (which would
407/// be wrong in non-UTC zones), we compute the TTL from the parsed from/to difference
408/// (timezone-independent) and measure elapsed time since the cert file was written (UTC
409/// file mtime vs UTC now). This keeps both sides in the same reference frame.
410pub fn check_cert_validity(cert_path: &Path) -> CertStatus {
411    if !cert_path.exists() {
412        return CertStatus::Missing;
413    }
414
415    let output = match Command::new("ssh-keygen")
416        .args(["-L", "-f"])
417        .arg(cert_path)
418        .output()
419    {
420        Ok(o) => o,
421        Err(e) => return CertStatus::Invalid(format!("Failed to run ssh-keygen: {}", e)),
422    };
423
424    if !output.status.success() {
425        return CertStatus::Invalid("ssh-keygen could not read certificate".to_string());
426    }
427
428    let stdout = String::from_utf8_lossy(&output.stdout);
429
430    // Handle certificates signed with no expiration ("Valid: forever").
431    for line in stdout.lines() {
432        let t = line.trim();
433        if t == "Valid: forever" || t.starts_with("Valid: from ") && t.ends_with(" to forever") {
434            return CertStatus::Valid {
435                expires_at: i64::MAX,
436                remaining_secs: i64::MAX,
437                total_secs: i64::MAX,
438            };
439        }
440    }
441
442    for line in stdout.lines() {
443        if let Some((from, to)) = parse_valid_line(line) {
444            let ttl = to - from; // Correct regardless of timezone
445            // Defensive: a cert with to < from is malformed. Treat as Invalid
446            // rather than propagating a negative ttl into the cache and the
447            // renewal threshold calculation.
448            if ttl <= 0 {
449                return CertStatus::Invalid(
450                    "certificate has non-positive validity window".to_string(),
451                );
452            }
453
454            // Use file modification time as the signing timestamp (UTC)
455            let signed_at = match std::fs::metadata(cert_path)
456                .and_then(|m| m.modified())
457                .ok()
458                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
459            {
460                Some(d) => d.as_secs() as i64,
461                None => {
462                    // Cannot determine file age. Treat as needing renewal.
463                    return CertStatus::Expired;
464                }
465            };
466
467            let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
468                Ok(d) => d.as_secs() as i64,
469                Err(_) => {
470                    return CertStatus::Invalid("system clock before unix epoch".to_string());
471                }
472            };
473
474            let elapsed = now - signed_at;
475            let remaining = ttl - elapsed;
476
477            if remaining <= 0 {
478                return CertStatus::Expired;
479            }
480            let expires_at = now + remaining;
481            return CertStatus::Valid {
482                expires_at,
483                remaining_secs: remaining,
484                total_secs: ttl,
485            };
486        }
487    }
488
489    CertStatus::Invalid("No Valid: line found in certificate".to_string())
490}
491
492/// Parse "Valid: from YYYY-MM-DDTHH:MM:SS to YYYY-MM-DDTHH:MM:SS" from ssh-keygen -L.
493fn parse_valid_line(line: &str) -> Option<(i64, i64)> {
494    let trimmed = line.trim();
495    let rest = trimmed.strip_prefix("Valid:")?;
496    let rest = rest.trim();
497    let rest = rest.strip_prefix("from ")?;
498    let (from_str, rest) = rest.split_once(" to ")?;
499    let to_str = rest.trim();
500
501    let from = parse_ssh_datetime(from_str)?;
502    let to = parse_ssh_datetime(to_str)?;
503    Some((from, to))
504}
505
506/// Parse YYYY-MM-DDTHH:MM:SS to Unix epoch seconds.
507/// Note: ssh-keygen outputs local time. We use the same clock for comparison
508/// (SystemTime::now gives wall clock), so the relative difference is correct
509/// for TTL checks even though the absolute epoch may be off by the UTC offset.
510fn parse_ssh_datetime(s: &str) -> Option<i64> {
511    let s = s.trim();
512    if s.len() < 19 {
513        return None;
514    }
515    let year: i64 = s.get(0..4)?.parse().ok()?;
516    let month: i64 = s.get(5..7)?.parse().ok()?;
517    let day: i64 = s.get(8..10)?.parse().ok()?;
518    let hour: i64 = s.get(11..13)?.parse().ok()?;
519    let min: i64 = s.get(14..16)?.parse().ok()?;
520    let sec: i64 = s.get(17..19)?.parse().ok()?;
521
522    if s.as_bytes().get(4) != Some(&b'-')
523        || s.as_bytes().get(7) != Some(&b'-')
524        || s.as_bytes().get(10) != Some(&b'T')
525        || s.as_bytes().get(13) != Some(&b':')
526        || s.as_bytes().get(16) != Some(&b':')
527    {
528        return None;
529    }
530
531    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
532        return None;
533    }
534    if !(0..=23).contains(&hour) || !(0..=59).contains(&min) || !(0..=59).contains(&sec) {
535        return None;
536    }
537
538    // Civil date to Unix epoch (same algorithm as chrono/time crates).
539    let mut y = year;
540    let m = if month <= 2 {
541        y -= 1;
542        month + 9
543    } else {
544        month - 3
545    };
546    let era = if y >= 0 { y } else { y - 399 } / 400;
547    let yoe = y - era * 400;
548    let doy = (153 * m + 2) / 5 + day - 1;
549    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
550    let days = era * 146097 + doe - 719468;
551
552    Some(days * 86400 + hour * 3600 + min * 60 + sec)
553}
554
555/// Check if a certificate needs renewal.
556///
557/// For certificates whose total validity window is shorter than
558/// `RENEWAL_THRESHOLD_SECS`, the fixed 5-minute threshold would flag a freshly
559/// signed cert as needing renewal immediately, causing an infinite re-sign loop.
560/// In that case we fall back to a proportional threshold (half the total).
561pub fn needs_renewal(status: &CertStatus) -> bool {
562    match status {
563        CertStatus::Missing | CertStatus::Expired | CertStatus::Invalid(_) => true,
564        CertStatus::Valid {
565            remaining_secs,
566            total_secs,
567            ..
568        } => {
569            let threshold = if *total_secs > 0 && *total_secs <= RENEWAL_THRESHOLD_SECS {
570                *total_secs / 2
571            } else {
572                RENEWAL_THRESHOLD_SECS
573            };
574            *remaining_secs < threshold
575        }
576    }
577}
578
579/// Ensure a valid certificate exists for a host. Signs a new one if needed.
580/// Checks at the CertificateFile path (or purple's default) before signing.
581pub fn ensure_cert(
582    role: &str,
583    pubkey_path: &Path,
584    alias: &str,
585    certificate_file: &str,
586    vault_addr: Option<&str>,
587) -> Result<PathBuf> {
588    let check_path = resolve_cert_path(alias, certificate_file)?;
589    let status = check_cert_validity(&check_path);
590
591    if !needs_renewal(&status) {
592        info!("Vault SSH certificate cache hit for {}", alias);
593        return Ok(check_path);
594    }
595
596    let result = sign_certificate(role, pubkey_path, alias, vault_addr)?;
597    Ok(result.cert_path)
598}
599
600/// Resolve the public key path for signing.
601/// Priority: host IdentityFile + ".pub" > ~/.ssh/id_ed25519.pub fallback.
602/// Returns an error when the user's home directory cannot be determined. Any
603/// IdentityFile pointing outside `$HOME` is rejected and falls back to the
604/// default `~/.ssh/id_ed25519.pub` to prevent reading arbitrary filesystem
605/// locations via a crafted IdentityFile directive.
606pub fn resolve_pubkey_path(identity_file: &str) -> Result<PathBuf> {
607    let home = dirs::home_dir().context("Could not determine home directory")?;
608    let fallback = home.join(".ssh/id_ed25519.pub");
609
610    if identity_file.is_empty() {
611        return Ok(fallback);
612    }
613
614    let expanded = if let Some(rest) = identity_file.strip_prefix("~/") {
615        home.join(rest)
616    } else {
617        PathBuf::from(identity_file)
618    };
619
620    // A purely lexical `starts_with(&home)` check can be bypassed by a symlink inside
621    // $HOME pointing to a path outside $HOME (e.g. ~/evil -> /etc). Canonicalize both
622    // sides so symlinks are resolved, then compare. If the expanded path does not yet
623    // exist (or canonicalize fails for any reason) we cannot safely reason about where
624    // it actually points, so fall back to the default key path.
625    let canonical_home = match std::fs::canonicalize(&home) {
626        Ok(p) => p,
627        Err(_) => return Ok(fallback),
628    };
629    if expanded.exists() {
630        match std::fs::canonicalize(&expanded) {
631            Ok(canonical) if canonical.starts_with(&canonical_home) => {}
632            _ => return Ok(fallback),
633        }
634    } else if !expanded.starts_with(&home) {
635        return Ok(fallback);
636    }
637
638    if expanded.extension().is_some_and(|ext| ext == "pub") {
639        Ok(expanded)
640    } else {
641        let mut s = expanded.into_os_string();
642        s.push(".pub");
643        Ok(PathBuf::from(s))
644    }
645}
646
647/// Resolve the effective vault role for a host.
648/// Priority: host-level vault_ssh > provider-level vault_role > None.
649pub fn resolve_vault_role(
650    host_vault_ssh: Option<&str>,
651    provider_name: Option<&str>,
652    provider_config: &crate::providers::config::ProviderConfig,
653) -> Option<String> {
654    if let Some(role) = host_vault_ssh {
655        if !role.is_empty() {
656            return Some(role.to_string());
657        }
658    }
659
660    if let Some(name) = provider_name {
661        if let Some(section) = provider_config.section(name) {
662            if !section.vault_role.is_empty() {
663                return Some(section.vault_role.clone());
664            }
665        }
666    }
667
668    None
669}
670
671/// Resolve the effective Vault address for a host.
672///
673/// Precedence (highest wins): per-host `# purple:vault-addr` comment,
674/// provider `vault_addr=` setting, else None (caller falls back to the
675/// `vault` CLI's own env resolution).
676///
677/// Both layers are re-validated with `is_valid_vault_addr` even though the
678/// parser paths (`HostBlock::vault_addr()` and `ProviderConfig::parse`)
679/// already drop invalid values. This is defensive: a future caller that
680/// constructs a `HostEntry` or `ProviderSection` in-memory (tests, migration
681/// code, a new feature) won't be able to smuggle a malformed `VAULT_ADDR`
682/// into `sign_certificate` through this resolver.
683pub fn resolve_vault_addr(
684    host_vault_addr: Option<&str>,
685    provider_name: Option<&str>,
686    provider_config: &crate::providers::config::ProviderConfig,
687) -> Option<String> {
688    if let Some(addr) = host_vault_addr {
689        let trimmed = addr.trim();
690        if !trimmed.is_empty() && is_valid_vault_addr(trimmed) {
691            return Some(normalize_vault_addr(trimmed));
692        }
693    }
694
695    if let Some(name) = provider_name {
696        if let Some(section) = provider_config.section(name) {
697            let trimmed = section.vault_addr.trim();
698            if !trimmed.is_empty() && is_valid_vault_addr(trimmed) {
699                return Some(normalize_vault_addr(trimmed));
700            }
701        }
702    }
703
704    None
705}
706
707/// Format remaining certificate time for display.
708pub fn format_remaining(remaining_secs: i64) -> String {
709    if remaining_secs <= 0 {
710        return "expired".to_string();
711    }
712    let hours = remaining_secs / 3600;
713    let mins = (remaining_secs % 3600) / 60;
714    if hours > 0 {
715        format!("{}h {}m", hours, mins)
716    } else {
717        format!("{}m", mins)
718    }
719}
720
721#[cfg(test)]
722#[path = "vault_ssh_tests.rs"]
723mod tests;