Skip to main content

purple_ssh/
vault_ssh.rs

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