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
15pub mod keychain_pending;
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub enum SecretRef {
19    Env(String),
20    Keychain { service: String, account: String },
21    File(PathBuf),
22    Cmd(String),
23}
24
25#[derive(thiserror::Error, Debug)]
26pub enum SecretError {
27    #[error("env var {0} not set")]
28    EnvNotSet(String),
29    #[error("keychain item not found: {service}/{account}")]
30    KeychainNotFound { service: String, account: String },
31    #[error("keychain backend error: {0}")]
32    KeychainBackend(String),
33    #[error("read file {path}: {source}")]
34    FileRead {
35        path: String,
36        #[source]
37        source: std::io::Error,
38    },
39    #[error("file mode is not 0600: {0}")]
40    FileMode(String),
41    #[error("decrypt {0}")]
42    AgeDecrypt(String),
43    #[error("cmd {cmd} exited with {status}")]
44    Cmd { cmd: String, status: i32 },
45    #[error("invalid SecretRef syntax: {0}")]
46    Parse(String),
47}
48
49/// Values resolved BEFORE a sandbox seals, for reading after it has.
50///
51/// The problem this exists for: an agent's provider secret is resolved when the
52/// LLM client is built (`supervisor.rs:384`), which is AFTER
53/// `sandbox::apply` (`:314`). A `file:` ref pointing inside `~/.mur/secrets/`
54/// is therefore unreadable — that directory is a denied credential path — and
55/// the caller silently falls back to a per-agent Keychain lookup, which is the
56/// #866 failure. Both paths were broken at once on a real install, each hiding
57/// the other.
58///
59/// The identity key already solves this by ordering: loaded at
60/// `supervisor.rs:174`, before the seal. This gives secrets the same treatment.
61/// The agent ends up holding the VALUE and never the path, so the `secrets/`
62/// deny is not weakened at all — it stays a directory no agent may open.
63///
64/// Deliberately a value cache and not a path cache: nothing here lets a
65/// post-seal caller learn where a secret came from, only what it was.
66static PRESEAL_CACHE: std::sync::OnceLock<std::sync::Mutex<Vec<(SecretRef, SecretString)>>> =
67    std::sync::OnceLock::new();
68
69fn preseal_cache() -> &'static std::sync::Mutex<Vec<(SecretRef, SecretString)>> {
70    PRESEAL_CACHE.get_or_init(|| std::sync::Mutex::new(Vec::new()))
71}
72
73/// Resolve `r` now and remember it, so a later `resolve_blocking` succeeds even
74/// once the path is unreachable. Call before sealing. Errors are the caller's
75/// to report — a secret that cannot be resolved pre-seal is not cached, and the
76/// later lookup fails exactly as it would have.
77pub fn cache_before_seal(r: &SecretRef) -> Result<(), SecretError> {
78    let v = r.resolve_blocking()?;
79    let mut c = preseal_cache().lock().unwrap_or_else(|e| e.into_inner());
80    if !c.iter().any(|(k, _)| k == r) {
81        c.push((r.clone(), v));
82    }
83    Ok(())
84}
85
86/// How many secrets are cached. For tests and diagnostics.
87pub fn preseal_cached_count() -> usize {
88    preseal_cache()
89        .lock()
90        .map(|c| c.len())
91        .unwrap_or_else(|e| e.into_inner().len())
92}
93
94fn preseal_lookup(r: &SecretRef) -> Option<SecretString> {
95    let c = preseal_cache().lock().unwrap_or_else(|e| e.into_inner());
96    c.iter().find(|(k, _)| k == r).map(|(_, v)| v.clone())
97}
98
99/// A form of this reference that is safe to put in front of a user, a log, or
100/// a model.
101///
102/// [`Display`](std::fmt::Display) prints the reference verbatim, which is right
103/// where the reader is the operator looking at their own config. It is wrong
104/// for `Cmd`: the whole command line is printed, and a command line is exactly
105/// where an inline credential lives (`cmd:vault read --token=…`).
106///
107/// Redaction by pattern does not cover this — `redact_secrets` matches known
108/// key shapes (`sk-`, `AKIA`, `ghp_`, JWT, PEM) and an arbitrary `--token=`
109/// argument is none of them. So the arguments are dropped structurally rather
110/// than filtered: the program name is what identifies the credential, and the
111/// arguments are only where the danger is.
112impl SecretRef {
113    pub fn label(&self) -> String {
114        match self {
115            SecretRef::Cmd(c) => {
116                let program = c.split_whitespace().next().unwrap_or("");
117                if c.split_whitespace().nth(1).is_some() {
118                    format!("cmd:{program} (arguments hidden)")
119                } else {
120                    format!("cmd:{program}")
121                }
122            }
123            other => other.to_string(),
124        }
125    }
126}
127
128impl std::fmt::Display for SecretRef {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        match self {
131            SecretRef::Env(v) => write!(f, "env:{v}"),
132            SecretRef::Keychain { service, account } => {
133                write!(f, "keychain:{service}/{account}")
134            }
135            SecretRef::File(p) => write!(f, "file:{}", p.display()),
136            SecretRef::Cmd(c) => write!(f, "cmd:{c}"),
137        }
138    }
139}
140
141impl std::str::FromStr for SecretRef {
142    type Err = SecretError;
143    fn from_str(s: &str) -> Result<Self, Self::Err> {
144        let (scheme, rest) = s
145            .split_once(':')
146            .ok_or_else(|| SecretError::Parse(format!("missing scheme: {s}")))?;
147        match scheme {
148            "env" => Ok(SecretRef::Env(rest.to_string())),
149            "keychain" => {
150                let (service, account) = rest.split_once('/').ok_or_else(|| {
151                    SecretError::Parse(format!("keychain ref needs service/account: {s}"))
152                })?;
153                Ok(SecretRef::Keychain {
154                    service: service.to_string(),
155                    account: account.to_string(),
156                })
157            }
158            "file" => Ok(SecretRef::File(PathBuf::from(rest))),
159            "cmd" => Ok(SecretRef::Cmd(rest.to_string())),
160            other => Err(SecretError::Parse(format!("unknown scheme: {other}"))),
161        }
162    }
163}
164
165impl Serialize for SecretRef {
166    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
167        s.collect_str(self)
168    }
169}
170
171impl<'de> Deserialize<'de> for SecretRef {
172    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
173        let s = String::deserialize(d)?;
174        s.parse().map_err(serde::de::Error::custom)
175    }
176}
177
178/// Force-block OS keychain access in this process: lookups behave as
179/// "not found", writes are rejected. Exists so processes that must never
180/// trigger a macOS keychain password prompt (test runs, CI) can opt out.
181pub const ENV_KEYCHAIN_DISABLED: &str = "MUR_KEYCHAIN_DISABLED";
182/// Overrides the automatic test-process block below. Set by tests that
183/// install a keyring mock builder (those never reach the real keychain).
184pub const ENV_KEYCHAIN_ALLOW: &str = "MUR_KEYCHAIN_ALLOW";
185
186/// Cargo test binaries get a fresh hash suffix on every rebuild, so macOS
187/// keychain "always allow" ACLs never stick and any test that resolves a
188/// real `keychain:` ref (e.g. via the user's ~/.mur/config.yaml) rains
189/// password prompts on every run. nextest sets `NEXTEST=1` in each test
190/// process — treat that as "no real keychain" unless explicitly re-enabled.
191fn keychain_blocked() -> bool {
192    if std::env::var_os(ENV_KEYCHAIN_ALLOW).is_some() {
193        return false;
194    }
195    std::env::var_os(ENV_KEYCHAIN_DISABLED).is_some() || std::env::var_os("NEXTEST").is_some()
196}
197
198impl SecretRef {
199    pub async fn resolve(&self) -> Result<SecretString, SecretError> {
200        match self {
201            SecretRef::Env(var) => std::env::var(var)
202                .map(SecretString::from)
203                .map_err(|_| SecretError::EnvNotSet(var.clone())),
204            SecretRef::Keychain { service, account } if keychain_blocked() => {
205                Err(SecretError::KeychainNotFound {
206                    service: service.clone(),
207                    account: account.clone(),
208                })
209            }
210            SecretRef::Keychain { service, account } => {
211                let svc = service.clone();
212                let acct = account.clone();
213                let res = tokio::task::spawn_blocking(move || -> Result<String, SecretError> {
214                    let entry = keyring::Entry::new(&svc, &acct)
215                        .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
216                    match entry.get_password() {
217                        Ok(s) => Ok(s),
218                        Err(keyring::Error::NoEntry) => Err(SecretError::KeychainNotFound {
219                            service: svc.clone(),
220                            account: acct.clone(),
221                        }),
222                        Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
223                    }
224                })
225                .await
226                .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?;
227                res.map(SecretString::from)
228            }
229            SecretRef::File(path) => resolve_file(path).await,
230            SecretRef::Cmd(spec) => resolve_cmd(spec).await,
231        }
232    }
233
234    /// Probe whether the secret resolves successfully without surfacing the
235    /// value. Used by GUI/CLI status indicators. Note: for `Cmd` refs this
236    /// actually runs the command, which may have side effects or be slow.
237    pub async fn check(&self) -> bool {
238        self.resolve().await.is_ok()
239    }
240
241    /// Resolve and expose the secret as a plain `String` for callers that must
242    /// hand the raw value to an external API (e.g. an `Authorization: Bearer`
243    /// header). This is the deliberate materialization boundary — keep the
244    /// returned value short-lived and never log or persist it. Returns `None`
245    /// on any resolution failure (missing env var, keychain entry, etc.).
246    pub async fn resolve_to_string(&self) -> Option<String> {
247        use secrecy::ExposeSecret;
248        self.resolve()
249            .await
250            .ok()
251            .map(|s| s.expose_secret().to_string())
252    }
253
254    /// Synchronous resolve for callers outside an async context (CLI
255    /// factories, config loaders). Inside a multi-thread tokio runtime it
256    /// uses block_in_place; inside a current-thread runtime (where
257    /// block_in_place panics) it hops to a fresh thread; otherwise it spins
258    /// a current-thread runtime.
259    /// The pre-seal cached value for this ref, if any — WITHOUT falling back
260    /// to the backend.
261    ///
262    /// For callers that reach a backend directly rather than through
263    /// `resolve_blocking`, so they can honour the cache without changing what
264    /// they do when it misses.
265    pub fn resolve_preseal_cached(&self) -> Option<SecretString> {
266        preseal_lookup(self)
267    }
268
269    pub fn resolve_blocking(&self) -> Result<SecretString, SecretError> {
270        // A value cached before the sandbox sealed wins. Without this, a `file:`
271        // ref inside the denied credential store is unreadable post-seal and the
272        // caller falls through to a per-agent Keychain lookup (#866). See
273        // `cache_before_seal`.
274        if let Some(v) = preseal_lookup(self) {
275            return Ok(v);
276        }
277        fn fresh_runtime_resolve(r: &SecretRef) -> Result<SecretString, SecretError> {
278            tokio::runtime::Builder::new_current_thread()
279                .enable_all()
280                .build()
281                .map_err(|e| SecretError::KeychainBackend(format!("runtime: {e}")))?
282                .block_on(r.resolve())
283        }
284        match tokio::runtime::Handle::try_current() {
285            Ok(h) if h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
286                tokio::task::block_in_place(|| h.block_on(self.resolve()))
287            }
288            // Current-thread runtime (e.g. #[tokio::test]): block_in_place
289            // would panic — resolve on a fresh OS thread instead.
290            Ok(_) => std::thread::scope(|s| {
291                s.spawn(|| fresh_runtime_resolve(self))
292                    .join()
293                    .unwrap_or_else(|_| {
294                        Err(SecretError::KeychainBackend(
295                            "resolver thread panicked".into(),
296                        ))
297                    })
298            }),
299            Err(_) => fresh_runtime_resolve(self),
300        }
301    }
302
303    /// Blocking analogue of `resolve_to_string` — same materialization
304    /// caveats apply.
305    pub fn resolve_to_string_blocking(&self) -> Option<String> {
306        use secrecy::ExposeSecret;
307        self.resolve_blocking()
308            .ok()
309            .map(|s| s.expose_secret().to_string())
310    }
311}
312
313/// Read a secret from the OS keychain.
314///
315/// Returns `Ok(None)` when the entry doesn't exist (so callers can fall
316/// through to the next precedence layer cleanly), and `Err(...)` only for
317/// real backend failures (locked keychain, permission denied, malformed
318/// service/account, transport error). Silently swallowing those errors would
319/// mask configuration problems and let the next fallback layer take over
320/// when the user actually expected the keychain entry to be honored.
321///
322/// Pairs with [`keychain_set`] / [`keychain_delete`].
323pub async fn keychain_get(
324    service: &str,
325    account: &str,
326) -> Result<Option<SecretString>, SecretError> {
327    if keychain_blocked() {
328        return Ok(None);
329    }
330    let svc = service.to_string();
331    let acct = account.to_string();
332    tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
333        let entry = keyring::Entry::new(&svc, &acct)
334            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
335        match entry.get_password() {
336            Ok(s) => Ok(Some(s)),
337            Err(keyring::Error::NoEntry) => Ok(None),
338            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
339        }
340    })
341    .await
342    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
343    .map(|opt| opt.map(SecretString::from))
344}
345
346/// `errSecItemNotFound` — the one `SecItemCopyMatching` status that means
347/// "absent" rather than "failed".
348#[cfg(target_os = "macos")]
349const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300;
350
351/// Does a keychain item exist? Never reads the secret value.
352///
353/// For callers that only need presence (e.g. `mur doctor` counting what an
354/// upgrade would lose). On macOS this is an attribute-only query: reading item
355/// DATA is ACL-gated, and an ad-hoc binary whose hash changed on upgrade is no
356/// longer on the ACL — `keychain_get` then blocks on an authorization prompt
357/// nobody answers and fails as if absent. Attribute reads are not gated, so
358/// this neither prompts nor stalls, and the answer is correct.
359///
360/// Other platforms' backends do not prompt, so they fall back to a value read.
361pub fn keychain_item_exists(service: &str, account: &str) -> Result<bool, SecretError> {
362    if keychain_blocked() {
363        return Ok(false);
364    }
365    #[cfg(target_os = "macos")]
366    {
367        use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
368        let found = ItemSearchOptions::new()
369            .class(ItemClass::generic_password())
370            .service(service)
371            .account(account)
372            .load_attributes(true)
373            .load_data(false)
374            .limit(Limit::Max(1))
375            .search();
376        match found {
377            Ok(items) => Ok(!items.is_empty()),
378            Err(e) if e.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(false),
379            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
380        }
381    }
382    #[cfg(not(target_os = "macos"))]
383    {
384        let entry = keyring::Entry::new(service, account)
385            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
386        match entry.get_password() {
387            Ok(_) => Ok(true),
388            Err(keyring::Error::NoEntry) => Ok(false),
389            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
390        }
391    }
392}
393
394/// Write a secret to the OS keychain. Used by `mur agent secret set` and the
395/// GUI's `set_secret` command.
396pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
397    if keychain_blocked() {
398        return Err(SecretError::KeychainBackend(format!(
399            "keychain access disabled in this process ({ENV_KEYCHAIN_DISABLED}/test); \
400             set {ENV_KEYCHAIN_ALLOW}=1 to override"
401        )));
402    }
403    let svc = service.to_string();
404    let acct = account.to_string();
405    let val = value.to_string();
406    tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
407        let entry = keyring::Entry::new(&svc, &acct)
408            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
409        entry
410            .set_password(&val)
411            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
412        Ok(())
413    })
414    .await
415    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
416}
417
418/// Delete a secret from the OS keychain. Idempotent: missing entries are not
419/// an error. Used by `mur agent secret delete`.
420pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
421    if keychain_blocked() {
422        return Ok(());
423    }
424    let svc = service.to_string();
425    let acct = account.to_string();
426    tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
427        let entry = keyring::Entry::new(&svc, &acct)
428            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
429        match entry.delete_credential() {
430            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
431            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
432        }
433    })
434    .await
435    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
436}
437
438async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
439    let mut parts = shell_words::split(spec)
440        .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
441    if parts.is_empty() {
442        return Err(SecretError::Parse("empty cmd".into()));
443    }
444    let program = parts.remove(0);
445    let output = tokio::process::Command::new(&program)
446        .args(&parts)
447        .output()
448        .await
449        .map_err(|e| SecretError::Cmd {
450            cmd: format!("{spec} ({e})"),
451            status: -1,
452        })?;
453    if !output.status.success() {
454        return Err(SecretError::Cmd {
455            cmd: spec.to_string(),
456            status: output.status.code().unwrap_or(-1),
457        });
458    }
459    let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
460        cmd: format!("{spec} (non-utf8 stdout: {e})"),
461        status: -2,
462    })?;
463    Ok(SecretString::from(
464        s.trim_end_matches(['\n', '\r']).to_string(),
465    ))
466}
467
468async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
469    let expanded = shellexpand::full(&path.to_string_lossy())
470        .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
471        .to_string();
472    let p = std::path::PathBuf::from(expanded);
473
474    #[cfg(unix)]
475    {
476        use std::os::unix::fs::PermissionsExt;
477        let meta = tokio::fs::metadata(&p)
478            .await
479            .map_err(|e| SecretError::FileRead {
480                path: p.display().to_string(),
481                source: e,
482            })?;
483        let mode = meta.permissions().mode() & 0o777;
484        if mode & 0o077 != 0 {
485            return Err(SecretError::FileMode(format!(
486                "{}: mode {:o} grants group/world access",
487                p.display(),
488                mode
489            )));
490        }
491    }
492
493    let bytes = tokio::fs::read(&p)
494        .await
495        .map_err(|e| SecretError::FileRead {
496            path: p.display().to_string(),
497            source: e,
498        })?;
499
500    let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
501        decrypt_age(&bytes).await?
502    } else {
503        String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
504    };
505    let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
506    Ok(SecretString::from(trimmed))
507}
508
509async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
510    let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
511        Ok(p) => std::path::PathBuf::from(p),
512        Err(_) => crate::home::try_mur_home()
513            .ok_or_else(|| {
514                SecretError::AgeDecrypt(
515                    "MUR_AGE_IDENTITY_PATH unset and MUR home not resolvable".into(),
516                )
517            })?
518            .join("age/identity.txt"),
519    };
520
521    let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
522        SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
523    })?;
524    let identity: age::x25519::Identity = id_str
525        .trim()
526        .parse()
527        .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
528
529    let decryptor =
530        age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
531    let mut reader = decryptor
532        .decrypt(std::iter::once(&identity as &dyn age::Identity))
533        .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
534    let mut out = String::new();
535    use std::io::Read;
536    reader
537        .read_to_string(&mut out)
538        .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
539    Ok(out)
540}
541
542#[cfg(test)]
543mod tests;
544
545#[cfg(test)]
546mod resolve_env_tests;
547
548#[cfg(test)]
549mod keychain_test_fixture;
550
551#[cfg(test)]
552mod resolve_keychain_tests;
553
554#[cfg(all(test, unix))]
555mod resolve_file_tests {
556    use super::*;
557    use secrecy::ExposeSecret;
558    use std::os::unix::fs::PermissionsExt;
559    use tempfile::tempdir;
560
561    #[tokio::test]
562    async fn reads_plaintext_0600() {
563        let dir = tempdir().unwrap();
564        let p = dir.path().join("k.txt");
565        std::fs::write(&p, "abc\n").unwrap();
566        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
567        let s = SecretRef::File(p);
568        let v = s.resolve().await.unwrap();
569        assert_eq!(v.expose_secret(), "abc"); // trailing newline stripped
570    }
571
572    #[tokio::test]
573    async fn rejects_world_readable() {
574        let dir = tempdir().unwrap();
575        let p = dir.path().join("k.txt");
576        std::fs::write(&p, "abc").unwrap();
577        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
578        let s = SecretRef::File(p);
579        let err = s.resolve().await.unwrap_err();
580        assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
581    }
582
583    #[tokio::test]
584    async fn decrypts_age_recipient_file() {
585        let dir = tempdir().unwrap();
586        let identity = age::x25519::Identity::generate();
587        let recipient = identity.to_public();
588        let payload = b"shh-from-age";
589
590        let mut encrypted: Vec<u8> = Vec::new();
591        let encryptor =
592            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
593                .unwrap();
594        let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
595        std::io::Write::write_all(&mut writer, payload).unwrap();
596        writer.finish().unwrap();
597
598        let enc_path = dir.path().join("k.age");
599        std::fs::write(&enc_path, &encrypted).unwrap();
600        std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
601        let id_path = dir.path().join("identity.txt");
602        use secrecy::ExposeSecret as _;
603        std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
604        std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
605        let _env = crate::test_env::EnvGuard::set([("MUR_AGE_IDENTITY_PATH", &id_path)]);
606        let s = SecretRef::File(enc_path);
607        let v = s.resolve().await.unwrap();
608        assert_eq!(v.expose_secret(), "shh-from-age");
609    }
610}
611
612#[cfg(all(test, unix))]
613mod resolve_cmd_tests {
614    use super::*;
615    use secrecy::ExposeSecret;
616
617    #[tokio::test]
618    async fn echoes_stdout() {
619        let s = SecretRef::Cmd("printf shh-from-cmd".into());
620        let v = s.resolve().await.unwrap();
621        assert_eq!(v.expose_secret(), "shh-from-cmd");
622    }
623
624    #[tokio::test]
625    async fn errors_on_non_zero_exit() {
626        let s = SecretRef::Cmd("sh -c 'exit 7'".into());
627        let err = s.resolve().await.unwrap_err();
628        match err {
629            SecretError::Cmd { status, .. } => assert_eq!(status, 7),
630            other => panic!("unexpected: {other:?}"),
631        }
632    }
633}
634
635#[cfg(test)]
636mod check_tests;
637
638#[cfg(test)]
639mod keychain_helpers_tests;
640
641#[cfg(test)]
642mod resolve_blocking_tests;