1use secrecy::SecretString;
12use serde::{Deserialize, Serialize};
13use std::path::PathBuf;
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum SecretRef {
17 Env(String),
18 Keychain { service: String, account: String },
19 File(PathBuf),
20 Cmd(String),
21}
22
23#[derive(thiserror::Error, Debug)]
24pub enum SecretError {
25 #[error("env var {0} not set")]
26 EnvNotSet(String),
27 #[error("keychain item not found: {service}/{account}")]
28 KeychainNotFound { service: String, account: String },
29 #[error("keychain backend error: {0}")]
30 KeychainBackend(String),
31 #[error("read file {path}: {source}")]
32 FileRead {
33 path: String,
34 #[source]
35 source: std::io::Error,
36 },
37 #[error("file mode is not 0600: {0}")]
38 FileMode(String),
39 #[error("decrypt {0}")]
40 AgeDecrypt(String),
41 #[error("cmd {cmd} exited with {status}")]
42 Cmd { cmd: String, status: i32 },
43 #[error("invalid SecretRef syntax: {0}")]
44 Parse(String),
45}
46
47static PRESEAL_CACHE: std::sync::OnceLock<std::sync::Mutex<Vec<(SecretRef, SecretString)>>> =
65 std::sync::OnceLock::new();
66
67fn preseal_cache() -> &'static std::sync::Mutex<Vec<(SecretRef, SecretString)>> {
68 PRESEAL_CACHE.get_or_init(|| std::sync::Mutex::new(Vec::new()))
69}
70
71pub fn cache_before_seal(r: &SecretRef) -> Result<(), SecretError> {
76 let v = r.resolve_blocking()?;
77 let mut c = preseal_cache().lock().unwrap_or_else(|e| e.into_inner());
78 if !c.iter().any(|(k, _)| k == r) {
79 c.push((r.clone(), v));
80 }
81 Ok(())
82}
83
84pub fn preseal_cached_count() -> usize {
86 preseal_cache()
87 .lock()
88 .map(|c| c.len())
89 .unwrap_or_else(|e| e.into_inner().len())
90}
91
92fn preseal_lookup(r: &SecretRef) -> Option<SecretString> {
93 let c = preseal_cache().lock().unwrap_or_else(|e| e.into_inner());
94 c.iter().find(|(k, _)| k == r).map(|(_, v)| v.clone())
95}
96
97impl std::fmt::Display for SecretRef {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 match self {
100 SecretRef::Env(v) => write!(f, "env:{v}"),
101 SecretRef::Keychain { service, account } => {
102 write!(f, "keychain:{service}/{account}")
103 }
104 SecretRef::File(p) => write!(f, "file:{}", p.display()),
105 SecretRef::Cmd(c) => write!(f, "cmd:{c}"),
106 }
107 }
108}
109
110impl std::str::FromStr for SecretRef {
111 type Err = SecretError;
112 fn from_str(s: &str) -> Result<Self, Self::Err> {
113 let (scheme, rest) = s
114 .split_once(':')
115 .ok_or_else(|| SecretError::Parse(format!("missing scheme: {s}")))?;
116 match scheme {
117 "env" => Ok(SecretRef::Env(rest.to_string())),
118 "keychain" => {
119 let (service, account) = rest.split_once('/').ok_or_else(|| {
120 SecretError::Parse(format!("keychain ref needs service/account: {s}"))
121 })?;
122 Ok(SecretRef::Keychain {
123 service: service.to_string(),
124 account: account.to_string(),
125 })
126 }
127 "file" => Ok(SecretRef::File(PathBuf::from(rest))),
128 "cmd" => Ok(SecretRef::Cmd(rest.to_string())),
129 other => Err(SecretError::Parse(format!("unknown scheme: {other}"))),
130 }
131 }
132}
133
134impl Serialize for SecretRef {
135 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
136 s.collect_str(self)
137 }
138}
139
140impl<'de> Deserialize<'de> for SecretRef {
141 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
142 let s = String::deserialize(d)?;
143 s.parse().map_err(serde::de::Error::custom)
144 }
145}
146
147pub const ENV_KEYCHAIN_DISABLED: &str = "MUR_KEYCHAIN_DISABLED";
151pub const ENV_KEYCHAIN_ALLOW: &str = "MUR_KEYCHAIN_ALLOW";
154
155fn keychain_blocked() -> bool {
161 if std::env::var_os(ENV_KEYCHAIN_ALLOW).is_some() {
162 return false;
163 }
164 std::env::var_os(ENV_KEYCHAIN_DISABLED).is_some() || std::env::var_os("NEXTEST").is_some()
165}
166
167impl SecretRef {
168 pub async fn resolve(&self) -> Result<SecretString, SecretError> {
169 match self {
170 SecretRef::Env(var) => std::env::var(var)
171 .map(SecretString::from)
172 .map_err(|_| SecretError::EnvNotSet(var.clone())),
173 SecretRef::Keychain { service, account } if keychain_blocked() => {
174 Err(SecretError::KeychainNotFound {
175 service: service.clone(),
176 account: account.clone(),
177 })
178 }
179 SecretRef::Keychain { service, account } => {
180 let svc = service.clone();
181 let acct = account.clone();
182 let res = tokio::task::spawn_blocking(move || -> Result<String, SecretError> {
183 let entry = keyring::Entry::new(&svc, &acct)
184 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
185 match entry.get_password() {
186 Ok(s) => Ok(s),
187 Err(keyring::Error::NoEntry) => Err(SecretError::KeychainNotFound {
188 service: svc.clone(),
189 account: acct.clone(),
190 }),
191 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
192 }
193 })
194 .await
195 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?;
196 res.map(SecretString::from)
197 }
198 SecretRef::File(path) => resolve_file(path).await,
199 SecretRef::Cmd(spec) => resolve_cmd(spec).await,
200 }
201 }
202
203 pub async fn check(&self) -> bool {
207 self.resolve().await.is_ok()
208 }
209
210 pub async fn resolve_to_string(&self) -> Option<String> {
216 use secrecy::ExposeSecret;
217 self.resolve()
218 .await
219 .ok()
220 .map(|s| s.expose_secret().to_string())
221 }
222
223 pub fn resolve_preseal_cached(&self) -> Option<SecretString> {
235 preseal_lookup(self)
236 }
237
238 pub fn resolve_blocking(&self) -> Result<SecretString, SecretError> {
239 if let Some(v) = preseal_lookup(self) {
244 return Ok(v);
245 }
246 fn fresh_runtime_resolve(r: &SecretRef) -> Result<SecretString, SecretError> {
247 tokio::runtime::Builder::new_current_thread()
248 .enable_all()
249 .build()
250 .map_err(|e| SecretError::KeychainBackend(format!("runtime: {e}")))?
251 .block_on(r.resolve())
252 }
253 match tokio::runtime::Handle::try_current() {
254 Ok(h) if h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
255 tokio::task::block_in_place(|| h.block_on(self.resolve()))
256 }
257 Ok(_) => std::thread::scope(|s| {
260 s.spawn(|| fresh_runtime_resolve(self))
261 .join()
262 .unwrap_or_else(|_| {
263 Err(SecretError::KeychainBackend(
264 "resolver thread panicked".into(),
265 ))
266 })
267 }),
268 Err(_) => fresh_runtime_resolve(self),
269 }
270 }
271
272 pub fn resolve_to_string_blocking(&self) -> Option<String> {
275 use secrecy::ExposeSecret;
276 self.resolve_blocking()
277 .ok()
278 .map(|s| s.expose_secret().to_string())
279 }
280}
281
282pub async fn keychain_get(
293 service: &str,
294 account: &str,
295) -> Result<Option<SecretString>, SecretError> {
296 if keychain_blocked() {
297 return Ok(None);
298 }
299 let svc = service.to_string();
300 let acct = account.to_string();
301 tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
302 let entry = keyring::Entry::new(&svc, &acct)
303 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
304 match entry.get_password() {
305 Ok(s) => Ok(Some(s)),
306 Err(keyring::Error::NoEntry) => Ok(None),
307 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
308 }
309 })
310 .await
311 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
312 .map(|opt| opt.map(SecretString::from))
313}
314
315pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
318 if keychain_blocked() {
319 return Err(SecretError::KeychainBackend(format!(
320 "keychain access disabled in this process ({ENV_KEYCHAIN_DISABLED}/test); \
321 set {ENV_KEYCHAIN_ALLOW}=1 to override"
322 )));
323 }
324 let svc = service.to_string();
325 let acct = account.to_string();
326 let val = value.to_string();
327 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
328 let entry = keyring::Entry::new(&svc, &acct)
329 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
330 entry
331 .set_password(&val)
332 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
333 Ok(())
334 })
335 .await
336 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
337}
338
339pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
342 if keychain_blocked() {
343 return Ok(());
344 }
345 let svc = service.to_string();
346 let acct = account.to_string();
347 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
348 let entry = keyring::Entry::new(&svc, &acct)
349 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
350 match entry.delete_credential() {
351 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
352 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
353 }
354 })
355 .await
356 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
357}
358
359async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
360 let mut parts = shell_words::split(spec)
361 .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
362 if parts.is_empty() {
363 return Err(SecretError::Parse("empty cmd".into()));
364 }
365 let program = parts.remove(0);
366 let output = tokio::process::Command::new(&program)
367 .args(&parts)
368 .output()
369 .await
370 .map_err(|e| SecretError::Cmd {
371 cmd: format!("{spec} ({e})"),
372 status: -1,
373 })?;
374 if !output.status.success() {
375 return Err(SecretError::Cmd {
376 cmd: spec.to_string(),
377 status: output.status.code().unwrap_or(-1),
378 });
379 }
380 let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
381 cmd: format!("{spec} (non-utf8 stdout: {e})"),
382 status: -2,
383 })?;
384 Ok(SecretString::from(
385 s.trim_end_matches(['\n', '\r']).to_string(),
386 ))
387}
388
389async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
390 let expanded = shellexpand::full(&path.to_string_lossy())
391 .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
392 .to_string();
393 let p = std::path::PathBuf::from(expanded);
394
395 #[cfg(unix)]
396 {
397 use std::os::unix::fs::PermissionsExt;
398 let meta = tokio::fs::metadata(&p)
399 .await
400 .map_err(|e| SecretError::FileRead {
401 path: p.display().to_string(),
402 source: e,
403 })?;
404 let mode = meta.permissions().mode() & 0o777;
405 if mode & 0o077 != 0 {
406 return Err(SecretError::FileMode(format!(
407 "{}: mode {:o} grants group/world access",
408 p.display(),
409 mode
410 )));
411 }
412 }
413
414 let bytes = tokio::fs::read(&p)
415 .await
416 .map_err(|e| SecretError::FileRead {
417 path: p.display().to_string(),
418 source: e,
419 })?;
420
421 let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
422 decrypt_age(&bytes).await?
423 } else {
424 String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
425 };
426 let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
427 Ok(SecretString::from(trimmed))
428}
429
430async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
431 let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
432 Ok(p) => std::path::PathBuf::from(p),
433 Err(_) => dirs::home_dir()
434 .ok_or_else(|| {
435 SecretError::AgeDecrypt(
436 "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
437 )
438 })?
439 .join(".mur/age/identity.txt"),
440 };
441
442 let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
443 SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
444 })?;
445 let identity: age::x25519::Identity = id_str
446 .trim()
447 .parse()
448 .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
449
450 let decryptor =
451 age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
452 let mut reader = decryptor
453 .decrypt(std::iter::once(&identity as &dyn age::Identity))
454 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
455 let mut out = String::new();
456 use std::io::Read;
457 reader
458 .read_to_string(&mut out)
459 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
460 Ok(out)
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use serde_yaml_ng as yaml;
467
468 #[test]
469 fn parses_env_form() {
470 let s: SecretRef = yaml::from_str("env:ANTHROPIC_API_KEY").unwrap();
471 assert_eq!(s, SecretRef::Env("ANTHROPIC_API_KEY".into()));
472 }
473
474 #[test]
475 fn parses_keychain_form() {
476 let s: SecretRef = yaml::from_str("keychain:mur/anthropic-oauth").unwrap();
477 assert_eq!(
478 s,
479 SecretRef::Keychain {
480 service: "mur".into(),
481 account: "anthropic-oauth".into()
482 }
483 );
484 }
485
486 #[test]
487 fn parses_file_form() {
488 let s: SecretRef = yaml::from_str("file:/tmp/foo.age").unwrap();
489 assert_eq!(s, SecretRef::File(PathBuf::from("/tmp/foo.age")));
490 }
491
492 #[test]
493 fn parses_cmd_form() {
494 let s: SecretRef = yaml::from_str("cmd:op read op://vault/item/field").unwrap();
495 assert_eq!(s, SecretRef::Cmd("op read op://vault/item/field".into()));
496 }
497
498 #[test]
499 fn rejects_unknown_scheme() {
500 let r: Result<SecretRef, _> = yaml::from_str("plain:supersecret");
501 assert!(r.is_err());
502 }
503
504 #[test]
505 fn round_trip_serde() {
506 let cases = [
507 "env:X",
508 "keychain:svc/acct",
509 "file:/p",
510 "cmd:bin --flag arg",
511 ];
512 for s in cases {
513 let parsed: SecretRef = yaml::from_str(s).unwrap();
514 let back = yaml::to_string(&parsed).unwrap();
515 let normalized = back
517 .trim()
518 .trim_matches(|c: char| c == '"' || c == '\'')
519 .to_string();
520 let reparsed: SecretRef = yaml::from_str(&normalized).unwrap();
521 assert_eq!(parsed, reparsed, "round-trip drift for {s}");
522 }
523 }
524}
525
526#[cfg(test)]
527mod resolve_env_tests {
528 use super::*;
529 use secrecy::ExposeSecret;
530
531 #[tokio::test]
532 async fn resolves_env_when_set() {
533 unsafe {
535 std::env::set_var("MUR_TEST_RESOLVE_ENV", "shhh");
536 }
537 let s = SecretRef::Env("MUR_TEST_RESOLVE_ENV".into());
538 let v = s.resolve().await.unwrap();
539 assert_eq!(v.expose_secret(), "shhh");
540 }
541
542 #[tokio::test]
543 async fn errors_when_env_missing() {
544 let s = SecretRef::Env("MUR_TEST_DEFINITELY_UNSET".into());
545 let err = s.resolve().await.unwrap_err();
546 assert!(matches!(err, SecretError::EnvNotSet(_)), "got {err:?}");
547 }
548
549 #[tokio::test]
550 async fn resolve_to_string_exposes_value_or_none() {
551 unsafe {
553 std::env::set_var("MUR_TEST_RESOLVE_TO_STRING", "kc-abc");
554 }
555 let set = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING".into());
556 assert_eq!(set.resolve_to_string().await.as_deref(), Some("kc-abc"));
557
558 let missing = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING_UNSET".into());
559 assert_eq!(missing.resolve_to_string().await, None);
560 }
561}
562
563#[cfg(test)]
564mod keychain_test_fixture {
565 use keyring::credential::{
577 Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
578 };
579 use std::any::Any;
580 use std::collections::HashMap;
581 use std::sync::{Arc, Mutex};
582 use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};
583
584 type Store = Arc<Mutex<HashMap<(String, String), Vec<u8>>>>;
585
586 struct SharedMockCredential {
587 store: Store,
588 key: (String, String),
589 }
590
591 impl CredentialApi for SharedMockCredential {
592 fn set_secret(&self, password: &[u8]) -> keyring::Result<()> {
593 self.store
594 .lock()
595 .unwrap()
596 .insert(self.key.clone(), password.to_vec());
597 Ok(())
598 }
599 fn get_secret(&self) -> keyring::Result<Vec<u8>> {
600 self.store
601 .lock()
602 .unwrap()
603 .get(&self.key)
604 .cloned()
605 .ok_or(keyring::Error::NoEntry)
606 }
607 fn delete_credential(&self) -> keyring::Result<()> {
608 self.store
609 .lock()
610 .unwrap()
611 .remove(&self.key)
612 .map(|_| ())
613 .ok_or(keyring::Error::NoEntry)
614 }
615 fn as_any(&self) -> &dyn Any {
616 self
617 }
618 }
619
620 struct SharedMockBuilder {
621 store: Store,
622 }
623
624 impl CredentialBuilderApi for SharedMockBuilder {
625 fn build(
626 &self,
627 _target: Option<&str>,
628 service: &str,
629 user: &str,
630 ) -> keyring::Result<Box<Credential>> {
631 Ok(Box::new(SharedMockCredential {
632 store: self.store.clone(),
633 key: (service.to_string(), user.to_string()),
634 }))
635 }
636 fn as_any(&self) -> &dyn Any {
637 self
638 }
639 fn persistence(&self) -> CredentialPersistence {
640 CredentialPersistence::ProcessOnly
641 }
642 }
643
644 static MOCK_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
645
646 pub(super) async fn env_lock() -> AsyncMutexGuard<'static, ()> {
649 MOCK_LOCK.lock().await
650 }
651
652 pub(super) async fn install_mock(
653 initial: Option<(&str, &str, &str)>,
654 ) -> AsyncMutexGuard<'static, ()> {
655 let g = MOCK_LOCK.lock().await;
656 unsafe {
661 std::env::set_var(super::ENV_KEYCHAIN_ALLOW, "1");
662 }
663 let store: Store = Arc::new(Mutex::new(HashMap::new()));
664 if let Some((svc, user, pw)) = initial {
665 store
666 .lock()
667 .unwrap()
668 .insert((svc.to_string(), user.to_string()), pw.as_bytes().to_vec());
669 }
670 let builder: Box<CredentialBuilder> = Box::new(SharedMockBuilder { store });
671 keyring::set_default_credential_builder(builder);
672 g
673 }
674}
675
676#[cfg(test)]
677mod resolve_keychain_tests {
678 use super::keychain_test_fixture::install_mock;
679 use super::*;
680 use secrecy::ExposeSecret;
681
682 #[tokio::test]
683 async fn blocked_process_never_reaches_keychain() {
684 let _g = super::keychain_test_fixture::env_lock().await;
685 unsafe {
688 std::env::remove_var(ENV_KEYCHAIN_ALLOW);
689 std::env::set_var(ENV_KEYCHAIN_DISABLED, "1");
690 }
691 let s = SecretRef::Keychain {
692 service: "mur-test".into(),
693 account: "nope".into(),
694 };
695 assert!(matches!(
696 s.resolve().await,
697 Err(SecretError::KeychainNotFound { .. })
698 ));
699 assert!(keychain_get("mur-test", "nope").await.unwrap().is_none());
700 assert!(keychain_set("mur-test", "nope", "v").await.is_err());
701 assert!(keychain_delete("mur-test", "nope").await.is_ok());
702 unsafe {
703 std::env::remove_var(ENV_KEYCHAIN_DISABLED);
704 }
705 }
706
707 #[tokio::test]
708 async fn resolves_when_set() {
709 let _g = install_mock(Some(("mur-test", "kc-acct", "kc-secret"))).await;
710 let s = SecretRef::Keychain {
711 service: "mur-test".into(),
712 account: "kc-acct".into(),
713 };
714 let v = s.resolve().await.unwrap();
715 assert_eq!(v.expose_secret(), "kc-secret");
716 }
717
718 #[tokio::test]
719 async fn errors_when_missing() {
720 let _g = install_mock(None).await;
721 let s = SecretRef::Keychain {
722 service: "mur-test".into(),
723 account: "kc-acct".into(),
724 };
725 let err = s.resolve().await.unwrap_err();
726 assert!(
727 matches!(err, SecretError::KeychainNotFound { .. }),
728 "got {err:?}"
729 );
730 }
731}
732
733#[cfg(all(test, unix))]
734mod resolve_file_tests {
735 use super::*;
736 use secrecy::ExposeSecret;
737 use std::os::unix::fs::PermissionsExt;
738 use tempfile::tempdir;
739
740 #[tokio::test]
741 async fn reads_plaintext_0600() {
742 let dir = tempdir().unwrap();
743 let p = dir.path().join("k.txt");
744 std::fs::write(&p, "abc\n").unwrap();
745 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
746 let s = SecretRef::File(p);
747 let v = s.resolve().await.unwrap();
748 assert_eq!(v.expose_secret(), "abc"); }
750
751 #[tokio::test]
752 async fn rejects_world_readable() {
753 let dir = tempdir().unwrap();
754 let p = dir.path().join("k.txt");
755 std::fs::write(&p, "abc").unwrap();
756 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
757 let s = SecretRef::File(p);
758 let err = s.resolve().await.unwrap_err();
759 assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
760 }
761
762 #[tokio::test]
763 async fn decrypts_age_recipient_file() {
764 let dir = tempdir().unwrap();
765 let identity = age::x25519::Identity::generate();
766 let recipient = identity.to_public();
767 let payload = b"shh-from-age";
768
769 let mut encrypted: Vec<u8> = Vec::new();
770 let encryptor =
771 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
772 .unwrap();
773 let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
774 std::io::Write::write_all(&mut writer, payload).unwrap();
775 writer.finish().unwrap();
776
777 let enc_path = dir.path().join("k.age");
778 std::fs::write(&enc_path, &encrypted).unwrap();
779 std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
780 let id_path = dir.path().join("identity.txt");
781 use secrecy::ExposeSecret as _;
782 std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
783 std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
784 unsafe {
788 std::env::set_var("MUR_AGE_IDENTITY_PATH", &id_path);
789 }
790 let s = SecretRef::File(enc_path);
791 let v = s.resolve().await.unwrap();
792 assert_eq!(v.expose_secret(), "shh-from-age");
793 unsafe {
794 std::env::remove_var("MUR_AGE_IDENTITY_PATH");
795 }
796 }
797}
798
799#[cfg(all(test, unix))]
800mod resolve_cmd_tests {
801 use super::*;
802 use secrecy::ExposeSecret;
803
804 #[tokio::test]
805 async fn echoes_stdout() {
806 let s = SecretRef::Cmd("printf shh-from-cmd".into());
807 let v = s.resolve().await.unwrap();
808 assert_eq!(v.expose_secret(), "shh-from-cmd");
809 }
810
811 #[tokio::test]
812 async fn errors_on_non_zero_exit() {
813 let s = SecretRef::Cmd("sh -c 'exit 7'".into());
814 let err = s.resolve().await.unwrap_err();
815 match err {
816 SecretError::Cmd { status, .. } => assert_eq!(status, 7),
817 other => panic!("unexpected: {other:?}"),
818 }
819 }
820}
821
822#[cfg(test)]
823mod check_tests {
824 use super::*;
825
826 #[tokio::test]
827 async fn check_env_present() {
828 unsafe {
830 std::env::set_var("MUR_TEST_CHECK_ENV", "1");
831 }
832 assert!(SecretRef::Env("MUR_TEST_CHECK_ENV".into()).check().await);
833 }
834
835 #[tokio::test]
836 async fn check_env_absent() {
837 assert!(
838 !SecretRef::Env("MUR_TEST_CHECK_DEFINITELY_UNSET".into())
839 .check()
840 .await
841 );
842 }
843}
844
845#[cfg(test)]
846mod keychain_helpers_tests {
847 use super::keychain_test_fixture::install_mock;
848 use super::*;
849 use secrecy::ExposeSecret;
850
851 #[tokio::test]
852 async fn set_then_resolve_round_trips() {
853 let _g = install_mock(None).await;
854 keychain_set("mur-test", "round-trip", "v1").await.unwrap();
855 let v = SecretRef::Keychain {
856 service: "mur-test".into(),
857 account: "round-trip".into(),
858 }
859 .resolve()
860 .await
861 .unwrap();
862 assert_eq!(v.expose_secret(), "v1");
863 }
864
865 #[tokio::test]
866 async fn delete_works() {
867 let _g = install_mock(None).await;
868 keychain_set("mur-test", "to-delete", "v").await.unwrap();
869 keychain_delete("mur-test", "to-delete").await.unwrap();
870 let r = SecretRef::Keychain {
871 service: "mur-test".into(),
872 account: "to-delete".into(),
873 }
874 .resolve()
875 .await;
876 assert!(matches!(r, Err(SecretError::KeychainNotFound { .. })));
877 }
878
879 #[tokio::test]
880 async fn delete_missing_is_idempotent() {
881 let _g = install_mock(None).await;
882 keychain_delete("mur-test", "never-set").await.unwrap();
884 }
885}
886
887#[cfg(test)]
888mod resolve_blocking_tests {
889 #[test]
896 fn a_cached_secret_survives_its_path_becoming_unreachable() {
897 let dir = tempfile::tempdir().unwrap();
898 let path = dir.path().join("provider.key");
899 std::fs::write(&path, "sk-test-value").unwrap();
900 #[cfg(unix)]
902 {
903 use std::os::unix::fs::PermissionsExt;
904 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
905 }
906 let r = SecretRef::File(path.clone());
907
908 cache_before_seal(&r).expect("resolves while the path is reachable");
909 std::fs::remove_file(&path).unwrap();
910
911 use secrecy::ExposeSecret;
912 let got = r
913 .resolve_blocking()
914 .expect("cached value must still resolve");
915 assert_eq!(got.expose_secret(), "sk-test-value");
916 }
917
918 #[test]
922 fn an_uncached_secret_fails_when_its_path_is_unreachable() {
923 let dir = tempfile::tempdir().unwrap();
924 let path = dir.path().join("uncached.key");
925 std::fs::write(&path, "sk-other-value").unwrap();
926 let r = SecretRef::File(path.clone());
927 std::fs::remove_file(&path).unwrap();
928
929 assert!(
930 r.resolve_blocking().is_err(),
931 "an uncached ref must not resolve once its path is gone"
932 );
933 }
934
935 #[test]
943 fn preseal_cached_lookup_does_not_touch_the_backend() {
944 let r = SecretRef::Keychain {
945 service: "mur-agent-test-nonexistent".into(),
946 account: "no-such-agent/NO_SUCH_KEY".into(),
947 };
948 assert!(r.resolve_preseal_cached().is_none());
951
952 let dir = tempfile::tempdir().unwrap();
953 let path = dir.path().join("agent.key");
954 std::fs::write(&path, "sk-agent").unwrap();
955 #[cfg(unix)]
956 {
957 use std::os::unix::fs::PermissionsExt;
958 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
959 }
960 let f = SecretRef::File(path.clone());
961 cache_before_seal(&f).unwrap();
962 std::fs::remove_file(&path).unwrap();
963
964 use secrecy::ExposeSecret;
965 let got = f.resolve_preseal_cached().expect("cached hit");
966 assert_eq!(got.expose_secret(), "sk-agent");
967 }
968
969 #[test]
971 fn caching_the_same_ref_twice_stores_one_entry() {
972 let dir = tempfile::tempdir().unwrap();
973 let path = dir.path().join("dup.key");
974 std::fs::write(&path, "v").unwrap();
975 #[cfg(unix)]
976 {
977 use std::os::unix::fs::PermissionsExt;
978 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
979 }
980 let r = SecretRef::File(path);
981
982 let before = preseal_cached_count();
983 cache_before_seal(&r).unwrap();
984 cache_before_seal(&r).unwrap();
985 assert_eq!(preseal_cached_count(), before + 1);
986 }
987
988 use super::*;
989
990 #[test]
991 fn resolve_blocking_env_and_missing() {
992 unsafe { std::env::set_var("MUR_TEST_SECRET_BLOCKING", "s3cret") };
993 let r: SecretRef = "env:MUR_TEST_SECRET_BLOCKING".parse().unwrap();
994 assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
995 unsafe { std::env::remove_var("MUR_TEST_SECRET_BLOCKING") };
996 assert!(r.resolve_blocking().is_err());
997 }
998
999 #[tokio::test]
1004 async fn resolve_blocking_inside_current_thread_runtime_does_not_panic() {
1005 unsafe { std::env::set_var("MUR_TEST_SECRET_CT_RT", "s3cret") };
1006 let r: SecretRef = "env:MUR_TEST_SECRET_CT_RT".parse().unwrap();
1007 assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
1008 unsafe { std::env::remove_var("MUR_TEST_SECRET_CT_RT") };
1009 }
1010}