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        let _env = crate::test_env::EnvGuard::set([("MUR_TEST_RESOLVE_ENV", "shhh")]);
598        let s = SecretRef::Env("MUR_TEST_RESOLVE_ENV".into());
599        let v = s.resolve().await.unwrap();
600        assert_eq!(v.expose_secret(), "shhh");
601    }
602
603    #[tokio::test]
604    async fn errors_when_env_missing() {
605        let s = SecretRef::Env("MUR_TEST_DEFINITELY_UNSET".into());
606        let err = s.resolve().await.unwrap_err();
607        assert!(matches!(err, SecretError::EnvNotSet(_)), "got {err:?}");
608    }
609
610    #[tokio::test]
611    async fn resolve_to_string_exposes_value_or_none() {
612        let _env = crate::test_env::EnvGuard::set([("MUR_TEST_RESOLVE_TO_STRING", "kc-abc")]);
613        let set = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING".into());
614        assert_eq!(set.resolve_to_string().await.as_deref(), Some("kc-abc"));
615
616        let missing = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING_UNSET".into());
617        assert_eq!(missing.resolve_to_string().await, None);
618    }
619}
620
621#[cfg(test)]
622mod keychain_test_fixture {
623    //! Shared mock fixture used by every test module that touches the keyring.
624    //!
625    //! v3's stock `keyring::mock` advertises CredentialPersistence::EntryOnly
626    //! and gives each Entry its own private storage — that breaks our tests
627    //! because resolve() creates a fresh `Entry::new` after setup. The fixture
628    //! below installs a SharedMockBuilder backed by an Arc<Mutex<HashMap>>
629    //! so all Entry instances see the same data.
630    //!
631    //! Tests serialize on a tokio::sync::Mutex (held across await) because
632    //! `set_default_credential_builder` mutates a process-global.
633
634    use keyring::credential::{
635        Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
636    };
637    use std::any::Any;
638    use std::collections::HashMap;
639    use std::sync::{Arc, Mutex};
640    use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};
641
642    type Store = Arc<Mutex<HashMap<(String, String), Vec<u8>>>>;
643
644    struct SharedMockCredential {
645        store: Store,
646        key: (String, String),
647    }
648
649    impl CredentialApi for SharedMockCredential {
650        fn set_secret(&self, password: &[u8]) -> keyring::Result<()> {
651            self.store
652                .lock()
653                .unwrap()
654                .insert(self.key.clone(), password.to_vec());
655            Ok(())
656        }
657        fn get_secret(&self) -> keyring::Result<Vec<u8>> {
658            self.store
659                .lock()
660                .unwrap()
661                .get(&self.key)
662                .cloned()
663                .ok_or(keyring::Error::NoEntry)
664        }
665        fn delete_credential(&self) -> keyring::Result<()> {
666            self.store
667                .lock()
668                .unwrap()
669                .remove(&self.key)
670                .map(|_| ())
671                .ok_or(keyring::Error::NoEntry)
672        }
673        fn as_any(&self) -> &dyn Any {
674            self
675        }
676    }
677
678    struct SharedMockBuilder {
679        store: Store,
680    }
681
682    impl CredentialBuilderApi for SharedMockBuilder {
683        fn build(
684            &self,
685            _target: Option<&str>,
686            service: &str,
687            user: &str,
688        ) -> keyring::Result<Box<Credential>> {
689            Ok(Box::new(SharedMockCredential {
690                store: self.store.clone(),
691                key: (service.to_string(), user.to_string()),
692            }))
693        }
694        fn as_any(&self) -> &dyn Any {
695            self
696        }
697        fn persistence(&self) -> CredentialPersistence {
698            CredentialPersistence::ProcessOnly
699        }
700    }
701
702    static MOCK_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
703
704    /// Serialize env-var mutation with the mock installs above (both are
705    /// process-global). Used by tests that exercise `keychain_blocked`.
706    pub(super) async fn env_lock() -> AsyncMutexGuard<'static, ()> {
707        MOCK_LOCK.lock().await
708    }
709
710    /// Returns the lock guard AND the env guard. Both must outlive the test
711    /// body: `ENV_KEYCHAIN_ALLOW` is what lifts `keychain_blocked`, and under
712    /// nextest (`NEXTEST` is always set) the block is on by default, so a
713    /// guard dropped when this function returns leaves the mock unreachable
714    /// and every lookup fails with `KeychainNotFound`. The pre-`EnvGuard`
715    /// code set the var permanently, which is why the lifetime mattered only
716    /// once it became RAII.
717    pub(super) async fn install_mock(
718        initial: Option<(&str, &str, &str)>,
719    ) -> (AsyncMutexGuard<'static, ()>, crate::test_env::EnvGuard) {
720        let g = MOCK_LOCK.lock().await;
721        // The mock never reaches the real OS keychain, so lift the automatic
722        // test-process keychain block (`keychain_blocked`).
723        let env = crate::test_env::EnvGuard::set([(super::ENV_KEYCHAIN_ALLOW, "1")]);
724        let store: Store = Arc::new(Mutex::new(HashMap::new()));
725        if let Some((svc, user, pw)) = initial {
726            store
727                .lock()
728                .unwrap()
729                .insert((svc.to_string(), user.to_string()), pw.as_bytes().to_vec());
730        }
731        let builder: Box<CredentialBuilder> = Box::new(SharedMockBuilder { store });
732        keyring::set_default_credential_builder(builder);
733        (g, env)
734    }
735}
736
737#[cfg(test)]
738mod resolve_keychain_tests {
739    use super::keychain_test_fixture::install_mock;
740    use super::*;
741    use secrecy::ExposeSecret;
742
743    #[tokio::test]
744    async fn blocked_process_never_reaches_keychain() {
745        let _g = super::keychain_test_fixture::env_lock().await;
746        let mut _env = crate::test_env::EnvGuard::unset([ENV_KEYCHAIN_ALLOW]);
747        _env.set_var(ENV_KEYCHAIN_DISABLED, "1");
748        let s = SecretRef::Keychain {
749            service: "mur-test".into(),
750            account: "nope".into(),
751        };
752        assert!(matches!(
753            s.resolve().await,
754            Err(SecretError::KeychainNotFound { .. })
755        ));
756        assert!(keychain_get("mur-test", "nope").await.unwrap().is_none());
757        assert!(keychain_set("mur-test", "nope", "v").await.is_err());
758        assert!(keychain_delete("mur-test", "nope").await.is_ok());
759    }
760
761    #[tokio::test]
762    async fn resolves_when_set() {
763        let _g = install_mock(Some(("mur-test", "kc-acct", "kc-secret"))).await;
764        let s = SecretRef::Keychain {
765            service: "mur-test".into(),
766            account: "kc-acct".into(),
767        };
768        let v = s.resolve().await.unwrap();
769        assert_eq!(v.expose_secret(), "kc-secret");
770    }
771
772    #[tokio::test]
773    async fn errors_when_missing() {
774        let _g = install_mock(None).await;
775        let s = SecretRef::Keychain {
776            service: "mur-test".into(),
777            account: "kc-acct".into(),
778        };
779        let err = s.resolve().await.unwrap_err();
780        assert!(
781            matches!(err, SecretError::KeychainNotFound { .. }),
782            "got {err:?}"
783        );
784    }
785}
786
787#[cfg(all(test, unix))]
788mod resolve_file_tests {
789    use super::*;
790    use secrecy::ExposeSecret;
791    use std::os::unix::fs::PermissionsExt;
792    use tempfile::tempdir;
793
794    #[tokio::test]
795    async fn reads_plaintext_0600() {
796        let dir = tempdir().unwrap();
797        let p = dir.path().join("k.txt");
798        std::fs::write(&p, "abc\n").unwrap();
799        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
800        let s = SecretRef::File(p);
801        let v = s.resolve().await.unwrap();
802        assert_eq!(v.expose_secret(), "abc"); // trailing newline stripped
803    }
804
805    #[tokio::test]
806    async fn rejects_world_readable() {
807        let dir = tempdir().unwrap();
808        let p = dir.path().join("k.txt");
809        std::fs::write(&p, "abc").unwrap();
810        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
811        let s = SecretRef::File(p);
812        let err = s.resolve().await.unwrap_err();
813        assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
814    }
815
816    #[tokio::test]
817    async fn decrypts_age_recipient_file() {
818        let dir = tempdir().unwrap();
819        let identity = age::x25519::Identity::generate();
820        let recipient = identity.to_public();
821        let payload = b"shh-from-age";
822
823        let mut encrypted: Vec<u8> = Vec::new();
824        let encryptor =
825            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
826                .unwrap();
827        let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
828        std::io::Write::write_all(&mut writer, payload).unwrap();
829        writer.finish().unwrap();
830
831        let enc_path = dir.path().join("k.age");
832        std::fs::write(&enc_path, &encrypted).unwrap();
833        std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
834        let id_path = dir.path().join("identity.txt");
835        use secrecy::ExposeSecret as _;
836        std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
837        std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
838        let _env = crate::test_env::EnvGuard::set([("MUR_AGE_IDENTITY_PATH", &id_path)]);
839        let s = SecretRef::File(enc_path);
840        let v = s.resolve().await.unwrap();
841        assert_eq!(v.expose_secret(), "shh-from-age");
842    }
843}
844
845#[cfg(all(test, unix))]
846mod resolve_cmd_tests {
847    use super::*;
848    use secrecy::ExposeSecret;
849
850    #[tokio::test]
851    async fn echoes_stdout() {
852        let s = SecretRef::Cmd("printf shh-from-cmd".into());
853        let v = s.resolve().await.unwrap();
854        assert_eq!(v.expose_secret(), "shh-from-cmd");
855    }
856
857    #[tokio::test]
858    async fn errors_on_non_zero_exit() {
859        let s = SecretRef::Cmd("sh -c 'exit 7'".into());
860        let err = s.resolve().await.unwrap_err();
861        match err {
862            SecretError::Cmd { status, .. } => assert_eq!(status, 7),
863            other => panic!("unexpected: {other:?}"),
864        }
865    }
866}
867
868#[cfg(test)]
869mod check_tests {
870    use super::*;
871
872    #[tokio::test]
873    async fn check_env_present() {
874        let _env = crate::test_env::EnvGuard::set([("MUR_TEST_CHECK_ENV", "1")]);
875        assert!(SecretRef::Env("MUR_TEST_CHECK_ENV".into()).check().await);
876    }
877
878    #[tokio::test]
879    async fn check_env_absent() {
880        assert!(
881            !SecretRef::Env("MUR_TEST_CHECK_DEFINITELY_UNSET".into())
882                .check()
883                .await
884        );
885    }
886}
887
888#[cfg(test)]
889mod keychain_helpers_tests {
890    use super::keychain_test_fixture::install_mock;
891    use super::*;
892    use secrecy::ExposeSecret;
893
894    #[tokio::test]
895    async fn set_then_resolve_round_trips() {
896        let _g = install_mock(None).await;
897        keychain_set("mur-test", "round-trip", "v1").await.unwrap();
898        let v = SecretRef::Keychain {
899            service: "mur-test".into(),
900            account: "round-trip".into(),
901        }
902        .resolve()
903        .await
904        .unwrap();
905        assert_eq!(v.expose_secret(), "v1");
906    }
907
908    #[tokio::test]
909    async fn delete_works() {
910        let _g = install_mock(None).await;
911        keychain_set("mur-test", "to-delete", "v").await.unwrap();
912        keychain_delete("mur-test", "to-delete").await.unwrap();
913        let r = SecretRef::Keychain {
914            service: "mur-test".into(),
915            account: "to-delete".into(),
916        }
917        .resolve()
918        .await;
919        assert!(matches!(r, Err(SecretError::KeychainNotFound { .. })));
920    }
921
922    #[tokio::test]
923    async fn delete_missing_is_idempotent() {
924        let _g = install_mock(None).await;
925        // No prior set — must still return Ok.
926        keychain_delete("mur-test", "never-set").await.unwrap();
927    }
928}
929
930#[cfg(test)]
931mod resolve_blocking_tests {
932    /// Serialises the tests that touch [`PRESEAL_CACHE`].
933    ///
934    /// That cache is a process-global. `cargo test` runs every test in this
935    /// module in ONE process on a thread pool, so two tests inserting
936    /// concurrently corrupt each other's view of the entry count — the delta
937    /// one test measures includes rows another test just added. `cargo
938    /// nextest` gives each test its own process, which hides the problem
939    /// rather than removing it, and nextest is this repo's canonical runner.
940    ///
941    /// Poison is deliberately ignored: a panic in one guarded test must fail
942    /// that test alone, not cascade into every other one.
943    static PRESEAL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
944
945    /// A secret cached before the seal resolves afterwards even when the path
946    /// has become unreachable — which is what a sandboxed agent faces for a
947    /// `file:` ref inside the denied credential store (#866).
948    ///
949    /// Simulated by deleting the file after caching: post-seal the path is gone
950    /// as far as the process is concerned, exactly as a deny makes it.
951    #[test]
952    fn a_cached_secret_survives_its_path_becoming_unreachable() {
953        let _preseal_guard = PRESEAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
954        let dir = tempfile::tempdir().unwrap();
955        let path = dir.path().join("provider.key");
956        std::fs::write(&path, "sk-test-value").unwrap();
957        // `file:` refs require 0600 — group/world access is refused outright.
958        #[cfg(unix)]
959        {
960            use std::os::unix::fs::PermissionsExt;
961            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
962        }
963        let r = SecretRef::File(path.clone());
964
965        cache_before_seal(&r).expect("resolves while the path is reachable");
966        std::fs::remove_file(&path).unwrap();
967
968        use secrecy::ExposeSecret;
969        let got = r
970            .resolve_blocking()
971            .expect("cached value must still resolve");
972        assert_eq!(got.expose_secret(), "sk-test-value");
973    }
974
975    /// Negative control for the above: WITHOUT caching, the same ref fails once
976    /// the path is unreachable. That is the pre-fix behaviour, and it is what
977    /// made the caller fall through to a Keychain lookup.
978    #[test]
979    fn an_uncached_secret_fails_when_its_path_is_unreachable() {
980        let dir = tempfile::tempdir().unwrap();
981        let path = dir.path().join("uncached.key");
982        std::fs::write(&path, "sk-other-value").unwrap();
983        let r = SecretRef::File(path.clone());
984        std::fs::remove_file(&path).unwrap();
985
986        assert!(
987            r.resolve_blocking().is_err(),
988            "an uncached ref must not resolve once its path is gone"
989        );
990    }
991
992    /// `resolve_preseal_cached` returns the cached value and NEVER reaches a
993    /// backend — the property the per-agent Keychain path depends on.
994    ///
995    /// That path (`from_agent_credentials`) calls `keychain_get` directly
996    /// rather than going through `resolve_blocking`, so the first cut of the
997    /// pre-seal fix did not cover it at all: an agent whose model entry has no
998    /// `secret:` of its own still broke on every upgrade.
999    #[test]
1000    fn preseal_cached_lookup_does_not_touch_the_backend() {
1001        let _preseal_guard = PRESEAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1002        let r = SecretRef::Keychain {
1003            service: "mur-agent-test-nonexistent".into(),
1004            account: "no-such-agent/NO_SUCH_KEY".into(),
1005        };
1006        // Never cached, and the backend has no such item: a miss, not a hang
1007        // and not an error.
1008        assert!(r.resolve_preseal_cached().is_none());
1009
1010        let dir = tempfile::tempdir().unwrap();
1011        let path = dir.path().join("agent.key");
1012        std::fs::write(&path, "sk-agent").unwrap();
1013        #[cfg(unix)]
1014        {
1015            use std::os::unix::fs::PermissionsExt;
1016            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1017        }
1018        let f = SecretRef::File(path.clone());
1019        cache_before_seal(&f).unwrap();
1020        std::fs::remove_file(&path).unwrap();
1021
1022        use secrecy::ExposeSecret;
1023        let got = f.resolve_preseal_cached().expect("cached hit");
1024        assert_eq!(got.expose_secret(), "sk-agent");
1025    }
1026
1027    /// Caching is idempotent and does not grow on repeat calls.
1028    #[test]
1029    fn caching_the_same_ref_twice_stores_one_entry() {
1030        let _preseal_guard = PRESEAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1031        let dir = tempfile::tempdir().unwrap();
1032        let path = dir.path().join("dup.key");
1033        std::fs::write(&path, "v").unwrap();
1034        #[cfg(unix)]
1035        {
1036            use std::os::unix::fs::PermissionsExt;
1037            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1038        }
1039        let r = SecretRef::File(path);
1040
1041        let before = preseal_cached_count();
1042        cache_before_seal(&r).unwrap();
1043        cache_before_seal(&r).unwrap();
1044        assert_eq!(preseal_cached_count(), before + 1);
1045    }
1046
1047    use super::*;
1048
1049    #[test]
1050    fn resolve_blocking_env_and_missing() {
1051        let mut env = crate::test_env::EnvGuard::set([("MUR_TEST_SECRET_BLOCKING", "s3cret")]);
1052        let r: SecretRef = "env:MUR_TEST_SECRET_BLOCKING".parse().unwrap();
1053        assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
1054        // Load-bearing, not cleanup: the `_and_missing` half of this test is
1055        // that the same ref fails once the variable is gone.
1056        env.unset_var("MUR_TEST_SECRET_BLOCKING");
1057        assert!(r.resolve_blocking().is_err());
1058    }
1059
1060    /// `#[tokio::test]` runs on a current-thread runtime, where
1061    /// `block_in_place` panics. `resolve_blocking` must detect the flavor and
1062    /// hop to a fresh thread instead (the crash behind the flaky rollup
1063    /// tests on machines whose config carries secret refs).
1064    #[tokio::test]
1065    async fn resolve_blocking_inside_current_thread_runtime_does_not_panic() {
1066        let _env = crate::test_env::EnvGuard::set([("MUR_TEST_SECRET_CT_RT", "s3cret")]);
1067        let r: SecretRef = "env:MUR_TEST_SECRET_CT_RT".parse().unwrap();
1068        assert_eq!(r.resolve_to_string_blocking().as_deref(), Some("s3cret"));
1069    }
1070}