Skip to main content

mur_common/
secret.rs

1//! Typed reference to a secret value. The reference itself is safe to
2//! commit / log / serialize; the resolved value (`SecretString`) is
3//! zeroized on drop.
4//!
5//! Wire format is a single string with a colon-prefixed scheme:
6//!   env:VAR_NAME
7//!   keychain:service/account
8//!   file:/absolute/or/~-path[.age]
9//!   cmd:./script-or-binary args…
10
11use 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    /// Probe whether the secret resolves successfully without surfacing the
128    /// value. Used by GUI/CLI status indicators. Note: for `Cmd` refs this
129    /// actually runs the command, which may have side effects or be slow.
130    pub async fn check(&self) -> bool {
131        self.resolve().await.is_ok()
132    }
133
134    /// Resolve and expose the secret as a plain `String` for callers that must
135    /// hand the raw value to an external API (e.g. an `Authorization: Bearer`
136    /// header). This is the deliberate materialization boundary — keep the
137    /// returned value short-lived and never log or persist it. Returns `None`
138    /// on any resolution failure (missing env var, keychain entry, etc.).
139    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
148/// Read a secret from the OS keychain.
149///
150/// Returns `Ok(None)` when the entry doesn't exist (so callers can fall
151/// through to the next precedence layer cleanly), and `Err(...)` only for
152/// real backend failures (locked keychain, permission denied, malformed
153/// service/account, transport error). Silently swallowing those errors would
154/// mask configuration problems and let the next fallback layer take over
155/// when the user actually expected the keychain entry to be honored.
156///
157/// Pairs with [`keychain_set`] / [`keychain_delete`].
158pub async fn keychain_get(
159    service: &str,
160    account: &str,
161) -> Result<Option<SecretString>, SecretError> {
162    let svc = service.to_string();
163    let acct = account.to_string();
164    tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
165        let entry = keyring::Entry::new(&svc, &acct)
166            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
167        match entry.get_password() {
168            Ok(s) => Ok(Some(s)),
169            Err(keyring::Error::NoEntry) => Ok(None),
170            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
171        }
172    })
173    .await
174    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
175    .map(|opt| opt.map(SecretString::from))
176}
177
178/// Write a secret to the OS keychain. Used by `mur agent secret set` and the
179/// GUI's `set_secret` command.
180pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
181    let svc = service.to_string();
182    let acct = account.to_string();
183    let val = value.to_string();
184    tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
185        let entry = keyring::Entry::new(&svc, &acct)
186            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
187        entry
188            .set_password(&val)
189            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
190        Ok(())
191    })
192    .await
193    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
194}
195
196/// Delete a secret from the OS keychain. Idempotent: missing entries are not
197/// an error. Used by `mur agent secret delete`.
198pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
199    let svc = service.to_string();
200    let acct = account.to_string();
201    tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
202        let entry = keyring::Entry::new(&svc, &acct)
203            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
204        match entry.delete_credential() {
205            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
206            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
207        }
208    })
209    .await
210    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
211}
212
213async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
214    let mut parts = shell_words::split(spec)
215        .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
216    if parts.is_empty() {
217        return Err(SecretError::Parse("empty cmd".into()));
218    }
219    let program = parts.remove(0);
220    let output = tokio::process::Command::new(&program)
221        .args(&parts)
222        .output()
223        .await
224        .map_err(|e| SecretError::Cmd {
225            cmd: format!("{spec} ({e})"),
226            status: -1,
227        })?;
228    if !output.status.success() {
229        return Err(SecretError::Cmd {
230            cmd: spec.to_string(),
231            status: output.status.code().unwrap_or(-1),
232        });
233    }
234    let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
235        cmd: format!("{spec} (non-utf8 stdout: {e})"),
236        status: -2,
237    })?;
238    Ok(SecretString::from(
239        s.trim_end_matches(['\n', '\r']).to_string(),
240    ))
241}
242
243async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
244    let expanded = shellexpand::full(&path.to_string_lossy())
245        .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
246        .to_string();
247    let p = std::path::PathBuf::from(expanded);
248
249    #[cfg(unix)]
250    {
251        use std::os::unix::fs::PermissionsExt;
252        let meta = tokio::fs::metadata(&p)
253            .await
254            .map_err(|e| SecretError::FileRead {
255                path: p.display().to_string(),
256                source: e,
257            })?;
258        let mode = meta.permissions().mode() & 0o777;
259        if mode & 0o077 != 0 {
260            return Err(SecretError::FileMode(format!(
261                "{}: mode {:o} grants group/world access",
262                p.display(),
263                mode
264            )));
265        }
266    }
267
268    let bytes = tokio::fs::read(&p)
269        .await
270        .map_err(|e| SecretError::FileRead {
271            path: p.display().to_string(),
272            source: e,
273        })?;
274
275    let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
276        decrypt_age(&bytes).await?
277    } else {
278        String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
279    };
280    let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
281    Ok(SecretString::from(trimmed))
282}
283
284async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
285    let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
286        Ok(p) => std::path::PathBuf::from(p),
287        Err(_) => dirs::home_dir()
288            .ok_or_else(|| {
289                SecretError::AgeDecrypt(
290                    "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
291                )
292            })?
293            .join(".mur/age/identity.txt"),
294    };
295
296    let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
297        SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
298    })?;
299    let identity: age::x25519::Identity = id_str
300        .trim()
301        .parse()
302        .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
303
304    let decryptor =
305        age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
306    let mut reader = decryptor
307        .decrypt(std::iter::once(&identity as &dyn age::Identity))
308        .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
309    let mut out = String::new();
310    use std::io::Read;
311    reader
312        .read_to_string(&mut out)
313        .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
314    Ok(out)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use serde_yaml_ng as yaml;
321
322    #[test]
323    fn parses_env_form() {
324        let s: SecretRef = yaml::from_str("env:ANTHROPIC_API_KEY").unwrap();
325        assert_eq!(s, SecretRef::Env("ANTHROPIC_API_KEY".into()));
326    }
327
328    #[test]
329    fn parses_keychain_form() {
330        let s: SecretRef = yaml::from_str("keychain:mur/anthropic-oauth").unwrap();
331        assert_eq!(
332            s,
333            SecretRef::Keychain {
334                service: "mur".into(),
335                account: "anthropic-oauth".into()
336            }
337        );
338    }
339
340    #[test]
341    fn parses_file_form() {
342        let s: SecretRef = yaml::from_str("file:/tmp/foo.age").unwrap();
343        assert_eq!(s, SecretRef::File(PathBuf::from("/tmp/foo.age")));
344    }
345
346    #[test]
347    fn parses_cmd_form() {
348        let s: SecretRef = yaml::from_str("cmd:op read op://vault/item/field").unwrap();
349        assert_eq!(s, SecretRef::Cmd("op read op://vault/item/field".into()));
350    }
351
352    #[test]
353    fn rejects_unknown_scheme() {
354        let r: Result<SecretRef, _> = yaml::from_str("plain:supersecret");
355        assert!(r.is_err());
356    }
357
358    #[test]
359    fn round_trip_serde() {
360        let cases = [
361            "env:X",
362            "keychain:svc/acct",
363            "file:/p",
364            "cmd:bin --flag arg",
365        ];
366        for s in cases {
367            let parsed: SecretRef = yaml::from_str(s).unwrap();
368            let back = yaml::to_string(&parsed).unwrap();
369            // serde-yaml adds a trailing newline / quoting. Strip and compare.
370            let normalized = back
371                .trim()
372                .trim_matches(|c: char| c == '"' || c == '\'')
373                .to_string();
374            let reparsed: SecretRef = yaml::from_str(&normalized).unwrap();
375            assert_eq!(parsed, reparsed, "round-trip drift for {s}");
376        }
377    }
378}
379
380#[cfg(test)]
381mod resolve_env_tests {
382    use super::*;
383    use secrecy::ExposeSecret;
384
385    #[tokio::test]
386    async fn resolves_env_when_set() {
387        // SAFETY: uniquely named env var so concurrent tests don't collide.
388        unsafe {
389            std::env::set_var("MUR_TEST_RESOLVE_ENV", "shhh");
390        }
391        let s = SecretRef::Env("MUR_TEST_RESOLVE_ENV".into());
392        let v = s.resolve().await.unwrap();
393        assert_eq!(v.expose_secret(), "shhh");
394    }
395
396    #[tokio::test]
397    async fn errors_when_env_missing() {
398        let s = SecretRef::Env("MUR_TEST_DEFINITELY_UNSET".into());
399        let err = s.resolve().await.unwrap_err();
400        assert!(matches!(err, SecretError::EnvNotSet(_)), "got {err:?}");
401    }
402
403    #[tokio::test]
404    async fn resolve_to_string_exposes_value_or_none() {
405        // SAFETY: uniquely named env var so concurrent tests don't collide.
406        unsafe {
407            std::env::set_var("MUR_TEST_RESOLVE_TO_STRING", "kc-abc");
408        }
409        let set = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING".into());
410        assert_eq!(set.resolve_to_string().await.as_deref(), Some("kc-abc"));
411
412        let missing = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING_UNSET".into());
413        assert_eq!(missing.resolve_to_string().await, None);
414    }
415}
416
417#[cfg(test)]
418mod keychain_test_fixture {
419    //! Shared mock fixture used by every test module that touches the keyring.
420    //!
421    //! v3's stock `keyring::mock` advertises CredentialPersistence::EntryOnly
422    //! and gives each Entry its own private storage — that breaks our tests
423    //! because resolve() creates a fresh `Entry::new` after setup. The fixture
424    //! below installs a SharedMockBuilder backed by an Arc<Mutex<HashMap>>
425    //! so all Entry instances see the same data.
426    //!
427    //! Tests serialize on a tokio::sync::Mutex (held across await) because
428    //! `set_default_credential_builder` mutates a process-global.
429
430    use keyring::credential::{
431        Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
432    };
433    use std::any::Any;
434    use std::collections::HashMap;
435    use std::sync::{Arc, Mutex};
436    use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};
437
438    type Store = Arc<Mutex<HashMap<(String, String), Vec<u8>>>>;
439
440    struct SharedMockCredential {
441        store: Store,
442        key: (String, String),
443    }
444
445    impl CredentialApi for SharedMockCredential {
446        fn set_secret(&self, password: &[u8]) -> keyring::Result<()> {
447            self.store
448                .lock()
449                .unwrap()
450                .insert(self.key.clone(), password.to_vec());
451            Ok(())
452        }
453        fn get_secret(&self) -> keyring::Result<Vec<u8>> {
454            self.store
455                .lock()
456                .unwrap()
457                .get(&self.key)
458                .cloned()
459                .ok_or(keyring::Error::NoEntry)
460        }
461        fn delete_credential(&self) -> keyring::Result<()> {
462            self.store
463                .lock()
464                .unwrap()
465                .remove(&self.key)
466                .map(|_| ())
467                .ok_or(keyring::Error::NoEntry)
468        }
469        fn as_any(&self) -> &dyn Any {
470            self
471        }
472    }
473
474    struct SharedMockBuilder {
475        store: Store,
476    }
477
478    impl CredentialBuilderApi for SharedMockBuilder {
479        fn build(
480            &self,
481            _target: Option<&str>,
482            service: &str,
483            user: &str,
484        ) -> keyring::Result<Box<Credential>> {
485            Ok(Box::new(SharedMockCredential {
486                store: self.store.clone(),
487                key: (service.to_string(), user.to_string()),
488            }))
489        }
490        fn as_any(&self) -> &dyn Any {
491            self
492        }
493        fn persistence(&self) -> CredentialPersistence {
494            CredentialPersistence::ProcessOnly
495        }
496    }
497
498    static MOCK_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
499
500    pub(super) async fn install_mock(
501        initial: Option<(&str, &str, &str)>,
502    ) -> AsyncMutexGuard<'static, ()> {
503        let g = MOCK_LOCK.lock().await;
504        let store: Store = Arc::new(Mutex::new(HashMap::new()));
505        if let Some((svc, user, pw)) = initial {
506            store
507                .lock()
508                .unwrap()
509                .insert((svc.to_string(), user.to_string()), pw.as_bytes().to_vec());
510        }
511        let builder: Box<CredentialBuilder> = Box::new(SharedMockBuilder { store });
512        keyring::set_default_credential_builder(builder);
513        g
514    }
515}
516
517#[cfg(test)]
518mod resolve_keychain_tests {
519    use super::keychain_test_fixture::install_mock;
520    use super::*;
521    use secrecy::ExposeSecret;
522
523    #[tokio::test]
524    async fn resolves_when_set() {
525        let _g = install_mock(Some(("mur-test", "kc-acct", "kc-secret"))).await;
526        let s = SecretRef::Keychain {
527            service: "mur-test".into(),
528            account: "kc-acct".into(),
529        };
530        let v = s.resolve().await.unwrap();
531        assert_eq!(v.expose_secret(), "kc-secret");
532    }
533
534    #[tokio::test]
535    async fn errors_when_missing() {
536        let _g = install_mock(None).await;
537        let s = SecretRef::Keychain {
538            service: "mur-test".into(),
539            account: "kc-acct".into(),
540        };
541        let err = s.resolve().await.unwrap_err();
542        assert!(
543            matches!(err, SecretError::KeychainNotFound { .. }),
544            "got {err:?}"
545        );
546    }
547}
548
549#[cfg(all(test, unix))]
550mod resolve_file_tests {
551    use super::*;
552    use secrecy::ExposeSecret;
553    use std::os::unix::fs::PermissionsExt;
554    use tempfile::tempdir;
555
556    #[tokio::test]
557    async fn reads_plaintext_0600() {
558        let dir = tempdir().unwrap();
559        let p = dir.path().join("k.txt");
560        std::fs::write(&p, "abc\n").unwrap();
561        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
562        let s = SecretRef::File(p);
563        let v = s.resolve().await.unwrap();
564        assert_eq!(v.expose_secret(), "abc"); // trailing newline stripped
565    }
566
567    #[tokio::test]
568    async fn rejects_world_readable() {
569        let dir = tempdir().unwrap();
570        let p = dir.path().join("k.txt");
571        std::fs::write(&p, "abc").unwrap();
572        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
573        let s = SecretRef::File(p);
574        let err = s.resolve().await.unwrap_err();
575        assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
576    }
577
578    #[tokio::test]
579    async fn decrypts_age_recipient_file() {
580        let dir = tempdir().unwrap();
581        let identity = age::x25519::Identity::generate();
582        let recipient = identity.to_public();
583        let payload = b"shh-from-age";
584
585        let mut encrypted: Vec<u8> = Vec::new();
586        let encryptor =
587            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
588                .unwrap();
589        let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
590        std::io::Write::write_all(&mut writer, payload).unwrap();
591        writer.finish().unwrap();
592
593        let enc_path = dir.path().join("k.age");
594        std::fs::write(&enc_path, &encrypted).unwrap();
595        std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
596        let id_path = dir.path().join("identity.txt");
597        use secrecy::ExposeSecret as _;
598        std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
599        std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
600        // SAFETY: setting an env var read by decrypt_age. Tests serialize on
601        // the same env var, so concurrent writes would race; we serialize via
602        // a Mutex-held guard.
603        unsafe {
604            std::env::set_var("MUR_AGE_IDENTITY_PATH", &id_path);
605        }
606        let s = SecretRef::File(enc_path);
607        let v = s.resolve().await.unwrap();
608        assert_eq!(v.expose_secret(), "shh-from-age");
609        unsafe {
610            std::env::remove_var("MUR_AGE_IDENTITY_PATH");
611        }
612    }
613}
614
615#[cfg(all(test, unix))]
616mod resolve_cmd_tests {
617    use super::*;
618    use secrecy::ExposeSecret;
619
620    #[tokio::test]
621    async fn echoes_stdout() {
622        let s = SecretRef::Cmd("printf shh-from-cmd".into());
623        let v = s.resolve().await.unwrap();
624        assert_eq!(v.expose_secret(), "shh-from-cmd");
625    }
626
627    #[tokio::test]
628    async fn errors_on_non_zero_exit() {
629        let s = SecretRef::Cmd("sh -c 'exit 7'".into());
630        let err = s.resolve().await.unwrap_err();
631        match err {
632            SecretError::Cmd { status, .. } => assert_eq!(status, 7),
633            other => panic!("unexpected: {other:?}"),
634        }
635    }
636}
637
638#[cfg(test)]
639mod check_tests {
640    use super::*;
641
642    #[tokio::test]
643    async fn check_env_present() {
644        // SAFETY: uniquely named env var so concurrent tests don't collide.
645        unsafe {
646            std::env::set_var("MUR_TEST_CHECK_ENV", "1");
647        }
648        assert!(SecretRef::Env("MUR_TEST_CHECK_ENV".into()).check().await);
649    }
650
651    #[tokio::test]
652    async fn check_env_absent() {
653        assert!(
654            !SecretRef::Env("MUR_TEST_CHECK_DEFINITELY_UNSET".into())
655                .check()
656                .await
657        );
658    }
659}
660
661#[cfg(test)]
662mod keychain_helpers_tests {
663    use super::keychain_test_fixture::install_mock;
664    use super::*;
665    use secrecy::ExposeSecret;
666
667    #[tokio::test]
668    async fn set_then_resolve_round_trips() {
669        let _g = install_mock(None).await;
670        keychain_set("mur-test", "round-trip", "v1").await.unwrap();
671        let v = SecretRef::Keychain {
672            service: "mur-test".into(),
673            account: "round-trip".into(),
674        }
675        .resolve()
676        .await
677        .unwrap();
678        assert_eq!(v.expose_secret(), "v1");
679    }
680
681    #[tokio::test]
682    async fn delete_works() {
683        let _g = install_mock(None).await;
684        keychain_set("mur-test", "to-delete", "v").await.unwrap();
685        keychain_delete("mur-test", "to-delete").await.unwrap();
686        let r = SecretRef::Keychain {
687            service: "mur-test".into(),
688            account: "to-delete".into(),
689        }
690        .resolve()
691        .await;
692        assert!(matches!(r, Err(SecretError::KeychainNotFound { .. })));
693    }
694
695    #[tokio::test]
696    async fn delete_missing_is_idempotent() {
697        let _g = install_mock(None).await;
698        // No prior set — must still return Ok.
699        keychain_delete("mur-test", "never-set").await.unwrap();
700    }
701}