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
97pub const ENV_KEYCHAIN_DISABLED: &str = "MUR_KEYCHAIN_DISABLED";
101pub const ENV_KEYCHAIN_ALLOW: &str = "MUR_KEYCHAIN_ALLOW";
104
105fn keychain_blocked() -> bool {
111 if std::env::var_os(ENV_KEYCHAIN_ALLOW).is_some() {
112 return false;
113 }
114 std::env::var_os(ENV_KEYCHAIN_DISABLED).is_some() || std::env::var_os("NEXTEST").is_some()
115}
116
117impl SecretRef {
118 pub async fn resolve(&self) -> Result<SecretString, SecretError> {
119 match self {
120 SecretRef::Env(var) => std::env::var(var)
121 .map(SecretString::from)
122 .map_err(|_| SecretError::EnvNotSet(var.clone())),
123 SecretRef::Keychain { service, account } if keychain_blocked() => {
124 Err(SecretError::KeychainNotFound {
125 service: service.clone(),
126 account: account.clone(),
127 })
128 }
129 SecretRef::Keychain { service, account } => {
130 let svc = service.clone();
131 let acct = account.clone();
132 let res = tokio::task::spawn_blocking(move || -> Result<String, SecretError> {
133 let entry = keyring::Entry::new(&svc, &acct)
134 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
135 match entry.get_password() {
136 Ok(s) => Ok(s),
137 Err(keyring::Error::NoEntry) => Err(SecretError::KeychainNotFound {
138 service: svc.clone(),
139 account: acct.clone(),
140 }),
141 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
142 }
143 })
144 .await
145 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?;
146 res.map(SecretString::from)
147 }
148 SecretRef::File(path) => resolve_file(path).await,
149 SecretRef::Cmd(spec) => resolve_cmd(spec).await,
150 }
151 }
152
153 pub async fn check(&self) -> bool {
157 self.resolve().await.is_ok()
158 }
159
160 pub async fn resolve_to_string(&self) -> Option<String> {
166 use secrecy::ExposeSecret;
167 self.resolve()
168 .await
169 .ok()
170 .map(|s| s.expose_secret().to_string())
171 }
172
173 pub fn resolve_blocking(&self) -> Result<SecretString, SecretError> {
179 fn fresh_runtime_resolve(r: &SecretRef) -> Result<SecretString, SecretError> {
180 tokio::runtime::Builder::new_current_thread()
181 .enable_all()
182 .build()
183 .map_err(|e| SecretError::KeychainBackend(format!("runtime: {e}")))?
184 .block_on(r.resolve())
185 }
186 match tokio::runtime::Handle::try_current() {
187 Ok(h) if h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
188 tokio::task::block_in_place(|| h.block_on(self.resolve()))
189 }
190 Ok(_) => std::thread::scope(|s| {
193 s.spawn(|| fresh_runtime_resolve(self))
194 .join()
195 .unwrap_or_else(|_| {
196 Err(SecretError::KeychainBackend(
197 "resolver thread panicked".into(),
198 ))
199 })
200 }),
201 Err(_) => fresh_runtime_resolve(self),
202 }
203 }
204
205 pub fn resolve_to_string_blocking(&self) -> Option<String> {
208 use secrecy::ExposeSecret;
209 self.resolve_blocking()
210 .ok()
211 .map(|s| s.expose_secret().to_string())
212 }
213}
214
215pub async fn keychain_get(
226 service: &str,
227 account: &str,
228) -> Result<Option<SecretString>, SecretError> {
229 if keychain_blocked() {
230 return Ok(None);
231 }
232 let svc = service.to_string();
233 let acct = account.to_string();
234 tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
235 let entry = keyring::Entry::new(&svc, &acct)
236 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
237 match entry.get_password() {
238 Ok(s) => Ok(Some(s)),
239 Err(keyring::Error::NoEntry) => Ok(None),
240 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
241 }
242 })
243 .await
244 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
245 .map(|opt| opt.map(SecretString::from))
246}
247
248pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
251 if keychain_blocked() {
252 return Err(SecretError::KeychainBackend(format!(
253 "keychain access disabled in this process ({ENV_KEYCHAIN_DISABLED}/test); \
254 set {ENV_KEYCHAIN_ALLOW}=1 to override"
255 )));
256 }
257 let svc = service.to_string();
258 let acct = account.to_string();
259 let val = value.to_string();
260 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
261 let entry = keyring::Entry::new(&svc, &acct)
262 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
263 entry
264 .set_password(&val)
265 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
266 Ok(())
267 })
268 .await
269 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
270}
271
272pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
275 if keychain_blocked() {
276 return Ok(());
277 }
278 let svc = service.to_string();
279 let acct = account.to_string();
280 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
281 let entry = keyring::Entry::new(&svc, &acct)
282 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
283 match entry.delete_credential() {
284 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
285 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
286 }
287 })
288 .await
289 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
290}
291
292async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
293 let mut parts = shell_words::split(spec)
294 .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
295 if parts.is_empty() {
296 return Err(SecretError::Parse("empty cmd".into()));
297 }
298 let program = parts.remove(0);
299 let output = tokio::process::Command::new(&program)
300 .args(&parts)
301 .output()
302 .await
303 .map_err(|e| SecretError::Cmd {
304 cmd: format!("{spec} ({e})"),
305 status: -1,
306 })?;
307 if !output.status.success() {
308 return Err(SecretError::Cmd {
309 cmd: spec.to_string(),
310 status: output.status.code().unwrap_or(-1),
311 });
312 }
313 let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
314 cmd: format!("{spec} (non-utf8 stdout: {e})"),
315 status: -2,
316 })?;
317 Ok(SecretString::from(
318 s.trim_end_matches(['\n', '\r']).to_string(),
319 ))
320}
321
322async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
323 let expanded = shellexpand::full(&path.to_string_lossy())
324 .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
325 .to_string();
326 let p = std::path::PathBuf::from(expanded);
327
328 #[cfg(unix)]
329 {
330 use std::os::unix::fs::PermissionsExt;
331 let meta = tokio::fs::metadata(&p)
332 .await
333 .map_err(|e| SecretError::FileRead {
334 path: p.display().to_string(),
335 source: e,
336 })?;
337 let mode = meta.permissions().mode() & 0o777;
338 if mode & 0o077 != 0 {
339 return Err(SecretError::FileMode(format!(
340 "{}: mode {:o} grants group/world access",
341 p.display(),
342 mode
343 )));
344 }
345 }
346
347 let bytes = tokio::fs::read(&p)
348 .await
349 .map_err(|e| SecretError::FileRead {
350 path: p.display().to_string(),
351 source: e,
352 })?;
353
354 let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
355 decrypt_age(&bytes).await?
356 } else {
357 String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
358 };
359 let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
360 Ok(SecretString::from(trimmed))
361}
362
363async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
364 let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
365 Ok(p) => std::path::PathBuf::from(p),
366 Err(_) => dirs::home_dir()
367 .ok_or_else(|| {
368 SecretError::AgeDecrypt(
369 "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
370 )
371 })?
372 .join(".mur/age/identity.txt"),
373 };
374
375 let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
376 SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
377 })?;
378 let identity: age::x25519::Identity = id_str
379 .trim()
380 .parse()
381 .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
382
383 let decryptor =
384 age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
385 let mut reader = decryptor
386 .decrypt(std::iter::once(&identity as &dyn age::Identity))
387 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
388 let mut out = String::new();
389 use std::io::Read;
390 reader
391 .read_to_string(&mut out)
392 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
393 Ok(out)
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use serde_yaml_ng as yaml;
400
401 #[test]
402 fn parses_env_form() {
403 let s: SecretRef = yaml::from_str("env:ANTHROPIC_API_KEY").unwrap();
404 assert_eq!(s, SecretRef::Env("ANTHROPIC_API_KEY".into()));
405 }
406
407 #[test]
408 fn parses_keychain_form() {
409 let s: SecretRef = yaml::from_str("keychain:mur/anthropic-oauth").unwrap();
410 assert_eq!(
411 s,
412 SecretRef::Keychain {
413 service: "mur".into(),
414 account: "anthropic-oauth".into()
415 }
416 );
417 }
418
419 #[test]
420 fn parses_file_form() {
421 let s: SecretRef = yaml::from_str("file:/tmp/foo.age").unwrap();
422 assert_eq!(s, SecretRef::File(PathBuf::from("/tmp/foo.age")));
423 }
424
425 #[test]
426 fn parses_cmd_form() {
427 let s: SecretRef = yaml::from_str("cmd:op read op://vault/item/field").unwrap();
428 assert_eq!(s, SecretRef::Cmd("op read op://vault/item/field".into()));
429 }
430
431 #[test]
432 fn rejects_unknown_scheme() {
433 let r: Result<SecretRef, _> = yaml::from_str("plain:supersecret");
434 assert!(r.is_err());
435 }
436
437 #[test]
438 fn round_trip_serde() {
439 let cases = [
440 "env:X",
441 "keychain:svc/acct",
442 "file:/p",
443 "cmd:bin --flag arg",
444 ];
445 for s in cases {
446 let parsed: SecretRef = yaml::from_str(s).unwrap();
447 let back = yaml::to_string(&parsed).unwrap();
448 let normalized = back
450 .trim()
451 .trim_matches(|c: char| c == '"' || c == '\'')
452 .to_string();
453 let reparsed: SecretRef = yaml::from_str(&normalized).unwrap();
454 assert_eq!(parsed, reparsed, "round-trip drift for {s}");
455 }
456 }
457}
458
459#[cfg(test)]
460mod resolve_env_tests {
461 use super::*;
462 use secrecy::ExposeSecret;
463
464 #[tokio::test]
465 async fn resolves_env_when_set() {
466 unsafe {
468 std::env::set_var("MUR_TEST_RESOLVE_ENV", "shhh");
469 }
470 let s = SecretRef::Env("MUR_TEST_RESOLVE_ENV".into());
471 let v = s.resolve().await.unwrap();
472 assert_eq!(v.expose_secret(), "shhh");
473 }
474
475 #[tokio::test]
476 async fn errors_when_env_missing() {
477 let s = SecretRef::Env("MUR_TEST_DEFINITELY_UNSET".into());
478 let err = s.resolve().await.unwrap_err();
479 assert!(matches!(err, SecretError::EnvNotSet(_)), "got {err:?}");
480 }
481
482 #[tokio::test]
483 async fn resolve_to_string_exposes_value_or_none() {
484 unsafe {
486 std::env::set_var("MUR_TEST_RESOLVE_TO_STRING", "kc-abc");
487 }
488 let set = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING".into());
489 assert_eq!(set.resolve_to_string().await.as_deref(), Some("kc-abc"));
490
491 let missing = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING_UNSET".into());
492 assert_eq!(missing.resolve_to_string().await, None);
493 }
494}
495
496#[cfg(test)]
497mod keychain_test_fixture {
498 use keyring::credential::{
510 Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
511 };
512 use std::any::Any;
513 use std::collections::HashMap;
514 use std::sync::{Arc, Mutex};
515 use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};
516
517 type Store = Arc<Mutex<HashMap<(String, String), Vec<u8>>>>;
518
519 struct SharedMockCredential {
520 store: Store,
521 key: (String, String),
522 }
523
524 impl CredentialApi for SharedMockCredential {
525 fn set_secret(&self, password: &[u8]) -> keyring::Result<()> {
526 self.store
527 .lock()
528 .unwrap()
529 .insert(self.key.clone(), password.to_vec());
530 Ok(())
531 }
532 fn get_secret(&self) -> keyring::Result<Vec<u8>> {
533 self.store
534 .lock()
535 .unwrap()
536 .get(&self.key)
537 .cloned()
538 .ok_or(keyring::Error::NoEntry)
539 }
540 fn delete_credential(&self) -> keyring::Result<()> {
541 self.store
542 .lock()
543 .unwrap()
544 .remove(&self.key)
545 .map(|_| ())
546 .ok_or(keyring::Error::NoEntry)
547 }
548 fn as_any(&self) -> &dyn Any {
549 self
550 }
551 }
552
553 struct SharedMockBuilder {
554 store: Store,
555 }
556
557 impl CredentialBuilderApi for SharedMockBuilder {
558 fn build(
559 &self,
560 _target: Option<&str>,
561 service: &str,
562 user: &str,
563 ) -> keyring::Result<Box<Credential>> {
564 Ok(Box::new(SharedMockCredential {
565 store: self.store.clone(),
566 key: (service.to_string(), user.to_string()),
567 }))
568 }
569 fn as_any(&self) -> &dyn Any {
570 self
571 }
572 fn persistence(&self) -> CredentialPersistence {
573 CredentialPersistence::ProcessOnly
574 }
575 }
576
577 static MOCK_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
578
579 pub(super) async fn env_lock() -> AsyncMutexGuard<'static, ()> {
582 MOCK_LOCK.lock().await
583 }
584
585 pub(super) async fn install_mock(
586 initial: Option<(&str, &str, &str)>,
587 ) -> AsyncMutexGuard<'static, ()> {
588 let g = MOCK_LOCK.lock().await;
589 unsafe {
594 std::env::set_var(super::ENV_KEYCHAIN_ALLOW, "1");
595 }
596 let store: Store = Arc::new(Mutex::new(HashMap::new()));
597 if let Some((svc, user, pw)) = initial {
598 store
599 .lock()
600 .unwrap()
601 .insert((svc.to_string(), user.to_string()), pw.as_bytes().to_vec());
602 }
603 let builder: Box<CredentialBuilder> = Box::new(SharedMockBuilder { store });
604 keyring::set_default_credential_builder(builder);
605 g
606 }
607}
608
609#[cfg(test)]
610mod resolve_keychain_tests {
611 use super::keychain_test_fixture::install_mock;
612 use super::*;
613 use secrecy::ExposeSecret;
614
615 #[tokio::test]
616 async fn blocked_process_never_reaches_keychain() {
617 let _g = super::keychain_test_fixture::env_lock().await;
618 unsafe {
621 std::env::remove_var(ENV_KEYCHAIN_ALLOW);
622 std::env::set_var(ENV_KEYCHAIN_DISABLED, "1");
623 }
624 let s = SecretRef::Keychain {
625 service: "mur-test".into(),
626 account: "nope".into(),
627 };
628 assert!(matches!(
629 s.resolve().await,
630 Err(SecretError::KeychainNotFound { .. })
631 ));
632 assert!(keychain_get("mur-test", "nope").await.unwrap().is_none());
633 assert!(keychain_set("mur-test", "nope", "v").await.is_err());
634 assert!(keychain_delete("mur-test", "nope").await.is_ok());
635 unsafe {
636 std::env::remove_var(ENV_KEYCHAIN_DISABLED);
637 }
638 }
639
640 #[tokio::test]
641 async fn resolves_when_set() {
642 let _g = install_mock(Some(("mur-test", "kc-acct", "kc-secret"))).await;
643 let s = SecretRef::Keychain {
644 service: "mur-test".into(),
645 account: "kc-acct".into(),
646 };
647 let v = s.resolve().await.unwrap();
648 assert_eq!(v.expose_secret(), "kc-secret");
649 }
650
651 #[tokio::test]
652 async fn errors_when_missing() {
653 let _g = install_mock(None).await;
654 let s = SecretRef::Keychain {
655 service: "mur-test".into(),
656 account: "kc-acct".into(),
657 };
658 let err = s.resolve().await.unwrap_err();
659 assert!(
660 matches!(err, SecretError::KeychainNotFound { .. }),
661 "got {err:?}"
662 );
663 }
664}
665
666#[cfg(all(test, unix))]
667mod resolve_file_tests {
668 use super::*;
669 use secrecy::ExposeSecret;
670 use std::os::unix::fs::PermissionsExt;
671 use tempfile::tempdir;
672
673 #[tokio::test]
674 async fn reads_plaintext_0600() {
675 let dir = tempdir().unwrap();
676 let p = dir.path().join("k.txt");
677 std::fs::write(&p, "abc\n").unwrap();
678 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
679 let s = SecretRef::File(p);
680 let v = s.resolve().await.unwrap();
681 assert_eq!(v.expose_secret(), "abc"); }
683
684 #[tokio::test]
685 async fn rejects_world_readable() {
686 let dir = tempdir().unwrap();
687 let p = dir.path().join("k.txt");
688 std::fs::write(&p, "abc").unwrap();
689 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
690 let s = SecretRef::File(p);
691 let err = s.resolve().await.unwrap_err();
692 assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
693 }
694
695 #[tokio::test]
696 async fn decrypts_age_recipient_file() {
697 let dir = tempdir().unwrap();
698 let identity = age::x25519::Identity::generate();
699 let recipient = identity.to_public();
700 let payload = b"shh-from-age";
701
702 let mut encrypted: Vec<u8> = Vec::new();
703 let encryptor =
704 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
705 .unwrap();
706 let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
707 std::io::Write::write_all(&mut writer, payload).unwrap();
708 writer.finish().unwrap();
709
710 let enc_path = dir.path().join("k.age");
711 std::fs::write(&enc_path, &encrypted).unwrap();
712 std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
713 let id_path = dir.path().join("identity.txt");
714 use secrecy::ExposeSecret as _;
715 std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
716 std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
717 unsafe {
721 std::env::set_var("MUR_AGE_IDENTITY_PATH", &id_path);
722 }
723 let s = SecretRef::File(enc_path);
724 let v = s.resolve().await.unwrap();
725 assert_eq!(v.expose_secret(), "shh-from-age");
726 unsafe {
727 std::env::remove_var("MUR_AGE_IDENTITY_PATH");
728 }
729 }
730}
731
732#[cfg(all(test, unix))]
733mod resolve_cmd_tests {
734 use super::*;
735 use secrecy::ExposeSecret;
736
737 #[tokio::test]
738 async fn echoes_stdout() {
739 let s = SecretRef::Cmd("printf shh-from-cmd".into());
740 let v = s.resolve().await.unwrap();
741 assert_eq!(v.expose_secret(), "shh-from-cmd");
742 }
743
744 #[tokio::test]
745 async fn errors_on_non_zero_exit() {
746 let s = SecretRef::Cmd("sh -c 'exit 7'".into());
747 let err = s.resolve().await.unwrap_err();
748 match err {
749 SecretError::Cmd { status, .. } => assert_eq!(status, 7),
750 other => panic!("unexpected: {other:?}"),
751 }
752 }
753}
754
755#[cfg(test)]
756mod check_tests {
757 use super::*;
758
759 #[tokio::test]
760 async fn check_env_present() {
761 unsafe {
763 std::env::set_var("MUR_TEST_CHECK_ENV", "1");
764 }
765 assert!(SecretRef::Env("MUR_TEST_CHECK_ENV".into()).check().await);
766 }
767
768 #[tokio::test]
769 async fn check_env_absent() {
770 assert!(
771 !SecretRef::Env("MUR_TEST_CHECK_DEFINITELY_UNSET".into())
772 .check()
773 .await
774 );
775 }
776}
777
778#[cfg(test)]
779mod keychain_helpers_tests {
780 use super::keychain_test_fixture::install_mock;
781 use super::*;
782 use secrecy::ExposeSecret;
783
784 #[tokio::test]
785 async fn set_then_resolve_round_trips() {
786 let _g = install_mock(None).await;
787 keychain_set("mur-test", "round-trip", "v1").await.unwrap();
788 let v = SecretRef::Keychain {
789 service: "mur-test".into(),
790 account: "round-trip".into(),
791 }
792 .resolve()
793 .await
794 .unwrap();
795 assert_eq!(v.expose_secret(), "v1");
796 }
797
798 #[tokio::test]
799 async fn delete_works() {
800 let _g = install_mock(None).await;
801 keychain_set("mur-test", "to-delete", "v").await.unwrap();
802 keychain_delete("mur-test", "to-delete").await.unwrap();
803 let r = SecretRef::Keychain {
804 service: "mur-test".into(),
805 account: "to-delete".into(),
806 }
807 .resolve()
808 .await;
809 assert!(matches!(r, Err(SecretError::KeychainNotFound { .. })));
810 }
811
812 #[tokio::test]
813 async fn delete_missing_is_idempotent() {
814 let _g = install_mock(None).await;
815 keychain_delete("mur-test", "never-set").await.unwrap();
817 }
818}
819
820#[cfg(test)]
821mod resolve_blocking_tests {
822 use super::*;
823
824 #[test]
825 fn resolve_blocking_env_and_missing() {
826 unsafe { std::env::set_var("MUR_TEST_SECRET_BLOCKING", "s3cret") };
827 let r: SecretRef = "env:MUR_TEST_SECRET_BLOCKING".parse().unwrap();
828 assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
829 unsafe { std::env::remove_var("MUR_TEST_SECRET_BLOCKING") };
830 assert!(r.resolve_blocking().is_err());
831 }
832
833 #[tokio::test]
838 async fn resolve_blocking_inside_current_thread_runtime_does_not_panic() {
839 unsafe { std::env::set_var("MUR_TEST_SECRET_CT_RT", "s3cret") };
840 let r: SecretRef = "env:MUR_TEST_SECRET_CT_RT".parse().unwrap();
841 assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
842 unsafe { std::env::remove_var("MUR_TEST_SECRET_CT_RT") };
843 }
844}