1use anyhow::{Context, Result};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5#[derive(Debug)]
7pub struct SignResult {
8 pub cert_path: PathBuf,
9}
10
11#[derive(Debug, Clone, PartialEq)]
13pub enum CertStatus {
14 Valid {
15 expires_at: i64,
16 remaining_secs: i64,
17 total_secs: i64,
20 },
21 Expired,
22 Missing,
23 Invalid(String),
24}
25
26pub const RENEWAL_THRESHOLD_SECS: i64 = 300;
28
29pub const CERT_STATUS_CACHE_TTL_SECS: u64 = 300;
35
36pub const CERT_ERROR_BACKOFF_SECS: u64 = 30;
41
42pub 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
51pub 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
64pub fn normalize_vault_addr(s: &str) -> String {
70 let trimmed = s.trim();
71 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 return trimmed.to_string();
80 } else {
81 (format!("https://{}", trimmed), 8)
82 };
83 let after_scheme = &with_scheme[scheme_len..];
85 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
86 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 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
106pub 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
133pub 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
151pub 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
170pub 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 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 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 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 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 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 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 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
344pub 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 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; if ttl <= 0 {
390 return CertStatus::Invalid(
391 "certificate has non-positive validity window".to_string(),
392 );
393 }
394
395 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 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
433fn 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
447fn 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 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
496pub 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
520pub 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
540pub 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 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
587pub 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
611pub 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
647pub 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 #[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 #[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 assert!(!needs_renewal(&CertStatus::Valid {
823 expires_at: 0,
824 remaining_secs: RENEWAL_THRESHOLD_SECS,
825 total_secs: 3600,
826 }));
827 assert!(needs_renewal(&CertStatus::Valid {
829 expires_at: 0,
830 remaining_secs: RENEWAL_THRESHOLD_SECS - 1,
831 total_secs: 3600,
832 }));
833 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 let total = 120i64; assert!(!needs_renewal(&CertStatus::Valid {
849 expires_at: 0,
850 remaining_secs: total,
851 total_secs: total,
852 }));
853 assert!(!needs_renewal(&CertStatus::Valid {
855 expires_at: 0,
856 remaining_secs: 61,
857 total_secs: total,
858 }));
859 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 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 #[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 assert_eq!(out.chars().count(), 203);
1468 }
1469
1470 #[test]
1471 fn resolve_pubkey_rejects_path_outside_home() {
1472 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 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 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 #[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 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 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 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 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 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 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 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 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 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}