Skip to main content

purple_ssh/
vault_ssh.rs

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