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
47/// Values resolved BEFORE a sandbox seals, for reading after it has.
48///
49/// The problem this exists for: an agent's provider secret is resolved when the
50/// LLM client is built (`supervisor.rs:384`), which is AFTER
51/// `sandbox::apply` (`:314`). A `file:` ref pointing inside `~/.mur/secrets/`
52/// is therefore unreadable — that directory is a denied credential path — and
53/// the caller silently falls back to a per-agent Keychain lookup, which is the
54/// #866 failure. Both paths were broken at once on a real install, each hiding
55/// the other.
56///
57/// The identity key already solves this by ordering: loaded at
58/// `supervisor.rs:174`, before the seal. This gives secrets the same treatment.
59/// The agent ends up holding the VALUE and never the path, so the `secrets/`
60/// deny is not weakened at all — it stays a directory no agent may open.
61///
62/// Deliberately a value cache and not a path cache: nothing here lets a
63/// post-seal caller learn where a secret came from, only what it was.
64static PRESEAL_CACHE: std::sync::OnceLock<std::sync::Mutex<Vec<(SecretRef, SecretString)>>> =
65    std::sync::OnceLock::new();
66
67fn preseal_cache() -> &'static std::sync::Mutex<Vec<(SecretRef, SecretString)>> {
68    PRESEAL_CACHE.get_or_init(|| std::sync::Mutex::new(Vec::new()))
69}
70
71/// Resolve `r` now and remember it, so a later `resolve_blocking` succeeds even
72/// once the path is unreachable. Call before sealing. Errors are the caller's
73/// to report — a secret that cannot be resolved pre-seal is not cached, and the
74/// later lookup fails exactly as it would have.
75pub fn cache_before_seal(r: &SecretRef) -> Result<(), SecretError> {
76    let v = r.resolve_blocking()?;
77    let mut c = preseal_cache().lock().unwrap_or_else(|e| e.into_inner());
78    if !c.iter().any(|(k, _)| k == r) {
79        c.push((r.clone(), v));
80    }
81    Ok(())
82}
83
84/// How many secrets are cached. For tests and diagnostics.
85pub fn preseal_cached_count() -> usize {
86    preseal_cache()
87        .lock()
88        .map(|c| c.len())
89        .unwrap_or_else(|e| e.into_inner().len())
90}
91
92fn preseal_lookup(r: &SecretRef) -> Option<SecretString> {
93    let c = preseal_cache().lock().unwrap_or_else(|e| e.into_inner());
94    c.iter().find(|(k, _)| k == r).map(|(_, v)| v.clone())
95}
96
97impl std::fmt::Display for SecretRef {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            SecretRef::Env(v) => write!(f, "env:{v}"),
101            SecretRef::Keychain { service, account } => {
102                write!(f, "keychain:{service}/{account}")
103            }
104            SecretRef::File(p) => write!(f, "file:{}", p.display()),
105            SecretRef::Cmd(c) => write!(f, "cmd:{c}"),
106        }
107    }
108}
109
110impl std::str::FromStr for SecretRef {
111    type Err = SecretError;
112    fn from_str(s: &str) -> Result<Self, Self::Err> {
113        let (scheme, rest) = s
114            .split_once(':')
115            .ok_or_else(|| SecretError::Parse(format!("missing scheme: {s}")))?;
116        match scheme {
117            "env" => Ok(SecretRef::Env(rest.to_string())),
118            "keychain" => {
119                let (service, account) = rest.split_once('/').ok_or_else(|| {
120                    SecretError::Parse(format!("keychain ref needs service/account: {s}"))
121                })?;
122                Ok(SecretRef::Keychain {
123                    service: service.to_string(),
124                    account: account.to_string(),
125                })
126            }
127            "file" => Ok(SecretRef::File(PathBuf::from(rest))),
128            "cmd" => Ok(SecretRef::Cmd(rest.to_string())),
129            other => Err(SecretError::Parse(format!("unknown scheme: {other}"))),
130        }
131    }
132}
133
134impl Serialize for SecretRef {
135    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
136        s.collect_str(self)
137    }
138}
139
140impl<'de> Deserialize<'de> for SecretRef {
141    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
142        let s = String::deserialize(d)?;
143        s.parse().map_err(serde::de::Error::custom)
144    }
145}
146
147/// Force-block OS keychain access in this process: lookups behave as
148/// "not found", writes are rejected. Exists so processes that must never
149/// trigger a macOS keychain password prompt (test runs, CI) can opt out.
150pub const ENV_KEYCHAIN_DISABLED: &str = "MUR_KEYCHAIN_DISABLED";
151/// Overrides the automatic test-process block below. Set by tests that
152/// install a keyring mock builder (those never reach the real keychain).
153pub const ENV_KEYCHAIN_ALLOW: &str = "MUR_KEYCHAIN_ALLOW";
154
155/// Cargo test binaries get a fresh hash suffix on every rebuild, so macOS
156/// keychain "always allow" ACLs never stick and any test that resolves a
157/// real `keychain:` ref (e.g. via the user's ~/.mur/config.yaml) rains
158/// password prompts on every run. nextest sets `NEXTEST=1` in each test
159/// process — treat that as "no real keychain" unless explicitly re-enabled.
160fn keychain_blocked() -> bool {
161    if std::env::var_os(ENV_KEYCHAIN_ALLOW).is_some() {
162        return false;
163    }
164    std::env::var_os(ENV_KEYCHAIN_DISABLED).is_some() || std::env::var_os("NEXTEST").is_some()
165}
166
167impl SecretRef {
168    pub async fn resolve(&self) -> Result<SecretString, SecretError> {
169        match self {
170            SecretRef::Env(var) => std::env::var(var)
171                .map(SecretString::from)
172                .map_err(|_| SecretError::EnvNotSet(var.clone())),
173            SecretRef::Keychain { service, account } if keychain_blocked() => {
174                Err(SecretError::KeychainNotFound {
175                    service: service.clone(),
176                    account: account.clone(),
177                })
178            }
179            SecretRef::Keychain { service, account } => {
180                let svc = service.clone();
181                let acct = account.clone();
182                let res = tokio::task::spawn_blocking(move || -> Result<String, SecretError> {
183                    let entry = keyring::Entry::new(&svc, &acct)
184                        .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
185                    match entry.get_password() {
186                        Ok(s) => Ok(s),
187                        Err(keyring::Error::NoEntry) => Err(SecretError::KeychainNotFound {
188                            service: svc.clone(),
189                            account: acct.clone(),
190                        }),
191                        Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
192                    }
193                })
194                .await
195                .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?;
196                res.map(SecretString::from)
197            }
198            SecretRef::File(path) => resolve_file(path).await,
199            SecretRef::Cmd(spec) => resolve_cmd(spec).await,
200        }
201    }
202
203    /// Probe whether the secret resolves successfully without surfacing the
204    /// value. Used by GUI/CLI status indicators. Note: for `Cmd` refs this
205    /// actually runs the command, which may have side effects or be slow.
206    pub async fn check(&self) -> bool {
207        self.resolve().await.is_ok()
208    }
209
210    /// Resolve and expose the secret as a plain `String` for callers that must
211    /// hand the raw value to an external API (e.g. an `Authorization: Bearer`
212    /// header). This is the deliberate materialization boundary — keep the
213    /// returned value short-lived and never log or persist it. Returns `None`
214    /// on any resolution failure (missing env var, keychain entry, etc.).
215    pub async fn resolve_to_string(&self) -> Option<String> {
216        use secrecy::ExposeSecret;
217        self.resolve()
218            .await
219            .ok()
220            .map(|s| s.expose_secret().to_string())
221    }
222
223    /// Synchronous resolve for callers outside an async context (CLI
224    /// factories, config loaders). Inside a multi-thread tokio runtime it
225    /// uses block_in_place; inside a current-thread runtime (where
226    /// block_in_place panics) it hops to a fresh thread; otherwise it spins
227    /// a current-thread runtime.
228    /// The pre-seal cached value for this ref, if any — WITHOUT falling back
229    /// to the backend.
230    ///
231    /// For callers that reach a backend directly rather than through
232    /// `resolve_blocking`, so they can honour the cache without changing what
233    /// they do when it misses.
234    pub fn resolve_preseal_cached(&self) -> Option<SecretString> {
235        preseal_lookup(self)
236    }
237
238    pub fn resolve_blocking(&self) -> Result<SecretString, SecretError> {
239        // A value cached before the sandbox sealed wins. Without this, a `file:`
240        // ref inside the denied credential store is unreadable post-seal and the
241        // caller falls through to a per-agent Keychain lookup (#866). See
242        // `cache_before_seal`.
243        if let Some(v) = preseal_lookup(self) {
244            return Ok(v);
245        }
246        fn fresh_runtime_resolve(r: &SecretRef) -> Result<SecretString, SecretError> {
247            tokio::runtime::Builder::new_current_thread()
248                .enable_all()
249                .build()
250                .map_err(|e| SecretError::KeychainBackend(format!("runtime: {e}")))?
251                .block_on(r.resolve())
252        }
253        match tokio::runtime::Handle::try_current() {
254            Ok(h) if h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
255                tokio::task::block_in_place(|| h.block_on(self.resolve()))
256            }
257            // Current-thread runtime (e.g. #[tokio::test]): block_in_place
258            // would panic — resolve on a fresh OS thread instead.
259            Ok(_) => std::thread::scope(|s| {
260                s.spawn(|| fresh_runtime_resolve(self))
261                    .join()
262                    .unwrap_or_else(|_| {
263                        Err(SecretError::KeychainBackend(
264                            "resolver thread panicked".into(),
265                        ))
266                    })
267            }),
268            Err(_) => fresh_runtime_resolve(self),
269        }
270    }
271
272    /// Blocking analogue of `resolve_to_string` — same materialization
273    /// caveats apply.
274    pub fn resolve_to_string_blocking(&self) -> Option<String> {
275        use secrecy::ExposeSecret;
276        self.resolve_blocking()
277            .ok()
278            .map(|s| s.expose_secret().to_string())
279    }
280}
281
282/// Read a secret from the OS keychain.
283///
284/// Returns `Ok(None)` when the entry doesn't exist (so callers can fall
285/// through to the next precedence layer cleanly), and `Err(...)` only for
286/// real backend failures (locked keychain, permission denied, malformed
287/// service/account, transport error). Silently swallowing those errors would
288/// mask configuration problems and let the next fallback layer take over
289/// when the user actually expected the keychain entry to be honored.
290///
291/// Pairs with [`keychain_set`] / [`keychain_delete`].
292pub async fn keychain_get(
293    service: &str,
294    account: &str,
295) -> Result<Option<SecretString>, SecretError> {
296    if keychain_blocked() {
297        return Ok(None);
298    }
299    let svc = service.to_string();
300    let acct = account.to_string();
301    tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
302        let entry = keyring::Entry::new(&svc, &acct)
303            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
304        match entry.get_password() {
305            Ok(s) => Ok(Some(s)),
306            Err(keyring::Error::NoEntry) => Ok(None),
307            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
308        }
309    })
310    .await
311    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
312    .map(|opt| opt.map(SecretString::from))
313}
314
315/// Write a secret to the OS keychain. Used by `mur agent secret set` and the
316/// GUI's `set_secret` command.
317pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
318    if keychain_blocked() {
319        return Err(SecretError::KeychainBackend(format!(
320            "keychain access disabled in this process ({ENV_KEYCHAIN_DISABLED}/test); \
321             set {ENV_KEYCHAIN_ALLOW}=1 to override"
322        )));
323    }
324    let svc = service.to_string();
325    let acct = account.to_string();
326    let val = value.to_string();
327    tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
328        let entry = keyring::Entry::new(&svc, &acct)
329            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
330        entry
331            .set_password(&val)
332            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
333        Ok(())
334    })
335    .await
336    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
337}
338
339/// Delete a secret from the OS keychain. Idempotent: missing entries are not
340/// an error. Used by `mur agent secret delete`.
341pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
342    if keychain_blocked() {
343        return Ok(());
344    }
345    let svc = service.to_string();
346    let acct = account.to_string();
347    tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
348        let entry = keyring::Entry::new(&svc, &acct)
349            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
350        match entry.delete_credential() {
351            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
352            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
353        }
354    })
355    .await
356    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
357}
358
359async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
360    let mut parts = shell_words::split(spec)
361        .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
362    if parts.is_empty() {
363        return Err(SecretError::Parse("empty cmd".into()));
364    }
365    let program = parts.remove(0);
366    let output = tokio::process::Command::new(&program)
367        .args(&parts)
368        .output()
369        .await
370        .map_err(|e| SecretError::Cmd {
371            cmd: format!("{spec} ({e})"),
372            status: -1,
373        })?;
374    if !output.status.success() {
375        return Err(SecretError::Cmd {
376            cmd: spec.to_string(),
377            status: output.status.code().unwrap_or(-1),
378        });
379    }
380    let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
381        cmd: format!("{spec} (non-utf8 stdout: {e})"),
382        status: -2,
383    })?;
384    Ok(SecretString::from(
385        s.trim_end_matches(['\n', '\r']).to_string(),
386    ))
387}
388
389async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
390    let expanded = shellexpand::full(&path.to_string_lossy())
391        .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
392        .to_string();
393    let p = std::path::PathBuf::from(expanded);
394
395    #[cfg(unix)]
396    {
397        use std::os::unix::fs::PermissionsExt;
398        let meta = tokio::fs::metadata(&p)
399            .await
400            .map_err(|e| SecretError::FileRead {
401                path: p.display().to_string(),
402                source: e,
403            })?;
404        let mode = meta.permissions().mode() & 0o777;
405        if mode & 0o077 != 0 {
406            return Err(SecretError::FileMode(format!(
407                "{}: mode {:o} grants group/world access",
408                p.display(),
409                mode
410            )));
411        }
412    }
413
414    let bytes = tokio::fs::read(&p)
415        .await
416        .map_err(|e| SecretError::FileRead {
417            path: p.display().to_string(),
418            source: e,
419        })?;
420
421    let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
422        decrypt_age(&bytes).await?
423    } else {
424        String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
425    };
426    let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
427    Ok(SecretString::from(trimmed))
428}
429
430async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
431    let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
432        Ok(p) => std::path::PathBuf::from(p),
433        Err(_) => dirs::home_dir()
434            .ok_or_else(|| {
435                SecretError::AgeDecrypt(
436                    "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
437                )
438            })?
439            .join(".mur/age/identity.txt"),
440    };
441
442    let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
443        SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
444    })?;
445    let identity: age::x25519::Identity = id_str
446        .trim()
447        .parse()
448        .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
449
450    let decryptor =
451        age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
452    let mut reader = decryptor
453        .decrypt(std::iter::once(&identity as &dyn age::Identity))
454        .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
455    let mut out = String::new();
456    use std::io::Read;
457    reader
458        .read_to_string(&mut out)
459        .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
460    Ok(out)
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use serde_yaml_ng as yaml;
467
468    #[test]
469    fn parses_env_form() {
470        let s: SecretRef = yaml::from_str("env:ANTHROPIC_API_KEY").unwrap();
471        assert_eq!(s, SecretRef::Env("ANTHROPIC_API_KEY".into()));
472    }
473
474    #[test]
475    fn parses_keychain_form() {
476        let s: SecretRef = yaml::from_str("keychain:mur/anthropic-oauth").unwrap();
477        assert_eq!(
478            s,
479            SecretRef::Keychain {
480                service: "mur".into(),
481                account: "anthropic-oauth".into()
482            }
483        );
484    }
485
486    #[test]
487    fn parses_file_form() {
488        let s: SecretRef = yaml::from_str("file:/tmp/foo.age").unwrap();
489        assert_eq!(s, SecretRef::File(PathBuf::from("/tmp/foo.age")));
490    }
491
492    #[test]
493    fn parses_cmd_form() {
494        let s: SecretRef = yaml::from_str("cmd:op read op://vault/item/field").unwrap();
495        assert_eq!(s, SecretRef::Cmd("op read op://vault/item/field".into()));
496    }
497
498    #[test]
499    fn rejects_unknown_scheme() {
500        let r: Result<SecretRef, _> = yaml::from_str("plain:supersecret");
501        assert!(r.is_err());
502    }
503
504    #[test]
505    fn round_trip_serde() {
506        let cases = [
507            "env:X",
508            "keychain:svc/acct",
509            "file:/p",
510            "cmd:bin --flag arg",
511        ];
512        for s in cases {
513            let parsed: SecretRef = yaml::from_str(s).unwrap();
514            let back = yaml::to_string(&parsed).unwrap();
515            // serde-yaml adds a trailing newline / quoting. Strip and compare.
516            let normalized = back
517                .trim()
518                .trim_matches(|c: char| c == '"' || c == '\'')
519                .to_string();
520            let reparsed: SecretRef = yaml::from_str(&normalized).unwrap();
521            assert_eq!(parsed, reparsed, "round-trip drift for {s}");
522        }
523    }
524}
525
526#[cfg(test)]
527mod resolve_env_tests {
528    use super::*;
529    use secrecy::ExposeSecret;
530
531    #[tokio::test]
532    async fn resolves_env_when_set() {
533        // SAFETY: uniquely named env var so concurrent tests don't collide.
534        unsafe {
535            std::env::set_var("MUR_TEST_RESOLVE_ENV", "shhh");
536        }
537        let s = SecretRef::Env("MUR_TEST_RESOLVE_ENV".into());
538        let v = s.resolve().await.unwrap();
539        assert_eq!(v.expose_secret(), "shhh");
540    }
541
542    #[tokio::test]
543    async fn errors_when_env_missing() {
544        let s = SecretRef::Env("MUR_TEST_DEFINITELY_UNSET".into());
545        let err = s.resolve().await.unwrap_err();
546        assert!(matches!(err, SecretError::EnvNotSet(_)), "got {err:?}");
547    }
548
549    #[tokio::test]
550    async fn resolve_to_string_exposes_value_or_none() {
551        // SAFETY: uniquely named env var so concurrent tests don't collide.
552        unsafe {
553            std::env::set_var("MUR_TEST_RESOLVE_TO_STRING", "kc-abc");
554        }
555        let set = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING".into());
556        assert_eq!(set.resolve_to_string().await.as_deref(), Some("kc-abc"));
557
558        let missing = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING_UNSET".into());
559        assert_eq!(missing.resolve_to_string().await, None);
560    }
561}
562
563#[cfg(test)]
564mod keychain_test_fixture {
565    //! Shared mock fixture used by every test module that touches the keyring.
566    //!
567    //! v3's stock `keyring::mock` advertises CredentialPersistence::EntryOnly
568    //! and gives each Entry its own private storage — that breaks our tests
569    //! because resolve() creates a fresh `Entry::new` after setup. The fixture
570    //! below installs a SharedMockBuilder backed by an Arc<Mutex<HashMap>>
571    //! so all Entry instances see the same data.
572    //!
573    //! Tests serialize on a tokio::sync::Mutex (held across await) because
574    //! `set_default_credential_builder` mutates a process-global.
575
576    use keyring::credential::{
577        Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
578    };
579    use std::any::Any;
580    use std::collections::HashMap;
581    use std::sync::{Arc, Mutex};
582    use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};
583
584    type Store = Arc<Mutex<HashMap<(String, String), Vec<u8>>>>;
585
586    struct SharedMockCredential {
587        store: Store,
588        key: (String, String),
589    }
590
591    impl CredentialApi for SharedMockCredential {
592        fn set_secret(&self, password: &[u8]) -> keyring::Result<()> {
593            self.store
594                .lock()
595                .unwrap()
596                .insert(self.key.clone(), password.to_vec());
597            Ok(())
598        }
599        fn get_secret(&self) -> keyring::Result<Vec<u8>> {
600            self.store
601                .lock()
602                .unwrap()
603                .get(&self.key)
604                .cloned()
605                .ok_or(keyring::Error::NoEntry)
606        }
607        fn delete_credential(&self) -> keyring::Result<()> {
608            self.store
609                .lock()
610                .unwrap()
611                .remove(&self.key)
612                .map(|_| ())
613                .ok_or(keyring::Error::NoEntry)
614        }
615        fn as_any(&self) -> &dyn Any {
616            self
617        }
618    }
619
620    struct SharedMockBuilder {
621        store: Store,
622    }
623
624    impl CredentialBuilderApi for SharedMockBuilder {
625        fn build(
626            &self,
627            _target: Option<&str>,
628            service: &str,
629            user: &str,
630        ) -> keyring::Result<Box<Credential>> {
631            Ok(Box::new(SharedMockCredential {
632                store: self.store.clone(),
633                key: (service.to_string(), user.to_string()),
634            }))
635        }
636        fn as_any(&self) -> &dyn Any {
637            self
638        }
639        fn persistence(&self) -> CredentialPersistence {
640            CredentialPersistence::ProcessOnly
641        }
642    }
643
644    static MOCK_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
645
646    /// Serialize env-var mutation with the mock installs above (both are
647    /// process-global). Used by tests that exercise `keychain_blocked`.
648    pub(super) async fn env_lock() -> AsyncMutexGuard<'static, ()> {
649        MOCK_LOCK.lock().await
650    }
651
652    pub(super) async fn install_mock(
653        initial: Option<(&str, &str, &str)>,
654    ) -> AsyncMutexGuard<'static, ()> {
655        let g = MOCK_LOCK.lock().await;
656        // The mock never reaches the real OS keychain, so lift the automatic
657        // test-process keychain block (`keychain_blocked`).
658        // SAFETY: env mutation serialized by MOCK_LOCK; nextest runs one test
659        // per process anyway.
660        unsafe {
661            std::env::set_var(super::ENV_KEYCHAIN_ALLOW, "1");
662        }
663        let store: Store = Arc::new(Mutex::new(HashMap::new()));
664        if let Some((svc, user, pw)) = initial {
665            store
666                .lock()
667                .unwrap()
668                .insert((svc.to_string(), user.to_string()), pw.as_bytes().to_vec());
669        }
670        let builder: Box<CredentialBuilder> = Box::new(SharedMockBuilder { store });
671        keyring::set_default_credential_builder(builder);
672        g
673    }
674}
675
676#[cfg(test)]
677mod resolve_keychain_tests {
678    use super::keychain_test_fixture::install_mock;
679    use super::*;
680    use secrecy::ExposeSecret;
681
682    #[tokio::test]
683    async fn blocked_process_never_reaches_keychain() {
684        let _g = super::keychain_test_fixture::env_lock().await;
685        // SAFETY: env mutation serialized on the fixture lock; nextest is
686        // process-per-test anyway.
687        unsafe {
688            std::env::remove_var(ENV_KEYCHAIN_ALLOW);
689            std::env::set_var(ENV_KEYCHAIN_DISABLED, "1");
690        }
691        let s = SecretRef::Keychain {
692            service: "mur-test".into(),
693            account: "nope".into(),
694        };
695        assert!(matches!(
696            s.resolve().await,
697            Err(SecretError::KeychainNotFound { .. })
698        ));
699        assert!(keychain_get("mur-test", "nope").await.unwrap().is_none());
700        assert!(keychain_set("mur-test", "nope", "v").await.is_err());
701        assert!(keychain_delete("mur-test", "nope").await.is_ok());
702        unsafe {
703            std::env::remove_var(ENV_KEYCHAIN_DISABLED);
704        }
705    }
706
707    #[tokio::test]
708    async fn resolves_when_set() {
709        let _g = install_mock(Some(("mur-test", "kc-acct", "kc-secret"))).await;
710        let s = SecretRef::Keychain {
711            service: "mur-test".into(),
712            account: "kc-acct".into(),
713        };
714        let v = s.resolve().await.unwrap();
715        assert_eq!(v.expose_secret(), "kc-secret");
716    }
717
718    #[tokio::test]
719    async fn errors_when_missing() {
720        let _g = install_mock(None).await;
721        let s = SecretRef::Keychain {
722            service: "mur-test".into(),
723            account: "kc-acct".into(),
724        };
725        let err = s.resolve().await.unwrap_err();
726        assert!(
727            matches!(err, SecretError::KeychainNotFound { .. }),
728            "got {err:?}"
729        );
730    }
731}
732
733#[cfg(all(test, unix))]
734mod resolve_file_tests {
735    use super::*;
736    use secrecy::ExposeSecret;
737    use std::os::unix::fs::PermissionsExt;
738    use tempfile::tempdir;
739
740    #[tokio::test]
741    async fn reads_plaintext_0600() {
742        let dir = tempdir().unwrap();
743        let p = dir.path().join("k.txt");
744        std::fs::write(&p, "abc\n").unwrap();
745        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
746        let s = SecretRef::File(p);
747        let v = s.resolve().await.unwrap();
748        assert_eq!(v.expose_secret(), "abc"); // trailing newline stripped
749    }
750
751    #[tokio::test]
752    async fn rejects_world_readable() {
753        let dir = tempdir().unwrap();
754        let p = dir.path().join("k.txt");
755        std::fs::write(&p, "abc").unwrap();
756        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
757        let s = SecretRef::File(p);
758        let err = s.resolve().await.unwrap_err();
759        assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
760    }
761
762    #[tokio::test]
763    async fn decrypts_age_recipient_file() {
764        let dir = tempdir().unwrap();
765        let identity = age::x25519::Identity::generate();
766        let recipient = identity.to_public();
767        let payload = b"shh-from-age";
768
769        let mut encrypted: Vec<u8> = Vec::new();
770        let encryptor =
771            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
772                .unwrap();
773        let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
774        std::io::Write::write_all(&mut writer, payload).unwrap();
775        writer.finish().unwrap();
776
777        let enc_path = dir.path().join("k.age");
778        std::fs::write(&enc_path, &encrypted).unwrap();
779        std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
780        let id_path = dir.path().join("identity.txt");
781        use secrecy::ExposeSecret as _;
782        std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
783        std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
784        // SAFETY: setting an env var read by decrypt_age. Tests serialize on
785        // the same env var, so concurrent writes would race; we serialize via
786        // a Mutex-held guard.
787        unsafe {
788            std::env::set_var("MUR_AGE_IDENTITY_PATH", &id_path);
789        }
790        let s = SecretRef::File(enc_path);
791        let v = s.resolve().await.unwrap();
792        assert_eq!(v.expose_secret(), "shh-from-age");
793        unsafe {
794            std::env::remove_var("MUR_AGE_IDENTITY_PATH");
795        }
796    }
797}
798
799#[cfg(all(test, unix))]
800mod resolve_cmd_tests {
801    use super::*;
802    use secrecy::ExposeSecret;
803
804    #[tokio::test]
805    async fn echoes_stdout() {
806        let s = SecretRef::Cmd("printf shh-from-cmd".into());
807        let v = s.resolve().await.unwrap();
808        assert_eq!(v.expose_secret(), "shh-from-cmd");
809    }
810
811    #[tokio::test]
812    async fn errors_on_non_zero_exit() {
813        let s = SecretRef::Cmd("sh -c 'exit 7'".into());
814        let err = s.resolve().await.unwrap_err();
815        match err {
816            SecretError::Cmd { status, .. } => assert_eq!(status, 7),
817            other => panic!("unexpected: {other:?}"),
818        }
819    }
820}
821
822#[cfg(test)]
823mod check_tests {
824    use super::*;
825
826    #[tokio::test]
827    async fn check_env_present() {
828        // SAFETY: uniquely named env var so concurrent tests don't collide.
829        unsafe {
830            std::env::set_var("MUR_TEST_CHECK_ENV", "1");
831        }
832        assert!(SecretRef::Env("MUR_TEST_CHECK_ENV".into()).check().await);
833    }
834
835    #[tokio::test]
836    async fn check_env_absent() {
837        assert!(
838            !SecretRef::Env("MUR_TEST_CHECK_DEFINITELY_UNSET".into())
839                .check()
840                .await
841        );
842    }
843}
844
845#[cfg(test)]
846mod keychain_helpers_tests {
847    use super::keychain_test_fixture::install_mock;
848    use super::*;
849    use secrecy::ExposeSecret;
850
851    #[tokio::test]
852    async fn set_then_resolve_round_trips() {
853        let _g = install_mock(None).await;
854        keychain_set("mur-test", "round-trip", "v1").await.unwrap();
855        let v = SecretRef::Keychain {
856            service: "mur-test".into(),
857            account: "round-trip".into(),
858        }
859        .resolve()
860        .await
861        .unwrap();
862        assert_eq!(v.expose_secret(), "v1");
863    }
864
865    #[tokio::test]
866    async fn delete_works() {
867        let _g = install_mock(None).await;
868        keychain_set("mur-test", "to-delete", "v").await.unwrap();
869        keychain_delete("mur-test", "to-delete").await.unwrap();
870        let r = SecretRef::Keychain {
871            service: "mur-test".into(),
872            account: "to-delete".into(),
873        }
874        .resolve()
875        .await;
876        assert!(matches!(r, Err(SecretError::KeychainNotFound { .. })));
877    }
878
879    #[tokio::test]
880    async fn delete_missing_is_idempotent() {
881        let _g = install_mock(None).await;
882        // No prior set — must still return Ok.
883        keychain_delete("mur-test", "never-set").await.unwrap();
884    }
885}
886
887#[cfg(test)]
888mod resolve_blocking_tests {
889    /// A secret cached before the seal resolves afterwards even when the path
890    /// has become unreachable — which is what a sandboxed agent faces for a
891    /// `file:` ref inside the denied credential store (#866).
892    ///
893    /// Simulated by deleting the file after caching: post-seal the path is gone
894    /// as far as the process is concerned, exactly as a deny makes it.
895    #[test]
896    fn a_cached_secret_survives_its_path_becoming_unreachable() {
897        let dir = tempfile::tempdir().unwrap();
898        let path = dir.path().join("provider.key");
899        std::fs::write(&path, "sk-test-value").unwrap();
900        // `file:` refs require 0600 — group/world access is refused outright.
901        #[cfg(unix)]
902        {
903            use std::os::unix::fs::PermissionsExt;
904            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
905        }
906        let r = SecretRef::File(path.clone());
907
908        cache_before_seal(&r).expect("resolves while the path is reachable");
909        std::fs::remove_file(&path).unwrap();
910
911        use secrecy::ExposeSecret;
912        let got = r
913            .resolve_blocking()
914            .expect("cached value must still resolve");
915        assert_eq!(got.expose_secret(), "sk-test-value");
916    }
917
918    /// Negative control for the above: WITHOUT caching, the same ref fails once
919    /// the path is unreachable. That is the pre-fix behaviour, and it is what
920    /// made the caller fall through to a Keychain lookup.
921    #[test]
922    fn an_uncached_secret_fails_when_its_path_is_unreachable() {
923        let dir = tempfile::tempdir().unwrap();
924        let path = dir.path().join("uncached.key");
925        std::fs::write(&path, "sk-other-value").unwrap();
926        let r = SecretRef::File(path.clone());
927        std::fs::remove_file(&path).unwrap();
928
929        assert!(
930            r.resolve_blocking().is_err(),
931            "an uncached ref must not resolve once its path is gone"
932        );
933    }
934
935    /// `resolve_preseal_cached` returns the cached value and NEVER reaches a
936    /// backend — the property the per-agent Keychain path depends on.
937    ///
938    /// That path (`from_agent_credentials`) calls `keychain_get` directly
939    /// rather than going through `resolve_blocking`, so the first cut of the
940    /// pre-seal fix did not cover it at all: an agent whose model entry has no
941    /// `secret:` of its own still broke on every upgrade.
942    #[test]
943    fn preseal_cached_lookup_does_not_touch_the_backend() {
944        let r = SecretRef::Keychain {
945            service: "mur-agent-test-nonexistent".into(),
946            account: "no-such-agent/NO_SUCH_KEY".into(),
947        };
948        // Never cached, and the backend has no such item: a miss, not a hang
949        // and not an error.
950        assert!(r.resolve_preseal_cached().is_none());
951
952        let dir = tempfile::tempdir().unwrap();
953        let path = dir.path().join("agent.key");
954        std::fs::write(&path, "sk-agent").unwrap();
955        #[cfg(unix)]
956        {
957            use std::os::unix::fs::PermissionsExt;
958            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
959        }
960        let f = SecretRef::File(path.clone());
961        cache_before_seal(&f).unwrap();
962        std::fs::remove_file(&path).unwrap();
963
964        use secrecy::ExposeSecret;
965        let got = f.resolve_preseal_cached().expect("cached hit");
966        assert_eq!(got.expose_secret(), "sk-agent");
967    }
968
969    /// Caching is idempotent and does not grow on repeat calls.
970    #[test]
971    fn caching_the_same_ref_twice_stores_one_entry() {
972        let dir = tempfile::tempdir().unwrap();
973        let path = dir.path().join("dup.key");
974        std::fs::write(&path, "v").unwrap();
975        #[cfg(unix)]
976        {
977            use std::os::unix::fs::PermissionsExt;
978            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
979        }
980        let r = SecretRef::File(path);
981
982        let before = preseal_cached_count();
983        cache_before_seal(&r).unwrap();
984        cache_before_seal(&r).unwrap();
985        assert_eq!(preseal_cached_count(), before + 1);
986    }
987
988    use super::*;
989
990    #[test]
991    fn resolve_blocking_env_and_missing() {
992        unsafe { std::env::set_var("MUR_TEST_SECRET_BLOCKING", "s3cret") };
993        let r: SecretRef = "env:MUR_TEST_SECRET_BLOCKING".parse().unwrap();
994        assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
995        unsafe { std::env::remove_var("MUR_TEST_SECRET_BLOCKING") };
996        assert!(r.resolve_blocking().is_err());
997    }
998
999    /// `#[tokio::test]` runs on a current-thread runtime, where
1000    /// `block_in_place` panics. `resolve_blocking` must detect the flavor and
1001    /// hop to a fresh thread instead (the crash behind the flaky rollup
1002    /// tests on machines whose config carries secret refs).
1003    #[tokio::test]
1004    async fn resolve_blocking_inside_current_thread_runtime_does_not_panic() {
1005        unsafe { std::env::set_var("MUR_TEST_SECRET_CT_RT", "s3cret") };
1006        let r: SecretRef = "env:MUR_TEST_SECRET_CT_RT".parse().unwrap();
1007        assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
1008        unsafe { std::env::remove_var("MUR_TEST_SECRET_CT_RT") };
1009    }
1010}