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 SecretRef {
111 pub fn label(&self) -> String {
112 match self {
113 SecretRef::Cmd(c) => {
114 let program = c.split_whitespace().next().unwrap_or("");
115 if c.split_whitespace().nth(1).is_some() {
116 format!("cmd:{program} (arguments hidden)")
117 } else {
118 format!("cmd:{program}")
119 }
120 }
121 other => other.to_string(),
122 }
123 }
124}
125
126impl std::fmt::Display for SecretRef {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 match self {
129 SecretRef::Env(v) => write!(f, "env:{v}"),
130 SecretRef::Keychain { service, account } => {
131 write!(f, "keychain:{service}/{account}")
132 }
133 SecretRef::File(p) => write!(f, "file:{}", p.display()),
134 SecretRef::Cmd(c) => write!(f, "cmd:{c}"),
135 }
136 }
137}
138
139impl std::str::FromStr for SecretRef {
140 type Err = SecretError;
141 fn from_str(s: &str) -> Result<Self, Self::Err> {
142 let (scheme, rest) = s
143 .split_once(':')
144 .ok_or_else(|| SecretError::Parse(format!("missing scheme: {s}")))?;
145 match scheme {
146 "env" => Ok(SecretRef::Env(rest.to_string())),
147 "keychain" => {
148 let (service, account) = rest.split_once('/').ok_or_else(|| {
149 SecretError::Parse(format!("keychain ref needs service/account: {s}"))
150 })?;
151 Ok(SecretRef::Keychain {
152 service: service.to_string(),
153 account: account.to_string(),
154 })
155 }
156 "file" => Ok(SecretRef::File(PathBuf::from(rest))),
157 "cmd" => Ok(SecretRef::Cmd(rest.to_string())),
158 other => Err(SecretError::Parse(format!("unknown scheme: {other}"))),
159 }
160 }
161}
162
163impl Serialize for SecretRef {
164 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
165 s.collect_str(self)
166 }
167}
168
169impl<'de> Deserialize<'de> for SecretRef {
170 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
171 let s = String::deserialize(d)?;
172 s.parse().map_err(serde::de::Error::custom)
173 }
174}
175
176pub const ENV_KEYCHAIN_DISABLED: &str = "MUR_KEYCHAIN_DISABLED";
180pub const ENV_KEYCHAIN_ALLOW: &str = "MUR_KEYCHAIN_ALLOW";
183
184fn keychain_blocked() -> bool {
190 if std::env::var_os(ENV_KEYCHAIN_ALLOW).is_some() {
191 return false;
192 }
193 std::env::var_os(ENV_KEYCHAIN_DISABLED).is_some() || std::env::var_os("NEXTEST").is_some()
194}
195
196impl SecretRef {
197 pub async fn resolve(&self) -> Result<SecretString, SecretError> {
198 match self {
199 SecretRef::Env(var) => std::env::var(var)
200 .map(SecretString::from)
201 .map_err(|_| SecretError::EnvNotSet(var.clone())),
202 SecretRef::Keychain { service, account } if keychain_blocked() => {
203 Err(SecretError::KeychainNotFound {
204 service: service.clone(),
205 account: account.clone(),
206 })
207 }
208 SecretRef::Keychain { service, account } => {
209 let svc = service.clone();
210 let acct = account.clone();
211 let res = tokio::task::spawn_blocking(move || -> Result<String, SecretError> {
212 let entry = keyring::Entry::new(&svc, &acct)
213 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
214 match entry.get_password() {
215 Ok(s) => Ok(s),
216 Err(keyring::Error::NoEntry) => Err(SecretError::KeychainNotFound {
217 service: svc.clone(),
218 account: acct.clone(),
219 }),
220 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
221 }
222 })
223 .await
224 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?;
225 res.map(SecretString::from)
226 }
227 SecretRef::File(path) => resolve_file(path).await,
228 SecretRef::Cmd(spec) => resolve_cmd(spec).await,
229 }
230 }
231
232 pub async fn check(&self) -> bool {
236 self.resolve().await.is_ok()
237 }
238
239 pub async fn resolve_to_string(&self) -> Option<String> {
245 use secrecy::ExposeSecret;
246 self.resolve()
247 .await
248 .ok()
249 .map(|s| s.expose_secret().to_string())
250 }
251
252 pub fn resolve_preseal_cached(&self) -> Option<SecretString> {
264 preseal_lookup(self)
265 }
266
267 pub fn resolve_blocking(&self) -> Result<SecretString, SecretError> {
268 if let Some(v) = preseal_lookup(self) {
273 return Ok(v);
274 }
275 fn fresh_runtime_resolve(r: &SecretRef) -> Result<SecretString, SecretError> {
276 tokio::runtime::Builder::new_current_thread()
277 .enable_all()
278 .build()
279 .map_err(|e| SecretError::KeychainBackend(format!("runtime: {e}")))?
280 .block_on(r.resolve())
281 }
282 match tokio::runtime::Handle::try_current() {
283 Ok(h) if h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
284 tokio::task::block_in_place(|| h.block_on(self.resolve()))
285 }
286 Ok(_) => std::thread::scope(|s| {
289 s.spawn(|| fresh_runtime_resolve(self))
290 .join()
291 .unwrap_or_else(|_| {
292 Err(SecretError::KeychainBackend(
293 "resolver thread panicked".into(),
294 ))
295 })
296 }),
297 Err(_) => fresh_runtime_resolve(self),
298 }
299 }
300
301 pub fn resolve_to_string_blocking(&self) -> Option<String> {
304 use secrecy::ExposeSecret;
305 self.resolve_blocking()
306 .ok()
307 .map(|s| s.expose_secret().to_string())
308 }
309}
310
311pub async fn keychain_get(
322 service: &str,
323 account: &str,
324) -> Result<Option<SecretString>, SecretError> {
325 if keychain_blocked() {
326 return Ok(None);
327 }
328 let svc = service.to_string();
329 let acct = account.to_string();
330 tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
331 let entry = keyring::Entry::new(&svc, &acct)
332 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
333 match entry.get_password() {
334 Ok(s) => Ok(Some(s)),
335 Err(keyring::Error::NoEntry) => Ok(None),
336 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
337 }
338 })
339 .await
340 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
341 .map(|opt| opt.map(SecretString::from))
342}
343
344pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
347 if keychain_blocked() {
348 return Err(SecretError::KeychainBackend(format!(
349 "keychain access disabled in this process ({ENV_KEYCHAIN_DISABLED}/test); \
350 set {ENV_KEYCHAIN_ALLOW}=1 to override"
351 )));
352 }
353 let svc = service.to_string();
354 let acct = account.to_string();
355 let val = value.to_string();
356 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
357 let entry = keyring::Entry::new(&svc, &acct)
358 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
359 entry
360 .set_password(&val)
361 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
362 Ok(())
363 })
364 .await
365 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
366}
367
368pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
371 if keychain_blocked() {
372 return Ok(());
373 }
374 let svc = service.to_string();
375 let acct = account.to_string();
376 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
377 let entry = keyring::Entry::new(&svc, &acct)
378 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
379 match entry.delete_credential() {
380 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
381 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
382 }
383 })
384 .await
385 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
386}
387
388async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
389 let mut parts = shell_words::split(spec)
390 .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
391 if parts.is_empty() {
392 return Err(SecretError::Parse("empty cmd".into()));
393 }
394 let program = parts.remove(0);
395 let output = tokio::process::Command::new(&program)
396 .args(&parts)
397 .output()
398 .await
399 .map_err(|e| SecretError::Cmd {
400 cmd: format!("{spec} ({e})"),
401 status: -1,
402 })?;
403 if !output.status.success() {
404 return Err(SecretError::Cmd {
405 cmd: spec.to_string(),
406 status: output.status.code().unwrap_or(-1),
407 });
408 }
409 let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
410 cmd: format!("{spec} (non-utf8 stdout: {e})"),
411 status: -2,
412 })?;
413 Ok(SecretString::from(
414 s.trim_end_matches(['\n', '\r']).to_string(),
415 ))
416}
417
418async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
419 let expanded = shellexpand::full(&path.to_string_lossy())
420 .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
421 .to_string();
422 let p = std::path::PathBuf::from(expanded);
423
424 #[cfg(unix)]
425 {
426 use std::os::unix::fs::PermissionsExt;
427 let meta = tokio::fs::metadata(&p)
428 .await
429 .map_err(|e| SecretError::FileRead {
430 path: p.display().to_string(),
431 source: e,
432 })?;
433 let mode = meta.permissions().mode() & 0o777;
434 if mode & 0o077 != 0 {
435 return Err(SecretError::FileMode(format!(
436 "{}: mode {:o} grants group/world access",
437 p.display(),
438 mode
439 )));
440 }
441 }
442
443 let bytes = tokio::fs::read(&p)
444 .await
445 .map_err(|e| SecretError::FileRead {
446 path: p.display().to_string(),
447 source: e,
448 })?;
449
450 let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
451 decrypt_age(&bytes).await?
452 } else {
453 String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
454 };
455 let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
456 Ok(SecretString::from(trimmed))
457}
458
459async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
460 let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
461 Ok(p) => std::path::PathBuf::from(p),
462 Err(_) => dirs::home_dir()
463 .ok_or_else(|| {
464 SecretError::AgeDecrypt(
465 "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
466 )
467 })?
468 .join(".mur/age/identity.txt"),
469 };
470
471 let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
472 SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
473 })?;
474 let identity: age::x25519::Identity = id_str
475 .trim()
476 .parse()
477 .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
478
479 let decryptor =
480 age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
481 let mut reader = decryptor
482 .decrypt(std::iter::once(&identity as &dyn age::Identity))
483 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
484 let mut out = String::new();
485 use std::io::Read;
486 reader
487 .read_to_string(&mut out)
488 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
489 Ok(out)
490}
491
492#[cfg(test)]
493mod tests {
494 #[test]
498 fn a_command_reference_hides_its_arguments() {
499 let r = SecretRef::Cmd("vault read -field=key secret/x --token=orgtok123".into());
500 let label = r.label();
501 assert!(label.starts_with("cmd:vault"), "{label}");
502 assert!(!label.contains("orgtok123"), "{label}");
503 assert!(label.contains("arguments hidden"), "{label}");
504 }
505
506 #[test]
509 fn a_bare_command_reference_is_shown_whole() {
510 assert_eq!(SecretRef::Cmd("get-key".into()).label(), "cmd:get-key");
511 }
512
513 #[test]
516 fn the_other_forms_are_unchanged() {
517 for r in [
518 SecretRef::Env("ANTHROPIC_API_KEY".into()),
519 SecretRef::File("/home/d/.mur/secrets/k".into()),
520 SecretRef::Keychain {
521 service: "mur".into(),
522 account: "anthropic".into(),
523 },
524 ] {
525 assert_eq!(r.label(), r.to_string(), "{r}");
526 }
527 }
528
529 use super::*;
530 use serde_yaml_ng as yaml;
531
532 #[test]
533 fn parses_env_form() {
534 let s: SecretRef = yaml::from_str("env:ANTHROPIC_API_KEY").unwrap();
535 assert_eq!(s, SecretRef::Env("ANTHROPIC_API_KEY".into()));
536 }
537
538 #[test]
539 fn parses_keychain_form() {
540 let s: SecretRef = yaml::from_str("keychain:mur/anthropic-oauth").unwrap();
541 assert_eq!(
542 s,
543 SecretRef::Keychain {
544 service: "mur".into(),
545 account: "anthropic-oauth".into()
546 }
547 );
548 }
549
550 #[test]
551 fn parses_file_form() {
552 let s: SecretRef = yaml::from_str("file:/tmp/foo.age").unwrap();
553 assert_eq!(s, SecretRef::File(PathBuf::from("/tmp/foo.age")));
554 }
555
556 #[test]
557 fn parses_cmd_form() {
558 let s: SecretRef = yaml::from_str("cmd:op read op://vault/item/field").unwrap();
559 assert_eq!(s, SecretRef::Cmd("op read op://vault/item/field".into()));
560 }
561
562 #[test]
563 fn rejects_unknown_scheme() {
564 let r: Result<SecretRef, _> = yaml::from_str("plain:supersecret");
565 assert!(r.is_err());
566 }
567
568 #[test]
569 fn round_trip_serde() {
570 let cases = [
571 "env:X",
572 "keychain:svc/acct",
573 "file:/p",
574 "cmd:bin --flag arg",
575 ];
576 for s in cases {
577 let parsed: SecretRef = yaml::from_str(s).unwrap();
578 let back = yaml::to_string(&parsed).unwrap();
579 let normalized = back
581 .trim()
582 .trim_matches(|c: char| c == '"' || c == '\'')
583 .to_string();
584 let reparsed: SecretRef = yaml::from_str(&normalized).unwrap();
585 assert_eq!(parsed, reparsed, "round-trip drift for {s}");
586 }
587 }
588}
589
590#[cfg(test)]
591mod resolve_env_tests {
592 use super::*;
593 use secrecy::ExposeSecret;
594
595 #[tokio::test]
596 async fn resolves_env_when_set() {
597 let _env = crate::test_env::EnvGuard::set([("MUR_TEST_RESOLVE_ENV", "shhh")]);
598 let s = SecretRef::Env("MUR_TEST_RESOLVE_ENV".into());
599 let v = s.resolve().await.unwrap();
600 assert_eq!(v.expose_secret(), "shhh");
601 }
602
603 #[tokio::test]
604 async fn errors_when_env_missing() {
605 let s = SecretRef::Env("MUR_TEST_DEFINITELY_UNSET".into());
606 let err = s.resolve().await.unwrap_err();
607 assert!(matches!(err, SecretError::EnvNotSet(_)), "got {err:?}");
608 }
609
610 #[tokio::test]
611 async fn resolve_to_string_exposes_value_or_none() {
612 let _env = crate::test_env::EnvGuard::set([("MUR_TEST_RESOLVE_TO_STRING", "kc-abc")]);
613 let set = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING".into());
614 assert_eq!(set.resolve_to_string().await.as_deref(), Some("kc-abc"));
615
616 let missing = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING_UNSET".into());
617 assert_eq!(missing.resolve_to_string().await, None);
618 }
619}
620
621#[cfg(test)]
622mod keychain_test_fixture {
623 use keyring::credential::{
635 Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
636 };
637 use std::any::Any;
638 use std::collections::HashMap;
639 use std::sync::{Arc, Mutex};
640 use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};
641
642 type Store = Arc<Mutex<HashMap<(String, String), Vec<u8>>>>;
643
644 struct SharedMockCredential {
645 store: Store,
646 key: (String, String),
647 }
648
649 impl CredentialApi for SharedMockCredential {
650 fn set_secret(&self, password: &[u8]) -> keyring::Result<()> {
651 self.store
652 .lock()
653 .unwrap()
654 .insert(self.key.clone(), password.to_vec());
655 Ok(())
656 }
657 fn get_secret(&self) -> keyring::Result<Vec<u8>> {
658 self.store
659 .lock()
660 .unwrap()
661 .get(&self.key)
662 .cloned()
663 .ok_or(keyring::Error::NoEntry)
664 }
665 fn delete_credential(&self) -> keyring::Result<()> {
666 self.store
667 .lock()
668 .unwrap()
669 .remove(&self.key)
670 .map(|_| ())
671 .ok_or(keyring::Error::NoEntry)
672 }
673 fn as_any(&self) -> &dyn Any {
674 self
675 }
676 }
677
678 struct SharedMockBuilder {
679 store: Store,
680 }
681
682 impl CredentialBuilderApi for SharedMockBuilder {
683 fn build(
684 &self,
685 _target: Option<&str>,
686 service: &str,
687 user: &str,
688 ) -> keyring::Result<Box<Credential>> {
689 Ok(Box::new(SharedMockCredential {
690 store: self.store.clone(),
691 key: (service.to_string(), user.to_string()),
692 }))
693 }
694 fn as_any(&self) -> &dyn Any {
695 self
696 }
697 fn persistence(&self) -> CredentialPersistence {
698 CredentialPersistence::ProcessOnly
699 }
700 }
701
702 static MOCK_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
703
704 pub(super) async fn env_lock() -> AsyncMutexGuard<'static, ()> {
707 MOCK_LOCK.lock().await
708 }
709
710 pub(super) async fn install_mock(
718 initial: Option<(&str, &str, &str)>,
719 ) -> (AsyncMutexGuard<'static, ()>, crate::test_env::EnvGuard) {
720 let g = MOCK_LOCK.lock().await;
721 let env = crate::test_env::EnvGuard::set([(super::ENV_KEYCHAIN_ALLOW, "1")]);
724 let store: Store = Arc::new(Mutex::new(HashMap::new()));
725 if let Some((svc, user, pw)) = initial {
726 store
727 .lock()
728 .unwrap()
729 .insert((svc.to_string(), user.to_string()), pw.as_bytes().to_vec());
730 }
731 let builder: Box<CredentialBuilder> = Box::new(SharedMockBuilder { store });
732 keyring::set_default_credential_builder(builder);
733 (g, env)
734 }
735}
736
737#[cfg(test)]
738mod resolve_keychain_tests {
739 use super::keychain_test_fixture::install_mock;
740 use super::*;
741 use secrecy::ExposeSecret;
742
743 #[tokio::test]
744 async fn blocked_process_never_reaches_keychain() {
745 let _g = super::keychain_test_fixture::env_lock().await;
746 let mut _env = crate::test_env::EnvGuard::unset([ENV_KEYCHAIN_ALLOW]);
747 _env.set_var(ENV_KEYCHAIN_DISABLED, "1");
748 let s = SecretRef::Keychain {
749 service: "mur-test".into(),
750 account: "nope".into(),
751 };
752 assert!(matches!(
753 s.resolve().await,
754 Err(SecretError::KeychainNotFound { .. })
755 ));
756 assert!(keychain_get("mur-test", "nope").await.unwrap().is_none());
757 assert!(keychain_set("mur-test", "nope", "v").await.is_err());
758 assert!(keychain_delete("mur-test", "nope").await.is_ok());
759 }
760
761 #[tokio::test]
762 async fn resolves_when_set() {
763 let _g = install_mock(Some(("mur-test", "kc-acct", "kc-secret"))).await;
764 let s = SecretRef::Keychain {
765 service: "mur-test".into(),
766 account: "kc-acct".into(),
767 };
768 let v = s.resolve().await.unwrap();
769 assert_eq!(v.expose_secret(), "kc-secret");
770 }
771
772 #[tokio::test]
773 async fn errors_when_missing() {
774 let _g = install_mock(None).await;
775 let s = SecretRef::Keychain {
776 service: "mur-test".into(),
777 account: "kc-acct".into(),
778 };
779 let err = s.resolve().await.unwrap_err();
780 assert!(
781 matches!(err, SecretError::KeychainNotFound { .. }),
782 "got {err:?}"
783 );
784 }
785}
786
787#[cfg(all(test, unix))]
788mod resolve_file_tests {
789 use super::*;
790 use secrecy::ExposeSecret;
791 use std::os::unix::fs::PermissionsExt;
792 use tempfile::tempdir;
793
794 #[tokio::test]
795 async fn reads_plaintext_0600() {
796 let dir = tempdir().unwrap();
797 let p = dir.path().join("k.txt");
798 std::fs::write(&p, "abc\n").unwrap();
799 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
800 let s = SecretRef::File(p);
801 let v = s.resolve().await.unwrap();
802 assert_eq!(v.expose_secret(), "abc"); }
804
805 #[tokio::test]
806 async fn rejects_world_readable() {
807 let dir = tempdir().unwrap();
808 let p = dir.path().join("k.txt");
809 std::fs::write(&p, "abc").unwrap();
810 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
811 let s = SecretRef::File(p);
812 let err = s.resolve().await.unwrap_err();
813 assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
814 }
815
816 #[tokio::test]
817 async fn decrypts_age_recipient_file() {
818 let dir = tempdir().unwrap();
819 let identity = age::x25519::Identity::generate();
820 let recipient = identity.to_public();
821 let payload = b"shh-from-age";
822
823 let mut encrypted: Vec<u8> = Vec::new();
824 let encryptor =
825 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
826 .unwrap();
827 let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
828 std::io::Write::write_all(&mut writer, payload).unwrap();
829 writer.finish().unwrap();
830
831 let enc_path = dir.path().join("k.age");
832 std::fs::write(&enc_path, &encrypted).unwrap();
833 std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
834 let id_path = dir.path().join("identity.txt");
835 use secrecy::ExposeSecret as _;
836 std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
837 std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
838 let _env = crate::test_env::EnvGuard::set([("MUR_AGE_IDENTITY_PATH", &id_path)]);
839 let s = SecretRef::File(enc_path);
840 let v = s.resolve().await.unwrap();
841 assert_eq!(v.expose_secret(), "shh-from-age");
842 }
843}
844
845#[cfg(all(test, unix))]
846mod resolve_cmd_tests {
847 use super::*;
848 use secrecy::ExposeSecret;
849
850 #[tokio::test]
851 async fn echoes_stdout() {
852 let s = SecretRef::Cmd("printf shh-from-cmd".into());
853 let v = s.resolve().await.unwrap();
854 assert_eq!(v.expose_secret(), "shh-from-cmd");
855 }
856
857 #[tokio::test]
858 async fn errors_on_non_zero_exit() {
859 let s = SecretRef::Cmd("sh -c 'exit 7'".into());
860 let err = s.resolve().await.unwrap_err();
861 match err {
862 SecretError::Cmd { status, .. } => assert_eq!(status, 7),
863 other => panic!("unexpected: {other:?}"),
864 }
865 }
866}
867
868#[cfg(test)]
869mod check_tests {
870 use super::*;
871
872 #[tokio::test]
873 async fn check_env_present() {
874 let _env = crate::test_env::EnvGuard::set([("MUR_TEST_CHECK_ENV", "1")]);
875 assert!(SecretRef::Env("MUR_TEST_CHECK_ENV".into()).check().await);
876 }
877
878 #[tokio::test]
879 async fn check_env_absent() {
880 assert!(
881 !SecretRef::Env("MUR_TEST_CHECK_DEFINITELY_UNSET".into())
882 .check()
883 .await
884 );
885 }
886}
887
888#[cfg(test)]
889mod keychain_helpers_tests {
890 use super::keychain_test_fixture::install_mock;
891 use super::*;
892 use secrecy::ExposeSecret;
893
894 #[tokio::test]
895 async fn set_then_resolve_round_trips() {
896 let _g = install_mock(None).await;
897 keychain_set("mur-test", "round-trip", "v1").await.unwrap();
898 let v = SecretRef::Keychain {
899 service: "mur-test".into(),
900 account: "round-trip".into(),
901 }
902 .resolve()
903 .await
904 .unwrap();
905 assert_eq!(v.expose_secret(), "v1");
906 }
907
908 #[tokio::test]
909 async fn delete_works() {
910 let _g = install_mock(None).await;
911 keychain_set("mur-test", "to-delete", "v").await.unwrap();
912 keychain_delete("mur-test", "to-delete").await.unwrap();
913 let r = SecretRef::Keychain {
914 service: "mur-test".into(),
915 account: "to-delete".into(),
916 }
917 .resolve()
918 .await;
919 assert!(matches!(r, Err(SecretError::KeychainNotFound { .. })));
920 }
921
922 #[tokio::test]
923 async fn delete_missing_is_idempotent() {
924 let _g = install_mock(None).await;
925 keychain_delete("mur-test", "never-set").await.unwrap();
927 }
928}
929
930#[cfg(test)]
931mod resolve_blocking_tests {
932 static PRESEAL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
944
945 #[test]
952 fn a_cached_secret_survives_its_path_becoming_unreachable() {
953 let _preseal_guard = PRESEAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
954 let dir = tempfile::tempdir().unwrap();
955 let path = dir.path().join("provider.key");
956 std::fs::write(&path, "sk-test-value").unwrap();
957 #[cfg(unix)]
959 {
960 use std::os::unix::fs::PermissionsExt;
961 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
962 }
963 let r = SecretRef::File(path.clone());
964
965 cache_before_seal(&r).expect("resolves while the path is reachable");
966 std::fs::remove_file(&path).unwrap();
967
968 use secrecy::ExposeSecret;
969 let got = r
970 .resolve_blocking()
971 .expect("cached value must still resolve");
972 assert_eq!(got.expose_secret(), "sk-test-value");
973 }
974
975 #[test]
979 fn an_uncached_secret_fails_when_its_path_is_unreachable() {
980 let dir = tempfile::tempdir().unwrap();
981 let path = dir.path().join("uncached.key");
982 std::fs::write(&path, "sk-other-value").unwrap();
983 let r = SecretRef::File(path.clone());
984 std::fs::remove_file(&path).unwrap();
985
986 assert!(
987 r.resolve_blocking().is_err(),
988 "an uncached ref must not resolve once its path is gone"
989 );
990 }
991
992 #[test]
1000 fn preseal_cached_lookup_does_not_touch_the_backend() {
1001 let _preseal_guard = PRESEAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1002 let r = SecretRef::Keychain {
1003 service: "mur-agent-test-nonexistent".into(),
1004 account: "no-such-agent/NO_SUCH_KEY".into(),
1005 };
1006 assert!(r.resolve_preseal_cached().is_none());
1009
1010 let dir = tempfile::tempdir().unwrap();
1011 let path = dir.path().join("agent.key");
1012 std::fs::write(&path, "sk-agent").unwrap();
1013 #[cfg(unix)]
1014 {
1015 use std::os::unix::fs::PermissionsExt;
1016 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1017 }
1018 let f = SecretRef::File(path.clone());
1019 cache_before_seal(&f).unwrap();
1020 std::fs::remove_file(&path).unwrap();
1021
1022 use secrecy::ExposeSecret;
1023 let got = f.resolve_preseal_cached().expect("cached hit");
1024 assert_eq!(got.expose_secret(), "sk-agent");
1025 }
1026
1027 #[test]
1029 fn caching_the_same_ref_twice_stores_one_entry() {
1030 let _preseal_guard = PRESEAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1031 let dir = tempfile::tempdir().unwrap();
1032 let path = dir.path().join("dup.key");
1033 std::fs::write(&path, "v").unwrap();
1034 #[cfg(unix)]
1035 {
1036 use std::os::unix::fs::PermissionsExt;
1037 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1038 }
1039 let r = SecretRef::File(path);
1040
1041 let before = preseal_cached_count();
1042 cache_before_seal(&r).unwrap();
1043 cache_before_seal(&r).unwrap();
1044 assert_eq!(preseal_cached_count(), before + 1);
1045 }
1046
1047 use super::*;
1048
1049 #[test]
1050 fn resolve_blocking_env_and_missing() {
1051 let mut env = crate::test_env::EnvGuard::set([("MUR_TEST_SECRET_BLOCKING", "s3cret")]);
1052 let r: SecretRef = "env:MUR_TEST_SECRET_BLOCKING".parse().unwrap();
1053 assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
1054 env.unset_var("MUR_TEST_SECRET_BLOCKING");
1057 assert!(r.resolve_blocking().is_err());
1058 }
1059
1060 #[tokio::test]
1065 async fn resolve_blocking_inside_current_thread_runtime_does_not_panic() {
1066 let _env = crate::test_env::EnvGuard::set([("MUR_TEST_SECRET_CT_RT", "s3cret")]);
1067 let r: SecretRef = "env:MUR_TEST_SECRET_CT_RT".parse().unwrap();
1068 assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
1069 }
1070}