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