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 `:8200`
67/// (Vault's default port) when no port is specified. The default
68/// scheme is `https://` because production Vault always uses TLS.
69/// Dev-mode users can set `http://` explicitly.
70pub fn normalize_vault_addr(s: &str) -> String {
71    let trimmed = s.trim();
72    // Case-insensitive scheme detection.
73    let lower = trimmed.to_ascii_lowercase();
74    let (with_scheme, scheme_len) = if lower.starts_with("http://") || lower.starts_with("https://")
75    {
76        let len = if lower.starts_with("https://") { 8 } else { 7 };
77        (trimmed.to_string(), len)
78    } else if trimmed.contains("://") {
79        // Unknown scheme (ftp://, etc.) — return as-is, let the CLI error.
80        return trimmed.to_string();
81    } else {
82        (format!("https://{}", trimmed), 8)
83    };
84    // Extract the authority (host[:port]) portion, ignoring any path/query.
85    let after_scheme = &with_scheme[scheme_len..];
86    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
87    // IPv6 addresses use [::1]:port syntax. A colon inside brackets is not a
88    // port separator.
89    let has_port = if let Some(bracket_end) = authority.rfind(']') {
90        authority[bracket_end..].contains(':')
91    } else {
92        authority.contains(':')
93    };
94    if has_port {
95        with_scheme
96    } else {
97        // Insert :8200 after the authority, before any path.
98        let path_start = scheme_len + authority.len();
99        format!(
100            "{}:8200{}",
101            &with_scheme[..path_start],
102            &with_scheme[path_start..]
103        )
104    }
105}
106
107/// Scrub a raw Vault CLI stderr for display. Drops lines containing credential-like
108/// tokens (token, secret, x-vault-, cookie, authorization), joins the rest with spaces
109/// and truncates to 200 chars.
110pub fn scrub_vault_stderr(raw: &str) -> String {
111    let filtered: String = raw
112        .lines()
113        .filter(|line| {
114            let lower = line.to_ascii_lowercase();
115            !(lower.contains("token")
116                || lower.contains("secret")
117                || lower.contains("x-vault-")
118                || lower.contains("cookie")
119                || lower.contains("authorization"))
120        })
121        .collect::<Vec<_>>()
122        .join(" ");
123    let trimmed = filtered.trim();
124    if trimmed.is_empty() {
125        return "Vault SSH signing failed. Check vault status and policy".to_string();
126    }
127    if trimmed.chars().count() > 200 {
128        trimmed.chars().take(200).collect::<String>() + "..."
129    } else {
130        trimmed.to_string()
131    }
132}
133
134/// Return the certificate path for a given alias: ~/.purple/certs/<alias>-cert.pub
135pub fn cert_path_for(alias: &str) -> Result<PathBuf> {
136    anyhow::ensure!(
137        !alias.is_empty()
138            && !alias.contains('/')
139            && !alias.contains('\\')
140            && !alias.contains(':')
141            && !alias.contains('\0')
142            && !alias.contains(".."),
143        "Invalid alias for cert path: '{}'",
144        alias
145    );
146    let dir = dirs::home_dir()
147        .context("Could not determine home directory")?
148        .join(".purple/certs");
149    Ok(dir.join(format!("{}-cert.pub", alias)))
150}
151
152/// Resolve the actual certificate file path for a host.
153/// Priority: CertificateFile directive > purple's default cert path.
154pub fn resolve_cert_path(alias: &str, certificate_file: &str) -> Result<PathBuf> {
155    if !certificate_file.is_empty() {
156        let expanded = if let Some(rest) = certificate_file.strip_prefix("~/") {
157            if let Some(home) = dirs::home_dir() {
158                home.join(rest)
159            } else {
160                PathBuf::from(certificate_file)
161            }
162        } else {
163            PathBuf::from(certificate_file)
164        };
165        Ok(expanded)
166    } else {
167        cert_path_for(alias)
168    }
169}
170
171/// Sign an SSH public key via Vault SSH secrets engine.
172/// Runs: `vault write -field=signed_key <role> public_key=@<pubkey_path>`
173/// Writes the signed certificate to ~/.purple/certs/<alias>-cert.pub.
174///
175/// When `vault_addr` is `Some`, it is set as the `VAULT_ADDR` env var on the
176/// `vault` subprocess, overriding whatever the parent shell has configured.
177/// When `None`, the subprocess inherits the parent's env (current behavior).
178/// This lets purple users configure Vault address at the provider or host
179/// level without needing to launch purple from a pre-exported shell.
180pub fn sign_certificate(
181    role: &str,
182    pubkey_path: &Path,
183    alias: &str,
184    vault_addr: Option<&str>,
185) -> Result<SignResult> {
186    if !pubkey_path.exists() {
187        anyhow::bail!(
188            "Public key not found: {}. Set IdentityFile on the host or ensure ~/.ssh/id_ed25519.pub exists.",
189            pubkey_path.display()
190        );
191    }
192
193    if !is_valid_role(role) {
194        anyhow::bail!("Invalid Vault SSH role: '{}'", role);
195    }
196
197    let cert_dest = cert_path_for(alias)?;
198
199    if let Some(parent) = cert_dest.parent() {
200        std::fs::create_dir_all(parent)
201            .with_context(|| format!("Failed to create {}", parent.display()))?;
202    }
203
204    // The Vault CLI receives the public key path as a UTF-8 argument. `Path::display()`
205    // is lossy on non-UTF8 paths and could produce a mangled path Vault would then fail
206    // to read. Require a valid UTF-8 path and fail fast with a clear message.
207    let pubkey_str = pubkey_path.to_str().context(
208        "public key path contains non-UTF8 bytes; vault CLI requires a valid UTF-8 path",
209    )?;
210    // The Vault CLI parses arguments as `key=value` KV pairs. A path containing
211    // `=` would be split mid-argument and produce a cryptic parse error. The
212    // check runs on the already-resolved (tilde-expanded) path because that is
213    // exactly the byte sequence the CLI will see. A user with a `$HOME` path
214    // that itself contains `=` will hit this early; the error message reports
215    // the expanded path so they can rename the offending directory.
216    if pubkey_str.contains('=') {
217        anyhow::bail!(
218            "Public key path '{}' contains '=' which is not supported by the Vault CLI argument format. Rename the key file or directory.",
219            pubkey_str
220        );
221    }
222    let pubkey_arg = format!("public_key=@{}", pubkey_str);
223    debug!(
224        "[external] Vault sign request: addr={} role={}",
225        vault_addr.unwrap_or("<env>"),
226        role
227    );
228    let mut cmd = Command::new("vault");
229    cmd.args(["write", "-field=signed_key", role, &pubkey_arg]);
230    // Override VAULT_ADDR for this subprocess only when a value was resolved
231    // from config. Otherwise leave the env untouched so `vault` keeps using
232    // whatever the parent shell (or `~/.vault-token`) provides. The caller
233    // (typically `resolve_vault_addr`) is expected to have validated and
234    // trimmed the value already — re-checking here is cheap belt-and-braces
235    // for callers that construct the `Option<&str>` manually.
236    if let Some(addr) = vault_addr {
237        anyhow::ensure!(
238            is_valid_vault_addr(addr),
239            "Invalid VAULT_ADDR '{}' for role '{}'. Check the Vault SSH Address field.",
240            addr,
241            role
242        );
243        cmd.env("VAULT_ADDR", addr);
244    }
245    let mut child = cmd
246        .stdout(std::process::Stdio::piped())
247        .stderr(std::process::Stdio::piped())
248        .spawn()
249        .context("Failed to run vault CLI. Is vault installed and in PATH?")?;
250
251    // Drain both pipes on background threads to prevent pipe-buffer deadlock.
252    // Without this, the vault CLI can block writing to a full stderr pipe
253    // (64 KB) while we poll try_wait, causing a false timeout.
254    let stdout_handle = child.stdout.take();
255    let stderr_handle = child.stderr.take();
256    let stdout_thread = std::thread::spawn(move || -> Vec<u8> {
257        let mut buf = Vec::new();
258        if let Some(mut h) = stdout_handle {
259            let _ = std::io::Read::read_to_end(&mut h, &mut buf);
260        }
261        buf
262    });
263    let stderr_thread = std::thread::spawn(move || -> Vec<u8> {
264        let mut buf = Vec::new();
265        if let Some(mut h) = stderr_handle {
266            let _ = std::io::Read::read_to_end(&mut h, &mut buf);
267        }
268        buf
269    });
270
271    // Wait up to 30 seconds for the vault CLI to complete. Without a timeout
272    // the thread blocks indefinitely when the Vault server is unreachable
273    // (e.g. wrong address, firewall, TLS handshake hanging).
274    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
275    let status = loop {
276        match child.try_wait() {
277            Ok(Some(s)) => break s,
278            Ok(None) => {
279                if std::time::Instant::now() >= deadline {
280                    let _ = child.kill();
281                    let _ = child.wait();
282                    // The pipe-drain threads (stdout_thread, stderr_thread)
283                    // are dropped without joining here. This is intentional:
284                    // kill() closes the child's pipe ends, so read_to_end
285                    // returns immediately and the threads self-terminate.
286                    error!(
287                        "[external] Vault unreachable: {}: timed out after 30s",
288                        vault_addr.unwrap_or("<env>")
289                    );
290                    anyhow::bail!("Vault SSH timed out. Server unreachable.");
291                }
292                std::thread::sleep(std::time::Duration::from_millis(100));
293            }
294            Err(e) => {
295                let _ = child.kill();
296                let _ = child.wait();
297                anyhow::bail!("Failed to wait for vault CLI: {}", e);
298            }
299        }
300    };
301
302    let stdout_bytes = stdout_thread.join().unwrap_or_default();
303    let stderr_bytes = stderr_thread.join().unwrap_or_default();
304    let output = std::process::Output {
305        status,
306        stdout: stdout_bytes,
307        stderr: stderr_bytes,
308    };
309
310    if !output.status.success() {
311        let stderr = String::from_utf8_lossy(&output.stderr);
312        if stderr.contains("permission denied") || stderr.contains("403") {
313            error!(
314                "[external] Vault auth failed: permission denied (role={} addr={})",
315                role,
316                vault_addr.unwrap_or("<env>")
317            );
318            anyhow::bail!("Vault SSH permission denied. Check token and policy.");
319        }
320        if stderr.contains("missing client token") || stderr.contains("token expired") {
321            error!(
322                "[external] Vault auth failed: token missing or expired (role={} addr={})",
323                role,
324                vault_addr.unwrap_or("<env>")
325            );
326            anyhow::bail!("Vault SSH token missing or expired. Run `vault login`.");
327        }
328        // Check "connection refused" before "dial tcp" because Go's
329        // refused-connection error contains both substrings.
330        if stderr.contains("connection refused") {
331            error!(
332                "[external] Vault unreachable: {}: connection refused",
333                vault_addr.unwrap_or("<env>")
334            );
335            anyhow::bail!("Vault SSH connection refused.");
336        }
337        if stderr.contains("i/o timeout") || stderr.contains("dial tcp") {
338            error!(
339                "[external] Vault unreachable: {}: connection timed out",
340                vault_addr.unwrap_or("<env>")
341            );
342            anyhow::bail!("Vault SSH connection timed out.");
343        }
344        if stderr.contains("no such host") {
345            error!(
346                "[external] Vault unreachable: {}: no such host",
347                vault_addr.unwrap_or("<env>")
348            );
349            anyhow::bail!("Vault SSH host not found.");
350        }
351        if stderr.contains("server gave HTTP response to HTTPS client") {
352            error!(
353                "[external] Vault unreachable: {}: server returned HTTP on HTTPS connection",
354                vault_addr.unwrap_or("<env>")
355            );
356            anyhow::bail!("Vault SSH server uses HTTP, not HTTPS. Set address to http://.");
357        }
358        if stderr.contains("certificate signed by unknown authority")
359            || stderr.contains("tls:")
360            || stderr.contains("x509:")
361        {
362            error!(
363                "[external] Vault unreachable: {}: TLS error",
364                vault_addr.unwrap_or("<env>")
365            );
366            anyhow::bail!("Vault SSH TLS error. Check certificate or use http://.");
367        }
368        error!(
369            "[external] Vault SSH signing failed: {}",
370            scrub_vault_stderr(&stderr)
371        );
372        anyhow::bail!("Vault SSH failed: {}", scrub_vault_stderr(&stderr));
373    }
374
375    let signed_key = String::from_utf8_lossy(&output.stdout).trim().to_string();
376    if signed_key.is_empty() {
377        anyhow::bail!("Vault returned empty certificate for role '{}'", role);
378    }
379
380    crate::fs_util::atomic_write(&cert_dest, signed_key.as_bytes())
381        .with_context(|| format!("Failed to write certificate to {}", cert_dest.display()))?;
382
383    info!("Vault SSH certificate signed for {}", alias);
384    Ok(SignResult {
385        cert_path: cert_dest,
386    })
387}
388
389/// Check the validity of an SSH certificate file via `ssh-keygen -L`.
390///
391/// Timezone note: `ssh-keygen -L` outputs local civil time, which `parse_ssh_datetime`
392/// converts to pseudo-epoch seconds. Rather than comparing against UTC `now` (which would
393/// be wrong in non-UTC zones), we compute the TTL from the parsed from/to difference
394/// (timezone-independent) and measure elapsed time since the cert file was written (UTC
395/// file mtime vs UTC now). This keeps both sides in the same reference frame.
396pub fn check_cert_validity(cert_path: &Path) -> CertStatus {
397    if !cert_path.exists() {
398        return CertStatus::Missing;
399    }
400
401    let output = match Command::new("ssh-keygen")
402        .args(["-L", "-f"])
403        .arg(cert_path)
404        .output()
405    {
406        Ok(o) => o,
407        Err(e) => return CertStatus::Invalid(format!("Failed to run ssh-keygen: {}", e)),
408    };
409
410    if !output.status.success() {
411        return CertStatus::Invalid("ssh-keygen could not read certificate".to_string());
412    }
413
414    let stdout = String::from_utf8_lossy(&output.stdout);
415
416    // Handle certificates signed with no expiration ("Valid: forever").
417    for line in stdout.lines() {
418        let t = line.trim();
419        if t == "Valid: forever" || t.starts_with("Valid: from ") && t.ends_with(" to forever") {
420            return CertStatus::Valid {
421                expires_at: i64::MAX,
422                remaining_secs: i64::MAX,
423                total_secs: i64::MAX,
424            };
425        }
426    }
427
428    for line in stdout.lines() {
429        if let Some((from, to)) = parse_valid_line(line) {
430            let ttl = to - from; // Correct regardless of timezone
431            // Defensive: a cert with to < from is malformed. Treat as Invalid
432            // rather than propagating a negative ttl into the cache and the
433            // renewal threshold calculation.
434            if ttl <= 0 {
435                return CertStatus::Invalid(
436                    "certificate has non-positive validity window".to_string(),
437                );
438            }
439
440            // Use file modification time as the signing timestamp (UTC)
441            let signed_at = match std::fs::metadata(cert_path)
442                .and_then(|m| m.modified())
443                .ok()
444                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
445            {
446                Some(d) => d.as_secs() as i64,
447                None => {
448                    // Cannot determine file age. Treat as needing renewal.
449                    return CertStatus::Expired;
450                }
451            };
452
453            let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
454                Ok(d) => d.as_secs() as i64,
455                Err(_) => {
456                    return CertStatus::Invalid("system clock before unix epoch".to_string());
457                }
458            };
459
460            let elapsed = now - signed_at;
461            let remaining = ttl - elapsed;
462
463            if remaining <= 0 {
464                return CertStatus::Expired;
465            }
466            let expires_at = now + remaining;
467            return CertStatus::Valid {
468                expires_at,
469                remaining_secs: remaining,
470                total_secs: ttl,
471            };
472        }
473    }
474
475    CertStatus::Invalid("No Valid: line found in certificate".to_string())
476}
477
478/// Parse "Valid: from YYYY-MM-DDTHH:MM:SS to YYYY-MM-DDTHH:MM:SS" from ssh-keygen -L.
479fn parse_valid_line(line: &str) -> Option<(i64, i64)> {
480    let trimmed = line.trim();
481    let rest = trimmed.strip_prefix("Valid:")?;
482    let rest = rest.trim();
483    let rest = rest.strip_prefix("from ")?;
484    let (from_str, rest) = rest.split_once(" to ")?;
485    let to_str = rest.trim();
486
487    let from = parse_ssh_datetime(from_str)?;
488    let to = parse_ssh_datetime(to_str)?;
489    Some((from, to))
490}
491
492/// Parse YYYY-MM-DDTHH:MM:SS to Unix epoch seconds.
493/// Note: ssh-keygen outputs local time. We use the same clock for comparison
494/// (SystemTime::now gives wall clock), so the relative difference is correct
495/// for TTL checks even though the absolute epoch may be off by the UTC offset.
496fn parse_ssh_datetime(s: &str) -> Option<i64> {
497    let s = s.trim();
498    if s.len() < 19 {
499        return None;
500    }
501    let year: i64 = s.get(0..4)?.parse().ok()?;
502    let month: i64 = s.get(5..7)?.parse().ok()?;
503    let day: i64 = s.get(8..10)?.parse().ok()?;
504    let hour: i64 = s.get(11..13)?.parse().ok()?;
505    let min: i64 = s.get(14..16)?.parse().ok()?;
506    let sec: i64 = s.get(17..19)?.parse().ok()?;
507
508    if s.as_bytes().get(4) != Some(&b'-')
509        || s.as_bytes().get(7) != Some(&b'-')
510        || s.as_bytes().get(10) != Some(&b'T')
511        || s.as_bytes().get(13) != Some(&b':')
512        || s.as_bytes().get(16) != Some(&b':')
513    {
514        return None;
515    }
516
517    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
518        return None;
519    }
520    if !(0..=23).contains(&hour) || !(0..=59).contains(&min) || !(0..=59).contains(&sec) {
521        return None;
522    }
523
524    // Civil date to Unix epoch (same algorithm as chrono/time crates).
525    let mut y = year;
526    let m = if month <= 2 {
527        y -= 1;
528        month + 9
529    } else {
530        month - 3
531    };
532    let era = if y >= 0 { y } else { y - 399 } / 400;
533    let yoe = y - era * 400;
534    let doy = (153 * m + 2) / 5 + day - 1;
535    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
536    let days = era * 146097 + doe - 719468;
537
538    Some(days * 86400 + hour * 3600 + min * 60 + sec)
539}
540
541/// Check if a certificate needs renewal.
542///
543/// For certificates whose total validity window is shorter than
544/// `RENEWAL_THRESHOLD_SECS`, the fixed 5-minute threshold would flag a freshly
545/// signed cert as needing renewal immediately, causing an infinite re-sign loop.
546/// In that case we fall back to a proportional threshold (half the total).
547pub fn needs_renewal(status: &CertStatus) -> bool {
548    match status {
549        CertStatus::Missing | CertStatus::Expired | CertStatus::Invalid(_) => true,
550        CertStatus::Valid {
551            remaining_secs,
552            total_secs,
553            ..
554        } => {
555            let threshold = if *total_secs > 0 && *total_secs <= RENEWAL_THRESHOLD_SECS {
556                *total_secs / 2
557            } else {
558                RENEWAL_THRESHOLD_SECS
559            };
560            *remaining_secs < threshold
561        }
562    }
563}
564
565/// Ensure a valid certificate exists for a host. Signs a new one if needed.
566/// Checks at the CertificateFile path (or purple's default) before signing.
567pub fn ensure_cert(
568    role: &str,
569    pubkey_path: &Path,
570    alias: &str,
571    certificate_file: &str,
572    vault_addr: Option<&str>,
573) -> Result<PathBuf> {
574    let check_path = resolve_cert_path(alias, certificate_file)?;
575    let status = check_cert_validity(&check_path);
576
577    if !needs_renewal(&status) {
578        info!("Vault SSH certificate cache hit for {}", alias);
579        return Ok(check_path);
580    }
581
582    let result = sign_certificate(role, pubkey_path, alias, vault_addr)?;
583    Ok(result.cert_path)
584}
585
586/// Resolve the public key path for signing.
587/// Priority: host IdentityFile + ".pub" > ~/.ssh/id_ed25519.pub fallback.
588/// Returns an error when the user's home directory cannot be determined. Any
589/// IdentityFile pointing outside `$HOME` is rejected and falls back to the
590/// default `~/.ssh/id_ed25519.pub` to prevent reading arbitrary filesystem
591/// locations via a crafted IdentityFile directive.
592pub fn resolve_pubkey_path(identity_file: &str) -> Result<PathBuf> {
593    let home = dirs::home_dir().context("Could not determine home directory")?;
594    let fallback = home.join(".ssh/id_ed25519.pub");
595
596    if identity_file.is_empty() {
597        return Ok(fallback);
598    }
599
600    let expanded = if let Some(rest) = identity_file.strip_prefix("~/") {
601        home.join(rest)
602    } else {
603        PathBuf::from(identity_file)
604    };
605
606    // A purely lexical `starts_with(&home)` check can be bypassed by a symlink inside
607    // $HOME pointing to a path outside $HOME (e.g. ~/evil -> /etc). Canonicalize both
608    // sides so symlinks are resolved, then compare. If the expanded path does not yet
609    // exist (or canonicalize fails for any reason) we cannot safely reason about where
610    // it actually points, so fall back to the default key path.
611    let canonical_home = match std::fs::canonicalize(&home) {
612        Ok(p) => p,
613        Err(_) => return Ok(fallback),
614    };
615    if expanded.exists() {
616        match std::fs::canonicalize(&expanded) {
617            Ok(canonical) if canonical.starts_with(&canonical_home) => {}
618            _ => return Ok(fallback),
619        }
620    } else if !expanded.starts_with(&home) {
621        return Ok(fallback);
622    }
623
624    if expanded.extension().is_some_and(|ext| ext == "pub") {
625        Ok(expanded)
626    } else {
627        let mut s = expanded.into_os_string();
628        s.push(".pub");
629        Ok(PathBuf::from(s))
630    }
631}
632
633/// Resolve the effective vault role for a host.
634/// Priority: host-level vault_ssh > provider-level vault_role > None.
635pub fn resolve_vault_role(
636    host_vault_ssh: Option<&str>,
637    provider_name: Option<&str>,
638    provider_config: &crate::providers::config::ProviderConfig,
639) -> Option<String> {
640    if let Some(role) = host_vault_ssh {
641        if !role.is_empty() {
642            return Some(role.to_string());
643        }
644    }
645
646    if let Some(name) = provider_name {
647        if let Some(section) = provider_config.section(name) {
648            if !section.vault_role.is_empty() {
649                return Some(section.vault_role.clone());
650            }
651        }
652    }
653
654    None
655}
656
657/// Resolve the effective Vault address for a host.
658///
659/// Precedence (highest wins): per-host `# purple:vault-addr` comment,
660/// provider `vault_addr=` setting, else None (caller falls back to the
661/// `vault` CLI's own env resolution).
662///
663/// Both layers are re-validated with `is_valid_vault_addr` even though the
664/// parser paths (`HostBlock::vault_addr()` and `ProviderConfig::parse`)
665/// already drop invalid values. This is defensive: a future caller that
666/// constructs a `HostEntry` or `ProviderSection` in-memory (tests, migration
667/// code, a new feature) won't be able to smuggle a malformed `VAULT_ADDR`
668/// into `sign_certificate` through this resolver.
669pub fn resolve_vault_addr(
670    host_vault_addr: Option<&str>,
671    provider_name: Option<&str>,
672    provider_config: &crate::providers::config::ProviderConfig,
673) -> Option<String> {
674    if let Some(addr) = host_vault_addr {
675        let trimmed = addr.trim();
676        if !trimmed.is_empty() && is_valid_vault_addr(trimmed) {
677            return Some(normalize_vault_addr(trimmed));
678        }
679    }
680
681    if let Some(name) = provider_name {
682        if let Some(section) = provider_config.section(name) {
683            let trimmed = section.vault_addr.trim();
684            if !trimmed.is_empty() && is_valid_vault_addr(trimmed) {
685                return Some(normalize_vault_addr(trimmed));
686            }
687        }
688    }
689
690    None
691}
692
693/// Format remaining certificate time for display.
694pub fn format_remaining(remaining_secs: i64) -> String {
695    if remaining_secs <= 0 {
696        return "expired".to_string();
697    }
698    let hours = remaining_secs / 3600;
699    let mins = (remaining_secs % 3600) / 60;
700    if hours > 0 {
701        format!("{}h {}m", hours, mins)
702    } else {
703        format!("{}m", mins)
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    /// Module-wide lock shared by every test that mutates `PATH` to install
712    /// a mock `ssh-keygen` or `vault` binary. Without this, parallel tests
713    /// race on the process-wide environment and one test's PATH restore
714    /// overwrites another's mock.
715    #[cfg(unix)]
716    static PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
717
718    #[test]
719    fn cert_path_for_simple_alias() {
720        let path = cert_path_for("webserver").unwrap();
721        assert!(path.ends_with("certs/webserver-cert.pub"));
722        assert!(path.to_string_lossy().contains(".purple/certs/"));
723    }
724
725    #[test]
726    fn cert_path_for_alias_with_prefix() {
727        let path = cert_path_for("aws-prod-web01").unwrap();
728        assert!(path.ends_with("certs/aws-prod-web01-cert.pub"));
729    }
730
731    /// Regression: a public key path that contains `=` would split the
732    /// `public_key=@<path>` argument mid-pair when handed to the Vault CLI and
733    /// produce a cryptic parse error. `sign_certificate` rejects such paths up
734    /// front so the user gets a clear actionable message instead.
735    #[test]
736    fn sign_certificate_rejects_pubkey_path_with_equals() {
737        let dir = std::env::temp_dir().join(format!(
738            "purple_test_pubkey_eq_{:?}",
739            std::thread::current().id()
740        ));
741        let _ = std::fs::remove_dir_all(&dir);
742        std::fs::create_dir_all(&dir).unwrap();
743        let bad = dir.join("key=foo.pub");
744        std::fs::write(&bad, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@test\n").unwrap();
745
746        let result = sign_certificate("ssh/sign/test", &bad, "alias", None);
747        let err = result.unwrap_err().to_string();
748        assert!(
749            err.contains('=') && err.contains("Vault CLI"),
750            "expected explicit `=` rejection, got: {}",
751            err
752        );
753        let _ = std::fs::remove_dir_all(&dir);
754    }
755
756    #[test]
757    fn sign_certificate_missing_pubkey() {
758        let result = sign_certificate(
759            "ssh/sign/test",
760            Path::new("/tmp/purple_nonexistent_key.pub"),
761            "test",
762            None,
763        );
764        assert!(result.is_err());
765        let err = result.unwrap_err().to_string();
766        assert!(err.contains("Public key not found"), "got: {}", err);
767    }
768
769    #[test]
770    fn sign_certificate_vault_not_configured() {
771        let tmpdir = std::env::temp_dir();
772        let fake_key = tmpdir.join("purple_test_vault_sign_key.pub");
773        std::fs::write(
774            &fake_key,
775            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@test\n",
776        )
777        .unwrap();
778
779        let result = sign_certificate("nonexistent/sign/role", &fake_key, "test-host", None);
780        assert!(result.is_err());
781        let err = result.unwrap_err().to_string();
782        assert!(
783            err.contains("vault") || err.contains("Vault") || err.contains("Failed"),
784            "Error should mention vault: {}",
785            err
786        );
787
788        let _ = std::fs::remove_file(&fake_key);
789    }
790
791    #[test]
792    fn parse_valid_line_standard() {
793        let line = "        Valid: from 2026-04-08T10:00:00 to 2026-04-09T10:00:00";
794        let (from, to) = parse_valid_line(line).unwrap();
795        assert!(from > 0);
796        assert!(to > from);
797        assert_eq!(to - from, 86400);
798    }
799
800    #[test]
801    fn parse_valid_line_no_match() {
802        assert!(parse_valid_line("        Type: ssh-ed25519-cert-v01@openssh.com").is_none());
803    }
804
805    #[test]
806    fn parse_valid_line_forever() {
807        let line = "        Valid: from 2026-04-08T10:00:00 to forever";
808        assert!(parse_valid_line(line).is_none());
809    }
810
811    #[test]
812    fn parse_ssh_datetime_valid() {
813        let epoch = parse_ssh_datetime("2026-04-08T12:00:00").unwrap();
814        assert!(epoch > 1_700_000_000);
815        assert!(epoch < 2_000_000_000);
816    }
817
818    #[test]
819    fn parse_ssh_datetime_invalid() {
820        assert!(parse_ssh_datetime("not-a-date").is_none());
821        assert!(parse_ssh_datetime("2026-13-08T12:00:00").is_none());
822    }
823
824    #[test]
825    fn check_cert_validity_missing() {
826        let path = Path::new("/tmp/purple_test_nonexistent_cert.pub");
827        assert_eq!(check_cert_validity(path), CertStatus::Missing);
828    }
829
830    #[test]
831    fn needs_renewal_missing() {
832        assert!(needs_renewal(&CertStatus::Missing));
833    }
834
835    #[test]
836    fn needs_renewal_expired() {
837        assert!(needs_renewal(&CertStatus::Expired));
838    }
839
840    #[test]
841    fn needs_renewal_invalid() {
842        assert!(needs_renewal(&CertStatus::Invalid("bad".to_string())));
843    }
844
845    #[test]
846    fn needs_renewal_valid_plenty_of_time() {
847        assert!(!needs_renewal(&CertStatus::Valid {
848            expires_at: 0,
849            remaining_secs: 3600,
850            total_secs: 3600,
851        }));
852    }
853
854    #[test]
855    fn needs_renewal_valid_under_threshold() {
856        assert!(needs_renewal(&CertStatus::Valid {
857            expires_at: 0,
858            remaining_secs: 60,
859            total_secs: 3600,
860        }));
861    }
862
863    #[test]
864    fn needs_renewal_at_threshold_boundary() {
865        // A freshly signed cert with remaining == threshold must NOT trigger
866        // renewal. Otherwise a cert whose TTL equals the threshold (or close
867        // to it) would be re-signed on every check, causing an infinite loop.
868        assert!(!needs_renewal(&CertStatus::Valid {
869            expires_at: 0,
870            remaining_secs: RENEWAL_THRESHOLD_SECS,
871            total_secs: 3600,
872        }));
873        // Just under the threshold is the renewal tipping point.
874        assert!(needs_renewal(&CertStatus::Valid {
875            expires_at: 0,
876            remaining_secs: RENEWAL_THRESHOLD_SECS - 1,
877            total_secs: 3600,
878        }));
879        // Above threshold: still valid.
880        assert!(!needs_renewal(&CertStatus::Valid {
881            expires_at: 0,
882            remaining_secs: RENEWAL_THRESHOLD_SECS + 1,
883            total_secs: 3600,
884        }));
885    }
886
887    #[test]
888    fn needs_renewal_short_ttl_freshly_signed_not_renewed() {
889        // Regression: a cert with a total TTL shorter than RENEWAL_THRESHOLD_SECS
890        // must not be flagged for renewal the instant it is signed. Prior to the
891        // fix this caused an infinite re-sign loop for sub-5-minute roles.
892        let total = 120i64; // 2-minute role
893        // Freshly signed: remaining ~= total.
894        assert!(!needs_renewal(&CertStatus::Valid {
895            expires_at: 0,
896            remaining_secs: total,
897            total_secs: total,
898        }));
899        // Half-life: still valid under the proportional threshold (total/2 = 60).
900        assert!(!needs_renewal(&CertStatus::Valid {
901            expires_at: 0,
902            remaining_secs: 61,
903            total_secs: total,
904        }));
905        // Under proportional threshold: renew.
906        assert!(needs_renewal(&CertStatus::Valid {
907            expires_at: 0,
908            remaining_secs: 30,
909            total_secs: total,
910        }));
911    }
912
913    #[test]
914    fn needs_renewal_total_zero_uses_fixed_threshold() {
915        // total_secs == 0 is unusual (forever certs use i64::MAX) but must
916        // not divide by zero or trigger the proportional path. Fall back to
917        // the fixed 5-minute threshold.
918        assert!(!needs_renewal(&CertStatus::Valid {
919            expires_at: 0,
920            remaining_secs: RENEWAL_THRESHOLD_SECS + 1,
921            total_secs: 0,
922        }));
923        assert!(needs_renewal(&CertStatus::Valid {
924            expires_at: 0,
925            remaining_secs: RENEWAL_THRESHOLD_SECS - 1,
926            total_secs: 0,
927        }));
928    }
929
930    #[test]
931    fn needs_renewal_total_one_uses_proportional_threshold() {
932        // total_secs == 1: proportional threshold is 1/2 == 0. With `<`
933        // comparison, remaining == 0 does NOT renew, which matches the
934        // "don't re-sign a cert that just expired on the client clock"
935        // intent. (CertStatus::Expired is the normal path for that.)
936        assert!(!needs_renewal(&CertStatus::Valid {
937            expires_at: 0,
938            remaining_secs: 1,
939            total_secs: 1,
940        }));
941    }
942
943    #[test]
944    fn needs_renewal_forever_cert_never_renews() {
945        // "Valid: forever" certs use i64::MAX for both remaining and total.
946        // These must never be flagged for renewal regardless of threshold.
947        assert!(!needs_renewal(&CertStatus::Valid {
948            expires_at: i64::MAX,
949            remaining_secs: i64::MAX,
950            total_secs: i64::MAX,
951        }));
952    }
953
954    #[test]
955    fn cert_error_backoff_is_shorter_than_normal_ttl() {
956        // The lazy cert-check loop picks a shorter TTL for Invalid entries so
957        // transient check failures recover quickly without hammering the
958        // background thread on every poll tick. This invariant is structural
959        // — if a future change swaps the constants the lazy-check branch in
960        // main.rs becomes useless. Enforced at compile time via const block.
961        const _: () = assert!(CERT_ERROR_BACKOFF_SECS < CERT_STATUS_CACHE_TTL_SECS);
962        const _: () = assert!(CERT_ERROR_BACKOFF_SECS >= 5);
963    }
964
965    #[test]
966    fn needs_renewal_negative_remaining_is_expired() {
967        // Defensive: a negative remaining (clock skew) falls under the
968        // normal threshold so the caller re-signs. check_cert_validity
969        // actually returns Expired in this case, but needs_renewal must
970        // also be correct standalone.
971        assert!(needs_renewal(&CertStatus::Valid {
972            expires_at: 0,
973            remaining_secs: -100,
974            total_secs: 3600,
975        }));
976    }
977
978    #[test]
979    fn needs_renewal_short_ttl_at_exact_threshold() {
980        // Boundary case: remaining == total/2 should NOT renew (uses `<`).
981        let total = 200i64;
982        assert!(!needs_renewal(&CertStatus::Valid {
983            expires_at: 0,
984            remaining_secs: 100,
985            total_secs: total,
986        }));
987        assert!(needs_renewal(&CertStatus::Valid {
988            expires_at: 0,
989            remaining_secs: 99,
990            total_secs: total,
991        }));
992    }
993
994    #[test]
995    fn resolve_pubkey_from_identity_file() {
996        let path = resolve_pubkey_path("~/.ssh/id_rsa").unwrap();
997        let s = path.to_string_lossy();
998        assert!(s.ends_with("id_rsa.pub"), "got: {}", s);
999        assert!(!s.contains('~'), "tilde should be expanded: {}", s);
1000    }
1001
1002    #[test]
1003    fn resolve_pubkey_already_pub_no_double_suffix() {
1004        let path = resolve_pubkey_path("~/.ssh/id_ed25519.pub").unwrap();
1005        let s = path.to_string_lossy();
1006        assert!(s.ends_with("id_ed25519.pub"), "got: {}", s);
1007        assert!(!s.ends_with(".pub.pub"), "double .pub suffix: {}", s);
1008    }
1009
1010    #[test]
1011    fn resolve_pubkey_empty_falls_back() {
1012        let path = resolve_pubkey_path("").unwrap();
1013        let s = path.to_string_lossy();
1014        assert!(s.ends_with("id_ed25519.pub"), "got: {}", s);
1015        assert!(s.contains(".ssh/"), "should be in .ssh dir: {}", s);
1016    }
1017
1018    #[test]
1019    fn resolve_pubkey_absolute_path_inside_home() {
1020        // An absolute path inside the user's home should be honored.
1021        let home = dirs::home_dir().expect("home dir");
1022        let abs = home.join(".ssh/deploy_key");
1023        let path = resolve_pubkey_path(abs.to_str().unwrap()).unwrap();
1024        let expected = home.join(".ssh/deploy_key.pub");
1025        assert_eq!(path, expected);
1026    }
1027
1028    #[test]
1029    fn resolve_vault_role_host_override() {
1030        let config = crate::providers::config::ProviderConfig::default();
1031        let role = resolve_vault_role(Some("ssh/sign/admin"), Some("aws"), &config);
1032        assert_eq!(role.as_deref(), Some("ssh/sign/admin"));
1033    }
1034
1035    // ---- is_valid_vault_addr tests ----
1036
1037    #[test]
1038    fn is_valid_vault_addr_accepts_typical_urls() {
1039        assert!(is_valid_vault_addr("http://127.0.0.1:8200"));
1040        assert!(is_valid_vault_addr("https://vault.example.com:8200"));
1041        assert!(is_valid_vault_addr("https://vault.internal/v1"));
1042    }
1043
1044    #[test]
1045    fn is_valid_vault_addr_rejects_empty_and_blank() {
1046        assert!(!is_valid_vault_addr(""));
1047        assert!(!is_valid_vault_addr("   "));
1048        assert!(!is_valid_vault_addr("\t"));
1049    }
1050
1051    #[test]
1052    fn is_valid_vault_addr_rejects_whitespace_inside() {
1053        assert!(!is_valid_vault_addr("http://host :8200"));
1054        assert!(!is_valid_vault_addr("http://host\t:8200"));
1055    }
1056
1057    #[test]
1058    fn is_valid_vault_addr_rejects_control_chars() {
1059        assert!(!is_valid_vault_addr("http://host\n8200"));
1060        assert!(!is_valid_vault_addr("http://host\r8200"));
1061        assert!(!is_valid_vault_addr("http://host\x00:8200"));
1062    }
1063
1064    #[test]
1065    fn is_valid_vault_addr_rejects_overlong() {
1066        let long = "http://".to_string() + &"a".repeat(600);
1067        assert!(!is_valid_vault_addr(&long));
1068    }
1069
1070    // ---- resolve_vault_addr tests ----
1071
1072    #[test]
1073    fn resolve_vault_addr_none_when_nothing_set() {
1074        let config = crate::providers::config::ProviderConfig::default();
1075        assert!(resolve_vault_addr(None, None, &config).is_none());
1076    }
1077
1078    #[test]
1079    fn resolve_vault_addr_uses_host_override() {
1080        let config = crate::providers::config::ProviderConfig::default();
1081        let addr = resolve_vault_addr(Some("http://127.0.0.1:8200"), Some("aws"), &config);
1082        assert_eq!(addr.as_deref(), Some("http://127.0.0.1:8200"));
1083    }
1084
1085    #[test]
1086    fn resolve_vault_addr_falls_back_to_provider() {
1087        let config = crate::providers::config::ProviderConfig::parse(
1088            "[aws]\ntoken=abc\nvault_addr=https://vault.example:8200\n",
1089        );
1090        let addr = resolve_vault_addr(None, Some("aws"), &config);
1091        assert_eq!(addr.as_deref(), Some("https://vault.example:8200"));
1092    }
1093
1094    #[test]
1095    fn resolve_vault_addr_host_beats_provider() {
1096        let config = crate::providers::config::ProviderConfig::parse(
1097            "[aws]\ntoken=abc\nvault_addr=https://provider:8200\n",
1098        );
1099        let addr = resolve_vault_addr(Some("http://host:8200"), Some("aws"), &config);
1100        assert_eq!(addr.as_deref(), Some("http://host:8200"));
1101    }
1102
1103    #[test]
1104    fn resolve_vault_addr_empty_host_falls_through_to_provider() {
1105        let config = crate::providers::config::ProviderConfig::parse(
1106            "[aws]\ntoken=abc\nvault_addr=https://provider:8200\n",
1107        );
1108        let addr = resolve_vault_addr(Some(""), Some("aws"), &config);
1109        assert_eq!(addr.as_deref(), Some("https://provider:8200"));
1110    }
1111
1112    #[test]
1113    fn resolve_vault_addr_whitespace_host_falls_through_to_provider() {
1114        let config = crate::providers::config::ProviderConfig::parse(
1115            "[aws]\ntoken=abc\nvault_addr=https://provider:8200\n",
1116        );
1117        let addr = resolve_vault_addr(Some("   "), Some("aws"), &config);
1118        assert_eq!(addr.as_deref(), Some("https://provider:8200"));
1119    }
1120
1121    #[test]
1122    fn resolve_vault_addr_normalizes_bare_host_input() {
1123        let config = crate::providers::config::ProviderConfig::default();
1124        let addr = resolve_vault_addr(Some("192.168.1.100"), None, &config);
1125        assert_eq!(addr.as_deref(), Some("https://192.168.1.100:8200"));
1126    }
1127
1128    #[test]
1129    fn resolve_vault_addr_normalizes_provider_bare_addr() {
1130        let config = crate::providers::config::ProviderConfig::parse(
1131            "[aws]\ntoken=abc\nvault_addr=vault.example\n",
1132        );
1133        let addr = resolve_vault_addr(None, Some("aws"), &config);
1134        assert_eq!(addr.as_deref(), Some("https://vault.example:8200"));
1135    }
1136
1137    // ---- normalize_vault_addr tests ----
1138
1139    #[test]
1140    fn normalize_vault_addr_bare_ip() {
1141        assert_eq!(
1142            normalize_vault_addr("192.168.1.100"),
1143            "https://192.168.1.100:8200"
1144        );
1145    }
1146
1147    #[test]
1148    fn normalize_vault_addr_bare_hostname() {
1149        assert_eq!(
1150            normalize_vault_addr("vault.local"),
1151            "https://vault.local:8200"
1152        );
1153    }
1154
1155    #[test]
1156    fn normalize_vault_addr_ip_with_port() {
1157        assert_eq!(
1158            normalize_vault_addr("192.168.1.100:8200"),
1159            "https://192.168.1.100:8200"
1160        );
1161    }
1162
1163    #[test]
1164    fn normalize_vault_addr_ip_with_custom_port() {
1165        assert_eq!(normalize_vault_addr("10.0.0.1:443"), "https://10.0.0.1:443");
1166    }
1167
1168    #[test]
1169    fn normalize_vault_addr_full_http_url() {
1170        assert_eq!(
1171            normalize_vault_addr("http://127.0.0.1:8200"),
1172            "http://127.0.0.1:8200"
1173        );
1174    }
1175
1176    #[test]
1177    fn normalize_vault_addr_full_https_url() {
1178        assert_eq!(
1179            normalize_vault_addr("https://vault.example.com:8200"),
1180            "https://vault.example.com:8200"
1181        );
1182    }
1183
1184    #[test]
1185    fn normalize_vault_addr_https_without_port() {
1186        assert_eq!(
1187            normalize_vault_addr("https://vault.example.com"),
1188            "https://vault.example.com:8200"
1189        );
1190    }
1191
1192    #[test]
1193    fn normalize_vault_addr_trims_whitespace() {
1194        assert_eq!(
1195            normalize_vault_addr("  10.0.0.1  "),
1196            "https://10.0.0.1:8200"
1197        );
1198    }
1199
1200    #[test]
1201    fn normalize_vault_addr_ipv6_bare() {
1202        assert_eq!(normalize_vault_addr("[::1]"), "https://[::1]:8200");
1203    }
1204
1205    #[test]
1206    fn normalize_vault_addr_ipv6_with_port() {
1207        assert_eq!(normalize_vault_addr("[::1]:8200"), "https://[::1]:8200");
1208    }
1209
1210    #[test]
1211    fn normalize_vault_addr_url_with_path_no_port() {
1212        assert_eq!(
1213            normalize_vault_addr("http://vault.host/v1"),
1214            "http://vault.host:8200/v1"
1215        );
1216    }
1217
1218    #[test]
1219    fn normalize_vault_addr_trailing_slash() {
1220        assert_eq!(
1221            normalize_vault_addr("http://vault.host/"),
1222            "http://vault.host:8200/"
1223        );
1224    }
1225
1226    #[test]
1227    fn normalize_vault_addr_uppercase_scheme() {
1228        assert_eq!(
1229            normalize_vault_addr("HTTP://vault.host"),
1230            "HTTP://vault.host:8200"
1231        );
1232    }
1233
1234    #[test]
1235    fn normalize_vault_addr_unknown_scheme_passthrough() {
1236        assert_eq!(normalize_vault_addr("ftp://vault.host"), "ftp://vault.host");
1237    }
1238
1239    #[test]
1240    fn normalize_vault_addr_ipv6_https_without_port() {
1241        assert_eq!(normalize_vault_addr("https://[::1]"), "https://[::1]:8200");
1242    }
1243
1244    #[test]
1245    fn normalize_vault_addr_https_custom_port() {
1246        assert_eq!(
1247            normalize_vault_addr("https://vault.host:9200"),
1248            "https://vault.host:9200"
1249        );
1250    }
1251
1252    // ---- end vault_addr tests ----
1253
1254    #[test]
1255    fn resolve_vault_role_provider_fallback() {
1256        let config = crate::providers::config::ProviderConfig::parse(
1257            "[aws]\ntoken=abc\nvault_role=ssh/sign/engineer\n",
1258        );
1259        let role = resolve_vault_role(None, Some("aws"), &config);
1260        assert_eq!(role.as_deref(), Some("ssh/sign/engineer"));
1261    }
1262
1263    #[test]
1264    fn resolve_vault_role_none_when_no_config() {
1265        let config = crate::providers::config::ProviderConfig::default();
1266        assert!(resolve_vault_role(None, None, &config).is_none());
1267    }
1268
1269    #[test]
1270    fn resolve_vault_role_none_when_provider_has_no_role() {
1271        let config = crate::providers::config::ProviderConfig::parse("[aws]\ntoken=abc\n");
1272        assert!(resolve_vault_role(None, Some("aws"), &config).is_none());
1273    }
1274
1275    #[test]
1276    fn resolve_vault_role_host_overrides_provider() {
1277        let config = crate::providers::config::ProviderConfig::parse(
1278            "[aws]\ntoken=abc\nvault_role=ssh/sign/default\n",
1279        );
1280        let role = resolve_vault_role(Some("ssh/sign/admin"), Some("aws"), &config);
1281        assert_eq!(role.as_deref(), Some("ssh/sign/admin"));
1282    }
1283
1284    #[test]
1285    fn format_remaining_hours() {
1286        assert_eq!(format_remaining(7200 + 900), "2h 15m");
1287    }
1288
1289    #[test]
1290    fn format_remaining_minutes_only() {
1291        assert_eq!(format_remaining(300), "5m");
1292    }
1293
1294    #[test]
1295    fn format_remaining_expired() {
1296        assert_eq!(format_remaining(0), "expired");
1297        assert_eq!(format_remaining(-100), "expired");
1298    }
1299
1300    #[test]
1301    fn resolve_cert_path_uses_certificate_file_when_set() {
1302        let path = resolve_cert_path("myhost", "~/.ssh/my-cert.pub").unwrap();
1303        let s = path.to_string_lossy();
1304        assert!(s.ends_with("my-cert.pub"), "got: {}", s);
1305        assert!(!s.contains('~'), "tilde should be expanded: {}", s);
1306    }
1307
1308    #[test]
1309    fn resolve_cert_path_falls_back_to_default() {
1310        let path = resolve_cert_path("myhost", "").unwrap();
1311        assert!(
1312            path.to_string_lossy()
1313                .contains(".purple/certs/myhost-cert.pub"),
1314            "got: {}",
1315            path.display()
1316        );
1317    }
1318
1319    #[test]
1320    fn resolve_cert_path_absolute() {
1321        let path = resolve_cert_path("myhost", "/etc/ssh/certs/myhost.pub").unwrap();
1322        assert_eq!(path, PathBuf::from("/etc/ssh/certs/myhost.pub"));
1323    }
1324
1325    #[test]
1326    fn cert_path_for_rejects_path_traversal() {
1327        assert!(cert_path_for("../../tmp/evil").is_err());
1328        assert!(cert_path_for("foo/bar").is_err());
1329        assert!(cert_path_for("foo\\bar").is_err());
1330        assert!(cert_path_for("host:22").is_err());
1331    }
1332
1333    #[test]
1334    fn cert_path_for_rejects_empty_alias() {
1335        assert!(cert_path_for("").is_err());
1336    }
1337
1338    #[test]
1339    fn sign_certificate_rejects_role_starting_with_dash() {
1340        let tmpdir = std::env::temp_dir();
1341        let fake_key = tmpdir.join("purple_test_dash_role.pub");
1342        std::fs::write(
1343            &fake_key,
1344            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@test\n",
1345        )
1346        .unwrap();
1347        let result = sign_certificate("-format=json", &fake_key, "test", None);
1348        assert!(result.is_err());
1349        assert!(
1350            result
1351                .unwrap_err()
1352                .to_string()
1353                .contains("Invalid Vault SSH role")
1354        );
1355        let _ = std::fs::remove_file(&fake_key);
1356    }
1357
1358    #[test]
1359    fn resolve_vault_role_empty_host_falls_through_to_provider() {
1360        let config = crate::providers::config::ProviderConfig::parse(
1361            "[aws]\ntoken=abc\nvault_role=ssh/sign/default\n",
1362        );
1363        let role = resolve_vault_role(Some(""), Some("aws"), &config);
1364        assert_eq!(role.as_deref(), Some("ssh/sign/default"));
1365    }
1366
1367    #[test]
1368    fn ensure_cert_returns_error_without_vault() {
1369        let tmpdir = std::env::temp_dir();
1370        let fake_key = tmpdir.join("purple_test_ensure_cert_key.pub");
1371        std::fs::write(
1372            &fake_key,
1373            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@test\n",
1374        )
1375        .unwrap();
1376
1377        let result = ensure_cert("ssh/sign/test", &fake_key, "ensure-test-host", "", None);
1378        // Should fail because vault CLI is not available
1379        assert!(result.is_err());
1380        let _ = std::fs::remove_file(&fake_key);
1381    }
1382
1383    #[test]
1384    fn parse_ssh_datetime_rejects_zero_month_and_day() {
1385        assert!(parse_ssh_datetime("2026-00-08T12:00:00").is_none());
1386        assert!(parse_ssh_datetime("2026-04-00T12:00:00").is_none());
1387    }
1388
1389    #[test]
1390    fn format_remaining_exactly_one_hour() {
1391        assert_eq!(format_remaining(3600), "1h 0m");
1392    }
1393
1394    #[test]
1395    fn cert_path_rejects_nul_byte() {
1396        assert!(cert_path_for("host\0name").is_err());
1397    }
1398
1399    #[test]
1400    fn is_valid_role_rejects_shell_metachars() {
1401        for bad in [
1402            "ssh/sign/role$x",
1403            "ssh/sign/role;rm",
1404            "ssh/sign/role|cat",
1405            "ssh/sign/role`id`",
1406            "ssh/sign/role&bg",
1407            "ssh/sign/role x",
1408            "ssh/sign/role\nx",
1409        ] {
1410            assert!(!is_valid_role(bad), "should reject {:?}", bad);
1411        }
1412    }
1413
1414    #[test]
1415    fn scrub_vault_stderr_redacts_all_marker_types() {
1416        let raw = "error contacting server\n\
1417                   x-vault-token: abcdef\n\
1418                   Authorization: Bearer xyz\n\
1419                   Cookie: session=1\n\
1420                   SECRET=foo\n\
1421                   token expired perhaps\n\
1422                   harmless trailing line";
1423        let out = scrub_vault_stderr(raw).to_ascii_lowercase();
1424        assert!(!out.contains("token"));
1425        assert!(!out.contains("x-vault-"));
1426        assert!(!out.contains("authorization"));
1427        assert!(!out.contains("cookie"));
1428        assert!(!out.contains("secret"));
1429    }
1430
1431    #[test]
1432    fn scrub_vault_stderr_truncation_bound() {
1433        let raw = "a".repeat(500);
1434        let out = scrub_vault_stderr(&raw);
1435        assert!(
1436            out.chars().count() <= 203,
1437            "len was {}",
1438            out.chars().count()
1439        );
1440        assert!(out.ends_with("..."));
1441    }
1442
1443    #[test]
1444    fn scrub_vault_stderr_default_when_all_filtered() {
1445        let raw = "token abc\nsecret def\nauthorization ghi";
1446        let out = scrub_vault_stderr(raw);
1447        assert_eq!(
1448            out,
1449            "Vault SSH signing failed. Check vault status and policy"
1450        );
1451    }
1452
1453    // TODO: resolve_pubkey_path_rejects_symlink_escape — requires mutating $HOME
1454    // for the current process, which races with other tests that read dirs::home_dir().
1455    // The canonicalize-based check is exercised manually; skipped here to keep the
1456    // test suite hermetic and parallel-safe.
1457
1458    #[test]
1459    fn is_valid_role_accepts_typical_paths() {
1460        assert!(is_valid_role("ssh/sign/engineer"));
1461        assert!(is_valid_role("ssh-ca/sign/admin_role"));
1462        assert!(is_valid_role("a"));
1463        assert!(is_valid_role(&"a".repeat(128)));
1464    }
1465
1466    #[test]
1467    fn is_valid_role_rejects_bad_input() {
1468        assert!(!is_valid_role(""));
1469        assert!(!is_valid_role("-format=json"));
1470        assert!(!is_valid_role("ssh/sign/role with space"));
1471        assert!(!is_valid_role("ssh/sign/role;rm"));
1472        assert!(!is_valid_role("ssh/sign/rôle"));
1473        assert!(!is_valid_role(&"a".repeat(129)));
1474    }
1475
1476    #[test]
1477    fn scrub_vault_stderr_drops_token_lines() {
1478        let raw = "error occurred\nX-Vault-Token: abc123\nrole missing\n";
1479        let out = scrub_vault_stderr(raw);
1480        assert!(!out.to_lowercase().contains("token"));
1481        assert!(out.contains("error occurred"));
1482        assert!(out.contains("role missing"));
1483    }
1484
1485    #[test]
1486    fn scrub_vault_stderr_drops_secret_and_authorization() {
1487        let raw = "line one\nsecret=foo\nAuthorization: Bearer x\nline four\n";
1488        let out = scrub_vault_stderr(raw);
1489        assert!(!out.to_lowercase().contains("secret"));
1490        assert!(!out.to_lowercase().contains("authorization"));
1491        assert!(out.contains("line one"));
1492        assert!(out.contains("line four"));
1493    }
1494
1495    #[test]
1496    fn scrub_vault_stderr_empty_falls_back() {
1497        let out = scrub_vault_stderr("");
1498        assert!(out.contains("Vault SSH signing failed"));
1499    }
1500
1501    #[test]
1502    fn scrub_vault_stderr_only_filtered_falls_back() {
1503        let out = scrub_vault_stderr("X-Vault-Token: abc\nSecret: xyz\n");
1504        assert!(out.contains("Vault SSH signing failed"));
1505    }
1506
1507    #[test]
1508    fn scrub_vault_stderr_truncates_long_output() {
1509        let raw = "x".repeat(500);
1510        let out = scrub_vault_stderr(&raw);
1511        assert!(out.ends_with("..."));
1512        // 200 chars + "..."
1513        assert_eq!(out.chars().count(), 203);
1514    }
1515
1516    #[test]
1517    fn resolve_pubkey_rejects_path_outside_home() {
1518        // Absolute path outside home should fall back to default in ~/.ssh
1519        let path = resolve_pubkey_path("/etc/passwd").unwrap();
1520        let s = path.to_string_lossy();
1521        assert!(s.ends_with("id_ed25519.pub"), "got: {}", s);
1522        assert!(s.contains(".ssh/"), "should be fallback: {}", s);
1523    }
1524
1525    #[cfg(unix)]
1526    fn unique_tmp_subdir(tag: &str) -> PathBuf {
1527        use std::time::{SystemTime, UNIX_EPOCH};
1528        let nanos = SystemTime::now()
1529            .duration_since(UNIX_EPOCH)
1530            .map(|d| d.as_nanos())
1531            .unwrap_or(0);
1532        let dir = std::env::temp_dir().join(format!(
1533            "purple_mock_vault_{}_{}_{}",
1534            tag,
1535            std::process::id(),
1536            nanos
1537        ));
1538        std::fs::create_dir_all(&dir).unwrap();
1539        dir
1540    }
1541
1542    #[cfg(unix)]
1543    fn with_mock_vault<F: FnOnce()>(tag: &str, stderr: &str, stdout: &str, exit_code: i32, f: F) {
1544        use std::os::unix::fs::PermissionsExt;
1545        // Use the module-wide PATH_LOCK so vault-mocking tests don't race
1546        // against ssh-keygen-mocking tests (both mutate the same PATH).
1547        let _guard = PATH_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1548
1549        let dir = unique_tmp_subdir(tag);
1550        let script = dir.join("vault");
1551        let escape = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
1552        let body = format!(
1553            "#!/bin/sh\nprintf '%s' \"{}\" >&2\nprintf '%s' \"{}\"\nexit {}\n",
1554            escape(stderr),
1555            escape(stdout),
1556            exit_code
1557        );
1558        std::fs::write(&script, body).unwrap();
1559        let mut perms = std::fs::metadata(&script).unwrap().permissions();
1560        perms.set_mode(0o755);
1561        std::fs::set_permissions(&script, perms).unwrap();
1562
1563        let old_path = std::env::var("PATH").unwrap_or_default();
1564        let new_path = format!("{}:{}", dir.display(), old_path);
1565        // SAFETY: std::env::set_var is unsound in multi-threaded processes
1566        // (rust-lang/rust#27970). The invariant we uphold here is: all mutations
1567        // of PATH within this test binary happen through `with_mock_vault`, which
1568        // holds the process-wide `LOCK` for the full mutate/use/restore cycle.
1569        // No other test in this crate reads or writes PATH concurrently. If a
1570        // future test introduces another PATH writer, it MUST acquire this same
1571        // LOCK. PATH is restored before the guard is dropped.
1572        unsafe { std::env::set_var("PATH", &new_path) };
1573        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
1574        unsafe { std::env::set_var("PATH", &old_path) };
1575        let _ = std::fs::remove_dir_all(&dir);
1576        if let Err(e) = result {
1577            std::panic::resume_unwind(e);
1578        }
1579    }
1580
1581    #[cfg(unix)]
1582    fn write_fake_pubkey(tag: &str) -> PathBuf {
1583        let dir = unique_tmp_subdir(tag);
1584        let p = dir.join("fake.pub");
1585        std::fs::write(&p, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@test\n").unwrap();
1586        p
1587    }
1588
1589    #[cfg(unix)]
1590    #[test]
1591    fn sign_certificate_permission_denied_maps_to_friendly_error() {
1592        let key = write_fake_pubkey("perm_denied");
1593        let alias = "mock-perm-denied";
1594        with_mock_vault(
1595            "perm_denied",
1596            "Error making API request.\npermission denied",
1597            "",
1598            1,
1599            || {
1600                let result = sign_certificate("ssh/sign/role", &key, alias, None);
1601                let err = result.unwrap_err().to_string();
1602                assert!(err.contains("Vault SSH permission denied"), "got: {}", err);
1603            },
1604        );
1605        let _ = std::fs::remove_file(&key);
1606    }
1607
1608    #[cfg(unix)]
1609    #[test]
1610    fn sign_certificate_token_expired_maps_to_friendly_error() {
1611        let key = write_fake_pubkey("tok_exp");
1612        let alias = "mock-tok-exp";
1613        with_mock_vault("tok_exp", "missing client token", "", 1, || {
1614            let result = sign_certificate("ssh/sign/role", &key, alias, None);
1615            let err = result.unwrap_err().to_string();
1616            assert!(err.contains("token missing or expired"), "got: {}", err);
1617        });
1618        let _ = std::fs::remove_file(&key);
1619    }
1620
1621    #[cfg(unix)]
1622    #[test]
1623    fn sign_certificate_scrubs_sensitive_stderr() {
1624        let key = write_fake_pubkey("scrub");
1625        let alias = "mock-scrub";
1626        with_mock_vault(
1627            "scrub",
1628            "role not configured\nX-Vault-Token: hvs.ABCDEFG",
1629            "",
1630            1,
1631            || {
1632                let result = sign_certificate("ssh/sign/role", &key, alias, None);
1633                let err = result.unwrap_err().to_string();
1634                assert!(!err.contains("hvs.ABCDEFG"), "leaked token: {}", err);
1635                assert!(!err.contains("X-Vault-Token"), "leaked header: {}", err);
1636            },
1637        );
1638        let _ = std::fs::remove_file(&key);
1639    }
1640
1641    #[cfg(unix)]
1642    #[test]
1643    fn sign_certificate_empty_stdout_errors() {
1644        let key = write_fake_pubkey("empty");
1645        let alias = "mock-empty";
1646        with_mock_vault("empty", "", "", 0, || {
1647            let result = sign_certificate("ssh/sign/role", &key, alias, None);
1648            let err = result.unwrap_err().to_string();
1649            assert!(err.contains("empty certificate"), "got: {}", err);
1650        });
1651        let _ = std::fs::remove_file(&key);
1652    }
1653
1654    #[cfg(unix)]
1655    #[test]
1656    fn sign_certificate_generic_failure_no_stderr() {
1657        let key = write_fake_pubkey("generic");
1658        let alias = "mock-generic";
1659        with_mock_vault("generic", "", "", 1, || {
1660            let result = sign_certificate("ssh/sign/role", &key, alias, None);
1661            let err = result.unwrap_err().to_string();
1662            assert!(err.contains("Vault SSH failed"), "got: {}", err);
1663        });
1664        let _ = std::fs::remove_file(&key);
1665    }
1666
1667    #[cfg(unix)]
1668    #[test]
1669    fn sign_certificate_success_writes_cert() {
1670        let key = write_fake_pubkey("success");
1671        let alias = "mock-success-host";
1672        let expected_cert = "ssh-ed25519-cert-v01@openssh.com AAAAFAKECERT test";
1673        with_mock_vault("success", "", expected_cert, 0, || {
1674            let result = sign_certificate("ssh/sign/role", &key, alias, None).unwrap();
1675            assert!(result.cert_path.exists());
1676            let content = std::fs::read_to_string(&result.cert_path).unwrap();
1677            assert_eq!(content, expected_cert);
1678            let _ = std::fs::remove_file(&result.cert_path);
1679        });
1680        let _ = std::fs::remove_file(&key);
1681    }
1682
1683    /// Install a mock `vault` binary that captures `$VAULT_ADDR` into a file
1684    /// and echoes a dummy cert on stdout. Returns the capture file path so
1685    /// callers can assert on the recorded value.
1686    #[cfg(unix)]
1687    fn with_env_capturing_vault<F: FnOnce(&Path)>(tag: &str, f: F) {
1688        use std::os::unix::fs::PermissionsExt;
1689        let _guard = PATH_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1690
1691        let dir = unique_tmp_subdir(tag);
1692        let capture = dir.join("captured_addr.txt");
1693        let script = dir.join("vault");
1694        // The mock writes VAULT_ADDR to the capture file (empty if unset)
1695        // and prints a dummy cert to stdout so sign_certificate's
1696        // "signed_key empty" guard does not trip.
1697        let body = format!(
1698            "#!/bin/sh\nprintf '%s' \"${{VAULT_ADDR-}}\" > {}\nprintf '%s' 'ssh-ed25519-cert-v01@openssh.com AAAAMOCKCERT mock'\nexit 0\n",
1699            capture.display()
1700        );
1701        std::fs::write(&script, body).unwrap();
1702        let mut perms = std::fs::metadata(&script).unwrap().permissions();
1703        perms.set_mode(0o755);
1704        std::fs::set_permissions(&script, perms).unwrap();
1705
1706        let old_path = std::env::var("PATH").unwrap_or_default();
1707        let old_vault_addr = std::env::var("VAULT_ADDR").ok();
1708        let new_path = format!("{}:{}", dir.display(), old_path);
1709        // SAFETY: see with_mock_vault — PATH_LOCK serializes all env mutations
1710        // in this test module. We clear VAULT_ADDR up front so the
1711        // "None = inherit parent env" test starts from a clean slate.
1712        unsafe {
1713            std::env::set_var("PATH", &new_path);
1714            std::env::remove_var("VAULT_ADDR");
1715        }
1716        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&capture)));
1717        unsafe {
1718            std::env::set_var("PATH", &old_path);
1719            match old_vault_addr {
1720                Some(v) => std::env::set_var("VAULT_ADDR", v),
1721                None => std::env::remove_var("VAULT_ADDR"),
1722            }
1723        }
1724        let _ = std::fs::remove_dir_all(&dir);
1725        if let Err(e) = result {
1726            std::panic::resume_unwind(e);
1727        }
1728    }
1729
1730    #[cfg(unix)]
1731    #[test]
1732    fn sign_certificate_sets_vault_addr_env_on_subprocess() {
1733        let key = write_fake_pubkey("addr_set");
1734        let alias = "mock-addr-set";
1735        with_env_capturing_vault("addr_set", |capture| {
1736            let res = sign_certificate(
1737                "ssh/sign/role",
1738                &key,
1739                alias,
1740                Some("http://override.example:8200"),
1741            );
1742            assert!(res.is_ok(), "sign failed: {:?}", res);
1743            let captured = std::fs::read_to_string(capture).unwrap();
1744            assert_eq!(
1745                captured, "http://override.example:8200",
1746                "subprocess did not receive the overridden VAULT_ADDR"
1747            );
1748            if let Ok(r) = res {
1749                let _ = std::fs::remove_file(&r.cert_path);
1750            }
1751        });
1752        let _ = std::fs::remove_file(&key);
1753    }
1754
1755    #[cfg(unix)]
1756    #[test]
1757    fn sign_certificate_does_not_set_vault_addr_when_none() {
1758        let key = write_fake_pubkey("addr_none");
1759        let alias = "mock-addr-none";
1760        with_env_capturing_vault("addr_none", |capture| {
1761            // with_env_capturing_vault clears VAULT_ADDR on entry, so when
1762            // sign_certificate passes None the subprocess inherits an empty
1763            // value. Assert exactly that — no override leaked through.
1764            let res = sign_certificate("ssh/sign/role", &key, alias, None);
1765            assert!(res.is_ok(), "sign failed: {:?}", res);
1766            let captured = std::fs::read_to_string(capture).unwrap();
1767            assert!(
1768                captured.is_empty(),
1769                "subprocess saw unexpected VAULT_ADDR: {:?}",
1770                captured
1771            );
1772            if let Ok(r) = res {
1773                let _ = std::fs::remove_file(&r.cert_path);
1774            }
1775        });
1776        let _ = std::fs::remove_file(&key);
1777    }
1778
1779    #[cfg(unix)]
1780    #[test]
1781    fn sign_certificate_rejects_invalid_vault_addr() {
1782        // An invalid vault_addr (whitespace inside) must be rejected with a
1783        // clear error before spawning the vault CLI.
1784        let key = write_fake_pubkey("addr_bad");
1785        let alias = "mock-addr-bad";
1786        let res = sign_certificate("ssh/sign/role", &key, alias, Some("http://has space:8200"));
1787        assert!(res.is_err());
1788        let msg = res.unwrap_err().to_string();
1789        assert!(
1790            msg.contains("Invalid VAULT_ADDR"),
1791            "expected explicit rejection, got: {}",
1792            msg
1793        );
1794        let _ = std::fs::remove_file(&key);
1795    }
1796
1797    #[cfg(unix)]
1798    #[test]
1799    fn check_cert_validity_handles_forever() {
1800        use std::os::unix::fs::PermissionsExt;
1801        let _guard = PATH_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1802
1803        let dir = unique_tmp_subdir("forever");
1804        let script = dir.join("ssh-keygen");
1805        let body = "#!/bin/sh\nprintf '%s\\n' '        Type: ssh-ed25519-cert-v01@openssh.com'\nprintf '%s\\n' '        Valid: forever'\nexit 0\n";
1806        std::fs::write(&script, body).unwrap();
1807        let mut perms = std::fs::metadata(&script).unwrap().permissions();
1808        perms.set_mode(0o755);
1809        std::fs::set_permissions(&script, perms).unwrap();
1810        let cert = dir.join("cert.pub");
1811        std::fs::write(&cert, "stub").unwrap();
1812
1813        let old_path = std::env::var("PATH").unwrap_or_default();
1814        let new_path = format!("{}:{}", dir.display(), old_path);
1815        // SAFETY: PATH mutation is serialized via LOCK above and restored before
1816        // the guard is released.
1817        unsafe { std::env::set_var("PATH", &new_path) };
1818        let status = check_cert_validity(&cert);
1819        unsafe { std::env::set_var("PATH", &old_path) };
1820        let _ = std::fs::remove_dir_all(&dir);
1821
1822        match status {
1823            CertStatus::Valid {
1824                remaining_secs,
1825                total_secs,
1826                expires_at,
1827            } => {
1828                assert_eq!(remaining_secs, i64::MAX);
1829                assert_eq!(total_secs, i64::MAX);
1830                assert_eq!(expires_at, i64::MAX);
1831            }
1832            other => panic!("expected Valid(forever), got {:?}", other),
1833        }
1834    }
1835
1836    #[cfg(unix)]
1837    #[test]
1838    fn check_cert_validity_rejects_non_positive_window() {
1839        // Regression: a malformed cert with `to < from` would produce a
1840        // negative total_secs that flowed into the needs_renewal threshold
1841        // calculation. The guard in check_cert_validity must reject it as
1842        // Invalid before it ever reaches the cache.
1843        use std::os::unix::fs::PermissionsExt;
1844        let _guard = PATH_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1845
1846        let dir = unique_tmp_subdir("non_positive");
1847        let script = dir.join("ssh-keygen");
1848        // Valid window with `to` == `from`, producing ttl == 0.
1849        let body = "#!/bin/sh\nprintf '%s\\n' '        Type: ssh-ed25519-cert-v01@openssh.com'\nprintf '%s\\n' '        Valid: from 2026-01-01T00:00:00 to 2026-01-01T00:00:00'\nexit 0\n";
1850        std::fs::write(&script, body).unwrap();
1851        let mut perms = std::fs::metadata(&script).unwrap().permissions();
1852        perms.set_mode(0o755);
1853        std::fs::set_permissions(&script, perms).unwrap();
1854        let cert = dir.join("cert.pub");
1855        std::fs::write(&cert, "stub").unwrap();
1856
1857        let old_path = std::env::var("PATH").unwrap_or_default();
1858        let new_path = format!("{}:{}", dir.display(), old_path);
1859        // SAFETY: see with_mock_vault for the full invariant. PATH is
1860        // serialized via LOCK and restored before the guard is released.
1861        unsafe { std::env::set_var("PATH", &new_path) };
1862        let status = check_cert_validity(&cert);
1863        unsafe { std::env::set_var("PATH", &old_path) };
1864        let _ = std::fs::remove_dir_all(&dir);
1865
1866        match status {
1867            CertStatus::Invalid(msg) => {
1868                assert!(
1869                    msg.contains("non-positive"),
1870                    "expected non-positive window error, got: {}",
1871                    msg
1872                );
1873            }
1874            other => panic!("expected Invalid, got {:?}", other),
1875        }
1876    }
1877
1878    #[test]
1879    fn is_valid_role_rejects_spaces_and_shell_metacharacters() {
1880        assert!(!is_valid_role(""));
1881        assert!(!is_valid_role("bad role"));
1882        assert!(!is_valid_role("role;rm"));
1883        assert!(!is_valid_role("role$(x)"));
1884        assert!(!is_valid_role("role|cat"));
1885        assert!(!is_valid_role("role`id`"));
1886        assert!(!is_valid_role("role&bg"));
1887        assert!(!is_valid_role("role\nx"));
1888        // "Missing /sign/" is not structurally enforced by is_valid_role (the
1889        // Vault CLI validates the mount), but character rules still pass:
1890        assert!(is_valid_role("ssh/engineer"));
1891    }
1892
1893    #[test]
1894    fn resolve_vault_role_host_overrides_provider_default() {
1895        let config = crate::providers::config::ProviderConfig::parse(
1896            "[aws]\ntoken=abc\nvault_role=ssh/sign/default\n",
1897        );
1898        let role = resolve_vault_role(Some("ssh/sign/override"), Some("aws"), &config);
1899        assert_eq!(role.as_deref(), Some("ssh/sign/override"));
1900    }
1901
1902    #[test]
1903    fn resolve_vault_role_falls_back_to_provider_when_host_empty() {
1904        let config = crate::providers::config::ProviderConfig::parse(
1905            "[aws]\ntoken=abc\nvault_role=ssh/sign/default\n",
1906        );
1907        let role = resolve_vault_role(None, Some("aws"), &config);
1908        assert_eq!(role.as_deref(), Some("ssh/sign/default"));
1909    }
1910
1911    #[test]
1912    fn resolve_vault_role_returns_none_when_neither_set() {
1913        let config = crate::providers::config::ProviderConfig::default();
1914        assert!(resolve_vault_role(None, Some("aws"), &config).is_none());
1915        assert!(resolve_vault_role(None, None, &config).is_none());
1916    }
1917
1918    #[cfg(unix)]
1919    #[test]
1920    fn check_cert_validity_invalid_file() {
1921        // Use a mock ssh-keygen that exits with failure, because the real
1922        // ssh-keygen behavior on non-certificate files varies across
1923        // platforms (macOS returns Invalid, some Linux versions return Valid).
1924        use std::os::unix::fs::PermissionsExt;
1925        let _guard = PATH_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1926
1927        let dir = unique_tmp_subdir("invalid_file");
1928        let script = dir.join("ssh-keygen");
1929        let body = "#!/bin/sh\necho 'is not a certificate' >&2\nexit 1\n";
1930        std::fs::write(&script, body).unwrap();
1931        let mut perms = std::fs::metadata(&script).unwrap().permissions();
1932        perms.set_mode(0o755);
1933        std::fs::set_permissions(&script, perms).unwrap();
1934        let cert = dir.join("cert.pub");
1935        std::fs::write(&cert, "this is not a certificate\n").unwrap();
1936
1937        let old_path = std::env::var("PATH").unwrap_or_default();
1938        let new_path = format!("{}:{}", dir.display(), old_path);
1939        unsafe { std::env::set_var("PATH", &new_path) };
1940        let status = check_cert_validity(&cert);
1941        unsafe { std::env::set_var("PATH", &old_path) };
1942        let _ = std::fs::remove_dir_all(&dir);
1943
1944        assert!(
1945            matches!(status, CertStatus::Invalid(_)),
1946            "Expected Invalid, got: {:?}",
1947            status
1948        );
1949    }
1950}