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
97/// Force-block OS keychain access in this process: lookups behave as
98/// "not found", writes are rejected. Exists so processes that must never
99/// trigger a macOS keychain password prompt (test runs, CI) can opt out.
100pub const ENV_KEYCHAIN_DISABLED: &str = "MUR_KEYCHAIN_DISABLED";
101/// Overrides the automatic test-process block below. Set by tests that
102/// install a keyring mock builder (those never reach the real keychain).
103pub const ENV_KEYCHAIN_ALLOW: &str = "MUR_KEYCHAIN_ALLOW";
104
105/// Cargo test binaries get a fresh hash suffix on every rebuild, so macOS
106/// keychain "always allow" ACLs never stick and any test that resolves a
107/// real `keychain:` ref (e.g. via the user's ~/.mur/config.yaml) rains
108/// password prompts on every run. nextest sets `NEXTEST=1` in each test
109/// process — treat that as "no real keychain" unless explicitly re-enabled.
110fn 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    /// Probe whether the secret resolves successfully without surfacing the
154    /// value. Used by GUI/CLI status indicators. Note: for `Cmd` refs this
155    /// actually runs the command, which may have side effects or be slow.
156    pub async fn check(&self) -> bool {
157        self.resolve().await.is_ok()
158    }
159
160    /// Resolve and expose the secret as a plain `String` for callers that must
161    /// hand the raw value to an external API (e.g. an `Authorization: Bearer`
162    /// header). This is the deliberate materialization boundary — keep the
163    /// returned value short-lived and never log or persist it. Returns `None`
164    /// on any resolution failure (missing env var, keychain entry, etc.).
165    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    /// Synchronous resolve for callers outside an async context (CLI
174    /// factories, config loaders). Inside a multi-thread tokio runtime it
175    /// uses block_in_place; inside a current-thread runtime (where
176    /// block_in_place panics) it hops to a fresh thread; otherwise it spins
177    /// a current-thread runtime.
178    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            // Current-thread runtime (e.g. #[tokio::test]): block_in_place
191            // would panic — resolve on a fresh OS thread instead.
192            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    /// Blocking analogue of `resolve_to_string` — same materialization
206    /// caveats apply.
207    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
215/// Read a secret from the OS keychain.
216///
217/// Returns `Ok(None)` when the entry doesn't exist (so callers can fall
218/// through to the next precedence layer cleanly), and `Err(...)` only for
219/// real backend failures (locked keychain, permission denied, malformed
220/// service/account, transport error). Silently swallowing those errors would
221/// mask configuration problems and let the next fallback layer take over
222/// when the user actually expected the keychain entry to be honored.
223///
224/// Pairs with [`keychain_set`] / [`keychain_delete`].
225pub 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
248/// Write a secret to the OS keychain. Used by `mur agent secret set` and the
249/// GUI's `set_secret` command.
250pub 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
272/// Delete a secret from the OS keychain. Idempotent: missing entries are not
273/// an error. Used by `mur agent secret delete`.
274pub 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            // serde-yaml adds a trailing newline / quoting. Strip and compare.
449            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        // SAFETY: uniquely named env var so concurrent tests don't collide.
467        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        // SAFETY: uniquely named env var so concurrent tests don't collide.
485        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    //! Shared mock fixture used by every test module that touches the keyring.
499    //!
500    //! v3's stock `keyring::mock` advertises CredentialPersistence::EntryOnly
501    //! and gives each Entry its own private storage — that breaks our tests
502    //! because resolve() creates a fresh `Entry::new` after setup. The fixture
503    //! below installs a SharedMockBuilder backed by an Arc<Mutex<HashMap>>
504    //! so all Entry instances see the same data.
505    //!
506    //! Tests serialize on a tokio::sync::Mutex (held across await) because
507    //! `set_default_credential_builder` mutates a process-global.
508
509    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    /// Serialize env-var mutation with the mock installs above (both are
580    /// process-global). Used by tests that exercise `keychain_blocked`.
581    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        // The mock never reaches the real OS keychain, so lift the automatic
590        // test-process keychain block (`keychain_blocked`).
591        // SAFETY: env mutation serialized by MOCK_LOCK; nextest runs one test
592        // per process anyway.
593        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        // SAFETY: env mutation serialized on the fixture lock; nextest is
619        // process-per-test anyway.
620        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"); // trailing newline stripped
682    }
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        // SAFETY: setting an env var read by decrypt_age. Tests serialize on
718        // the same env var, so concurrent writes would race; we serialize via
719        // a Mutex-held guard.
720        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        // SAFETY: uniquely named env var so concurrent tests don't collide.
762        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        // No prior set — must still return Ok.
816        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]` runs on a current-thread runtime, where
834    /// `block_in_place` panics. `resolve_blocking` must detect the flavor and
835    /// hop to a fresh thread instead (the crash behind the flaky rollup
836    /// tests on machines whose config carries secret refs).
837    #[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}