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
47impl std::fmt::Display for SecretRef {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match self {
50 SecretRef::Env(v) => write!(f, "env:{v}"),
51 SecretRef::Keychain { service, account } => {
52 write!(f, "keychain:{service}/{account}")
53 }
54 SecretRef::File(p) => write!(f, "file:{}", p.display()),
55 SecretRef::Cmd(c) => write!(f, "cmd:{c}"),
56 }
57 }
58}
59
60impl std::str::FromStr for SecretRef {
61 type Err = SecretError;
62 fn from_str(s: &str) -> Result<Self, Self::Err> {
63 let (scheme, rest) = s
64 .split_once(':')
65 .ok_or_else(|| SecretError::Parse(format!("missing scheme: {s}")))?;
66 match scheme {
67 "env" => Ok(SecretRef::Env(rest.to_string())),
68 "keychain" => {
69 let (service, account) = rest.split_once('/').ok_or_else(|| {
70 SecretError::Parse(format!("keychain ref needs service/account: {s}"))
71 })?;
72 Ok(SecretRef::Keychain {
73 service: service.to_string(),
74 account: account.to_string(),
75 })
76 }
77 "file" => Ok(SecretRef::File(PathBuf::from(rest))),
78 "cmd" => Ok(SecretRef::Cmd(rest.to_string())),
79 other => Err(SecretError::Parse(format!("unknown scheme: {other}"))),
80 }
81 }
82}
83
84impl Serialize for SecretRef {
85 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
86 s.collect_str(self)
87 }
88}
89
90impl<'de> Deserialize<'de> for SecretRef {
91 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
92 let s = String::deserialize(d)?;
93 s.parse().map_err(serde::de::Error::custom)
94 }
95}
96
97impl SecretRef {
98 pub async fn resolve(&self) -> Result<SecretString, SecretError> {
99 match self {
100 SecretRef::Env(var) => std::env::var(var)
101 .map(SecretString::from)
102 .map_err(|_| SecretError::EnvNotSet(var.clone())),
103 SecretRef::Keychain { service, account } => {
104 let svc = service.clone();
105 let acct = account.clone();
106 let res = tokio::task::spawn_blocking(move || -> Result<String, SecretError> {
107 let entry = keyring::Entry::new(&svc, &acct)
108 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
109 match entry.get_password() {
110 Ok(s) => Ok(s),
111 Err(keyring::Error::NoEntry) => Err(SecretError::KeychainNotFound {
112 service: svc.clone(),
113 account: acct.clone(),
114 }),
115 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
116 }
117 })
118 .await
119 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?;
120 res.map(SecretString::from)
121 }
122 SecretRef::File(path) => resolve_file(path).await,
123 SecretRef::Cmd(spec) => resolve_cmd(spec).await,
124 }
125 }
126
127 pub async fn check(&self) -> bool {
131 self.resolve().await.is_ok()
132 }
133
134 pub async fn resolve_to_string(&self) -> Option<String> {
140 use secrecy::ExposeSecret;
141 self.resolve()
142 .await
143 .ok()
144 .map(|s| s.expose_secret().to_string())
145 }
146
147 pub fn resolve_blocking(&self) -> Result<SecretString, SecretError> {
153 fn fresh_runtime_resolve(r: &SecretRef) -> Result<SecretString, SecretError> {
154 tokio::runtime::Builder::new_current_thread()
155 .enable_all()
156 .build()
157 .map_err(|e| SecretError::KeychainBackend(format!("runtime: {e}")))?
158 .block_on(r.resolve())
159 }
160 match tokio::runtime::Handle::try_current() {
161 Ok(h) if h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
162 tokio::task::block_in_place(|| h.block_on(self.resolve()))
163 }
164 Ok(_) => std::thread::scope(|s| {
167 s.spawn(|| fresh_runtime_resolve(self))
168 .join()
169 .unwrap_or_else(|_| {
170 Err(SecretError::KeychainBackend(
171 "resolver thread panicked".into(),
172 ))
173 })
174 }),
175 Err(_) => fresh_runtime_resolve(self),
176 }
177 }
178
179 pub fn resolve_to_string_blocking(&self) -> Option<String> {
182 use secrecy::ExposeSecret;
183 self.resolve_blocking()
184 .ok()
185 .map(|s| s.expose_secret().to_string())
186 }
187}
188
189pub async fn keychain_get(
200 service: &str,
201 account: &str,
202) -> Result<Option<SecretString>, SecretError> {
203 let svc = service.to_string();
204 let acct = account.to_string();
205 tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
206 let entry = keyring::Entry::new(&svc, &acct)
207 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
208 match entry.get_password() {
209 Ok(s) => Ok(Some(s)),
210 Err(keyring::Error::NoEntry) => Ok(None),
211 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
212 }
213 })
214 .await
215 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
216 .map(|opt| opt.map(SecretString::from))
217}
218
219pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
222 let svc = service.to_string();
223 let acct = account.to_string();
224 let val = value.to_string();
225 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
226 let entry = keyring::Entry::new(&svc, &acct)
227 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
228 entry
229 .set_password(&val)
230 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
231 Ok(())
232 })
233 .await
234 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
235}
236
237pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
240 let svc = service.to_string();
241 let acct = account.to_string();
242 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
243 let entry = keyring::Entry::new(&svc, &acct)
244 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
245 match entry.delete_credential() {
246 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
247 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
248 }
249 })
250 .await
251 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
252}
253
254async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
255 let mut parts = shell_words::split(spec)
256 .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
257 if parts.is_empty() {
258 return Err(SecretError::Parse("empty cmd".into()));
259 }
260 let program = parts.remove(0);
261 let output = tokio::process::Command::new(&program)
262 .args(&parts)
263 .output()
264 .await
265 .map_err(|e| SecretError::Cmd {
266 cmd: format!("{spec} ({e})"),
267 status: -1,
268 })?;
269 if !output.status.success() {
270 return Err(SecretError::Cmd {
271 cmd: spec.to_string(),
272 status: output.status.code().unwrap_or(-1),
273 });
274 }
275 let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
276 cmd: format!("{spec} (non-utf8 stdout: {e})"),
277 status: -2,
278 })?;
279 Ok(SecretString::from(
280 s.trim_end_matches(['\n', '\r']).to_string(),
281 ))
282}
283
284async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
285 let expanded = shellexpand::full(&path.to_string_lossy())
286 .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
287 .to_string();
288 let p = std::path::PathBuf::from(expanded);
289
290 #[cfg(unix)]
291 {
292 use std::os::unix::fs::PermissionsExt;
293 let meta = tokio::fs::metadata(&p)
294 .await
295 .map_err(|e| SecretError::FileRead {
296 path: p.display().to_string(),
297 source: e,
298 })?;
299 let mode = meta.permissions().mode() & 0o777;
300 if mode & 0o077 != 0 {
301 return Err(SecretError::FileMode(format!(
302 "{}: mode {:o} grants group/world access",
303 p.display(),
304 mode
305 )));
306 }
307 }
308
309 let bytes = tokio::fs::read(&p)
310 .await
311 .map_err(|e| SecretError::FileRead {
312 path: p.display().to_string(),
313 source: e,
314 })?;
315
316 let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
317 decrypt_age(&bytes).await?
318 } else {
319 String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
320 };
321 let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
322 Ok(SecretString::from(trimmed))
323}
324
325async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
326 let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
327 Ok(p) => std::path::PathBuf::from(p),
328 Err(_) => dirs::home_dir()
329 .ok_or_else(|| {
330 SecretError::AgeDecrypt(
331 "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
332 )
333 })?
334 .join(".mur/age/identity.txt"),
335 };
336
337 let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
338 SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
339 })?;
340 let identity: age::x25519::Identity = id_str
341 .trim()
342 .parse()
343 .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
344
345 let decryptor =
346 age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
347 let mut reader = decryptor
348 .decrypt(std::iter::once(&identity as &dyn age::Identity))
349 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
350 let mut out = String::new();
351 use std::io::Read;
352 reader
353 .read_to_string(&mut out)
354 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
355 Ok(out)
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use serde_yaml_ng as yaml;
362
363 #[test]
364 fn parses_env_form() {
365 let s: SecretRef = yaml::from_str("env:ANTHROPIC_API_KEY").unwrap();
366 assert_eq!(s, SecretRef::Env("ANTHROPIC_API_KEY".into()));
367 }
368
369 #[test]
370 fn parses_keychain_form() {
371 let s: SecretRef = yaml::from_str("keychain:mur/anthropic-oauth").unwrap();
372 assert_eq!(
373 s,
374 SecretRef::Keychain {
375 service: "mur".into(),
376 account: "anthropic-oauth".into()
377 }
378 );
379 }
380
381 #[test]
382 fn parses_file_form() {
383 let s: SecretRef = yaml::from_str("file:/tmp/foo.age").unwrap();
384 assert_eq!(s, SecretRef::File(PathBuf::from("/tmp/foo.age")));
385 }
386
387 #[test]
388 fn parses_cmd_form() {
389 let s: SecretRef = yaml::from_str("cmd:op read op://vault/item/field").unwrap();
390 assert_eq!(s, SecretRef::Cmd("op read op://vault/item/field".into()));
391 }
392
393 #[test]
394 fn rejects_unknown_scheme() {
395 let r: Result<SecretRef, _> = yaml::from_str("plain:supersecret");
396 assert!(r.is_err());
397 }
398
399 #[test]
400 fn round_trip_serde() {
401 let cases = [
402 "env:X",
403 "keychain:svc/acct",
404 "file:/p",
405 "cmd:bin --flag arg",
406 ];
407 for s in cases {
408 let parsed: SecretRef = yaml::from_str(s).unwrap();
409 let back = yaml::to_string(&parsed).unwrap();
410 let normalized = back
412 .trim()
413 .trim_matches(|c: char| c == '"' || c == '\'')
414 .to_string();
415 let reparsed: SecretRef = yaml::from_str(&normalized).unwrap();
416 assert_eq!(parsed, reparsed, "round-trip drift for {s}");
417 }
418 }
419}
420
421#[cfg(test)]
422mod resolve_env_tests {
423 use super::*;
424 use secrecy::ExposeSecret;
425
426 #[tokio::test]
427 async fn resolves_env_when_set() {
428 unsafe {
430 std::env::set_var("MUR_TEST_RESOLVE_ENV", "shhh");
431 }
432 let s = SecretRef::Env("MUR_TEST_RESOLVE_ENV".into());
433 let v = s.resolve().await.unwrap();
434 assert_eq!(v.expose_secret(), "shhh");
435 }
436
437 #[tokio::test]
438 async fn errors_when_env_missing() {
439 let s = SecretRef::Env("MUR_TEST_DEFINITELY_UNSET".into());
440 let err = s.resolve().await.unwrap_err();
441 assert!(matches!(err, SecretError::EnvNotSet(_)), "got {err:?}");
442 }
443
444 #[tokio::test]
445 async fn resolve_to_string_exposes_value_or_none() {
446 unsafe {
448 std::env::set_var("MUR_TEST_RESOLVE_TO_STRING", "kc-abc");
449 }
450 let set = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING".into());
451 assert_eq!(set.resolve_to_string().await.as_deref(), Some("kc-abc"));
452
453 let missing = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING_UNSET".into());
454 assert_eq!(missing.resolve_to_string().await, None);
455 }
456}
457
458#[cfg(test)]
459mod keychain_test_fixture {
460 use keyring::credential::{
472 Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
473 };
474 use std::any::Any;
475 use std::collections::HashMap;
476 use std::sync::{Arc, Mutex};
477 use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};
478
479 type Store = Arc<Mutex<HashMap<(String, String), Vec<u8>>>>;
480
481 struct SharedMockCredential {
482 store: Store,
483 key: (String, String),
484 }
485
486 impl CredentialApi for SharedMockCredential {
487 fn set_secret(&self, password: &[u8]) -> keyring::Result<()> {
488 self.store
489 .lock()
490 .unwrap()
491 .insert(self.key.clone(), password.to_vec());
492 Ok(())
493 }
494 fn get_secret(&self) -> keyring::Result<Vec<u8>> {
495 self.store
496 .lock()
497 .unwrap()
498 .get(&self.key)
499 .cloned()
500 .ok_or(keyring::Error::NoEntry)
501 }
502 fn delete_credential(&self) -> keyring::Result<()> {
503 self.store
504 .lock()
505 .unwrap()
506 .remove(&self.key)
507 .map(|_| ())
508 .ok_or(keyring::Error::NoEntry)
509 }
510 fn as_any(&self) -> &dyn Any {
511 self
512 }
513 }
514
515 struct SharedMockBuilder {
516 store: Store,
517 }
518
519 impl CredentialBuilderApi for SharedMockBuilder {
520 fn build(
521 &self,
522 _target: Option<&str>,
523 service: &str,
524 user: &str,
525 ) -> keyring::Result<Box<Credential>> {
526 Ok(Box::new(SharedMockCredential {
527 store: self.store.clone(),
528 key: (service.to_string(), user.to_string()),
529 }))
530 }
531 fn as_any(&self) -> &dyn Any {
532 self
533 }
534 fn persistence(&self) -> CredentialPersistence {
535 CredentialPersistence::ProcessOnly
536 }
537 }
538
539 static MOCK_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
540
541 pub(super) async fn install_mock(
542 initial: Option<(&str, &str, &str)>,
543 ) -> AsyncMutexGuard<'static, ()> {
544 let g = MOCK_LOCK.lock().await;
545 let store: Store = Arc::new(Mutex::new(HashMap::new()));
546 if let Some((svc, user, pw)) = initial {
547 store
548 .lock()
549 .unwrap()
550 .insert((svc.to_string(), user.to_string()), pw.as_bytes().to_vec());
551 }
552 let builder: Box<CredentialBuilder> = Box::new(SharedMockBuilder { store });
553 keyring::set_default_credential_builder(builder);
554 g
555 }
556}
557
558#[cfg(test)]
559mod resolve_keychain_tests {
560 use super::keychain_test_fixture::install_mock;
561 use super::*;
562 use secrecy::ExposeSecret;
563
564 #[tokio::test]
565 async fn resolves_when_set() {
566 let _g = install_mock(Some(("mur-test", "kc-acct", "kc-secret"))).await;
567 let s = SecretRef::Keychain {
568 service: "mur-test".into(),
569 account: "kc-acct".into(),
570 };
571 let v = s.resolve().await.unwrap();
572 assert_eq!(v.expose_secret(), "kc-secret");
573 }
574
575 #[tokio::test]
576 async fn errors_when_missing() {
577 let _g = install_mock(None).await;
578 let s = SecretRef::Keychain {
579 service: "mur-test".into(),
580 account: "kc-acct".into(),
581 };
582 let err = s.resolve().await.unwrap_err();
583 assert!(
584 matches!(err, SecretError::KeychainNotFound { .. }),
585 "got {err:?}"
586 );
587 }
588}
589
590#[cfg(all(test, unix))]
591mod resolve_file_tests {
592 use super::*;
593 use secrecy::ExposeSecret;
594 use std::os::unix::fs::PermissionsExt;
595 use tempfile::tempdir;
596
597 #[tokio::test]
598 async fn reads_plaintext_0600() {
599 let dir = tempdir().unwrap();
600 let p = dir.path().join("k.txt");
601 std::fs::write(&p, "abc\n").unwrap();
602 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
603 let s = SecretRef::File(p);
604 let v = s.resolve().await.unwrap();
605 assert_eq!(v.expose_secret(), "abc"); }
607
608 #[tokio::test]
609 async fn rejects_world_readable() {
610 let dir = tempdir().unwrap();
611 let p = dir.path().join("k.txt");
612 std::fs::write(&p, "abc").unwrap();
613 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
614 let s = SecretRef::File(p);
615 let err = s.resolve().await.unwrap_err();
616 assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
617 }
618
619 #[tokio::test]
620 async fn decrypts_age_recipient_file() {
621 let dir = tempdir().unwrap();
622 let identity = age::x25519::Identity::generate();
623 let recipient = identity.to_public();
624 let payload = b"shh-from-age";
625
626 let mut encrypted: Vec<u8> = Vec::new();
627 let encryptor =
628 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
629 .unwrap();
630 let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
631 std::io::Write::write_all(&mut writer, payload).unwrap();
632 writer.finish().unwrap();
633
634 let enc_path = dir.path().join("k.age");
635 std::fs::write(&enc_path, &encrypted).unwrap();
636 std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
637 let id_path = dir.path().join("identity.txt");
638 use secrecy::ExposeSecret as _;
639 std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
640 std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
641 unsafe {
645 std::env::set_var("MUR_AGE_IDENTITY_PATH", &id_path);
646 }
647 let s = SecretRef::File(enc_path);
648 let v = s.resolve().await.unwrap();
649 assert_eq!(v.expose_secret(), "shh-from-age");
650 unsafe {
651 std::env::remove_var("MUR_AGE_IDENTITY_PATH");
652 }
653 }
654}
655
656#[cfg(all(test, unix))]
657mod resolve_cmd_tests {
658 use super::*;
659 use secrecy::ExposeSecret;
660
661 #[tokio::test]
662 async fn echoes_stdout() {
663 let s = SecretRef::Cmd("printf shh-from-cmd".into());
664 let v = s.resolve().await.unwrap();
665 assert_eq!(v.expose_secret(), "shh-from-cmd");
666 }
667
668 #[tokio::test]
669 async fn errors_on_non_zero_exit() {
670 let s = SecretRef::Cmd("sh -c 'exit 7'".into());
671 let err = s.resolve().await.unwrap_err();
672 match err {
673 SecretError::Cmd { status, .. } => assert_eq!(status, 7),
674 other => panic!("unexpected: {other:?}"),
675 }
676 }
677}
678
679#[cfg(test)]
680mod check_tests {
681 use super::*;
682
683 #[tokio::test]
684 async fn check_env_present() {
685 unsafe {
687 std::env::set_var("MUR_TEST_CHECK_ENV", "1");
688 }
689 assert!(SecretRef::Env("MUR_TEST_CHECK_ENV".into()).check().await);
690 }
691
692 #[tokio::test]
693 async fn check_env_absent() {
694 assert!(
695 !SecretRef::Env("MUR_TEST_CHECK_DEFINITELY_UNSET".into())
696 .check()
697 .await
698 );
699 }
700}
701
702#[cfg(test)]
703mod keychain_helpers_tests {
704 use super::keychain_test_fixture::install_mock;
705 use super::*;
706 use secrecy::ExposeSecret;
707
708 #[tokio::test]
709 async fn set_then_resolve_round_trips() {
710 let _g = install_mock(None).await;
711 keychain_set("mur-test", "round-trip", "v1").await.unwrap();
712 let v = SecretRef::Keychain {
713 service: "mur-test".into(),
714 account: "round-trip".into(),
715 }
716 .resolve()
717 .await
718 .unwrap();
719 assert_eq!(v.expose_secret(), "v1");
720 }
721
722 #[tokio::test]
723 async fn delete_works() {
724 let _g = install_mock(None).await;
725 keychain_set("mur-test", "to-delete", "v").await.unwrap();
726 keychain_delete("mur-test", "to-delete").await.unwrap();
727 let r = SecretRef::Keychain {
728 service: "mur-test".into(),
729 account: "to-delete".into(),
730 }
731 .resolve()
732 .await;
733 assert!(matches!(r, Err(SecretError::KeychainNotFound { .. })));
734 }
735
736 #[tokio::test]
737 async fn delete_missing_is_idempotent() {
738 let _g = install_mock(None).await;
739 keychain_delete("mur-test", "never-set").await.unwrap();
741 }
742}
743
744#[cfg(test)]
745mod resolve_blocking_tests {
746 use super::*;
747
748 #[test]
749 fn resolve_blocking_env_and_missing() {
750 unsafe { std::env::set_var("MUR_TEST_SECRET_BLOCKING", "s3cret") };
751 let r: SecretRef = "env:MUR_TEST_SECRET_BLOCKING".parse().unwrap();
752 assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
753 unsafe { std::env::remove_var("MUR_TEST_SECRET_BLOCKING") };
754 assert!(r.resolve_blocking().is_err());
755 }
756
757 #[tokio::test]
762 async fn resolve_blocking_inside_current_thread_runtime_does_not_panic() {
763 unsafe { std::env::set_var("MUR_TEST_SECRET_CT_RT", "s3cret") };
764 let r: SecretRef = "env:MUR_TEST_SECRET_CT_RT".parse().unwrap();
765 assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
766 unsafe { std::env::remove_var("MUR_TEST_SECRET_CT_RT") };
767 }
768}