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    /// Synchronous resolve for callers outside an async context (CLI
148    /// factories, config loaders). Inside a multi-thread tokio runtime it
149    /// uses block_in_place; inside a current-thread runtime (where
150    /// block_in_place panics) it hops to a fresh thread; otherwise it spins
151    /// a current-thread runtime.
152    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            // Current-thread runtime (e.g. #[tokio::test]): block_in_place
165            // would panic — resolve on a fresh OS thread instead.
166            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    /// Blocking analogue of `resolve_to_string` — same materialization
180    /// caveats apply.
181    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
189/// Read a secret from the OS keychain.
190///
191/// Returns `Ok(None)` when the entry doesn't exist (so callers can fall
192/// through to the next precedence layer cleanly), and `Err(...)` only for
193/// real backend failures (locked keychain, permission denied, malformed
194/// service/account, transport error). Silently swallowing those errors would
195/// mask configuration problems and let the next fallback layer take over
196/// when the user actually expected the keychain entry to be honored.
197///
198/// Pairs with [`keychain_set`] / [`keychain_delete`].
199pub 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
219/// Write a secret to the OS keychain. Used by `mur agent secret set` and the
220/// GUI's `set_secret` command.
221pub 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
237/// Delete a secret from the OS keychain. Idempotent: missing entries are not
238/// an error. Used by `mur agent secret delete`.
239pub 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            // serde-yaml adds a trailing newline / quoting. Strip and compare.
411            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        // SAFETY: uniquely named env var so concurrent tests don't collide.
429        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        // SAFETY: uniquely named env var so concurrent tests don't collide.
447        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    //! Shared mock fixture used by every test module that touches the keyring.
461    //!
462    //! v3's stock `keyring::mock` advertises CredentialPersistence::EntryOnly
463    //! and gives each Entry its own private storage — that breaks our tests
464    //! because resolve() creates a fresh `Entry::new` after setup. The fixture
465    //! below installs a SharedMockBuilder backed by an Arc<Mutex<HashMap>>
466    //! so all Entry instances see the same data.
467    //!
468    //! Tests serialize on a tokio::sync::Mutex (held across await) because
469    //! `set_default_credential_builder` mutates a process-global.
470
471    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"); // trailing newline stripped
606    }
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        // SAFETY: setting an env var read by decrypt_age. Tests serialize on
642        // the same env var, so concurrent writes would race; we serialize via
643        // a Mutex-held guard.
644        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        // SAFETY: uniquely named env var so concurrent tests don't collide.
686        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        // No prior set — must still return Ok.
740        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]` runs on a current-thread runtime, where
758    /// `block_in_place` panics. `resolve_blocking` must detect the flavor and
759    /// hop to a fresh thread instead (the crash behind the flaky rollup
760    /// tests on machines whose config carries secret refs).
761    #[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}