1use anyhow::{Context, Result};
2use log::{debug, error, info};
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[derive(Debug)]
8pub struct SignResult {
9 pub cert_path: PathBuf,
10}
11
12#[derive(Debug, Clone, PartialEq)]
14pub enum CertStatus {
15 Valid {
16 expires_at: i64,
17 remaining_secs: i64,
18 total_secs: i64,
21 },
22 Expired,
23 Missing,
24 Invalid(String),
25}
26
27pub const RENEWAL_THRESHOLD_SECS: i64 = 300;
29
30pub const CERT_STATUS_CACHE_TTL_SECS: u64 = 300;
36
37pub const CERT_ERROR_BACKOFF_SECS: u64 = 30;
42
43pub 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
52pub 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
65pub fn normalize_vault_addr(s: &str) -> String {
71 let trimmed = s.trim();
72 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 return trimmed.to_string();
81 } else {
82 (format!("https://{}", trimmed), 8)
83 };
84 let after_scheme = &with_scheme[scheme_len..];
86 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
87 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 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
107pub 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
134pub 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
152pub 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
171pub 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 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 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 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 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 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 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 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
389pub 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 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; if ttl <= 0 {
435 return CertStatus::Invalid(
436 "certificate has non-positive validity window".to_string(),
437 );
438 }
439
440 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 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
478fn 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
492fn 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 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
541pub 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
565pub 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
586pub 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 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
633pub 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
657pub 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
693pub 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 #[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 #[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 assert!(!needs_renewal(&CertStatus::Valid {
869 expires_at: 0,
870 remaining_secs: RENEWAL_THRESHOLD_SECS,
871 total_secs: 3600,
872 }));
873 assert!(needs_renewal(&CertStatus::Valid {
875 expires_at: 0,
876 remaining_secs: RENEWAL_THRESHOLD_SECS - 1,
877 total_secs: 3600,
878 }));
879 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 let total = 120i64; assert!(!needs_renewal(&CertStatus::Valid {
895 expires_at: 0,
896 remaining_secs: total,
897 total_secs: total,
898 }));
899 assert!(!needs_renewal(&CertStatus::Valid {
901 expires_at: 0,
902 remaining_secs: 61,
903 total_secs: total,
904 }));
905 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 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 #[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 assert_eq!(out.chars().count(), 203);
1514 }
1515
1516 #[test]
1517 fn resolve_pubkey_rejects_path_outside_home() {
1518 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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}