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/// `errSecItemNotFound` — the one `SecItemCopyMatching` status that means
345/// "absent" rather than "failed".
346#[cfg(target_os = "macos")]
347const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300;
348
349/// Does a keychain item exist? Never reads the secret value.
350///
351/// For callers that only need presence (e.g. `mur doctor` counting what an
352/// upgrade would lose). On macOS this is an attribute-only query: reading item
353/// DATA is ACL-gated, and an ad-hoc binary whose hash changed on upgrade is no
354/// longer on the ACL — `keychain_get` then blocks on an authorization prompt
355/// nobody answers and fails as if absent. Attribute reads are not gated, so
356/// this neither prompts nor stalls, and the answer is correct.
357///
358/// Other platforms' backends do not prompt, so they fall back to a value read.
359pub fn keychain_item_exists(service: &str, account: &str) -> Result<bool, SecretError> {
360    if keychain_blocked() {
361        return Ok(false);
362    }
363    #[cfg(target_os = "macos")]
364    {
365        use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
366        let found = ItemSearchOptions::new()
367            .class(ItemClass::generic_password())
368            .service(service)
369            .account(account)
370            .load_attributes(true)
371            .load_data(false)
372            .limit(Limit::Max(1))
373            .search();
374        match found {
375            Ok(items) => Ok(!items.is_empty()),
376            Err(e) if e.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(false),
377            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
378        }
379    }
380    #[cfg(not(target_os = "macos"))]
381    {
382        let entry = keyring::Entry::new(service, account)
383            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
384        match entry.get_password() {
385            Ok(_) => Ok(true),
386            Err(keyring::Error::NoEntry) => Ok(false),
387            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
388        }
389    }
390}
391
392/// Write a secret to the OS keychain. Used by `mur agent secret set` and the
393/// GUI's `set_secret` command.
394pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
395    if keychain_blocked() {
396        return Err(SecretError::KeychainBackend(format!(
397            "keychain access disabled in this process ({ENV_KEYCHAIN_DISABLED}/test); \
398             set {ENV_KEYCHAIN_ALLOW}=1 to override"
399        )));
400    }
401    let svc = service.to_string();
402    let acct = account.to_string();
403    let val = value.to_string();
404    tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
405        let entry = keyring::Entry::new(&svc, &acct)
406            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
407        entry
408            .set_password(&val)
409            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
410        Ok(())
411    })
412    .await
413    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
414}
415
416/// Delete a secret from the OS keychain. Idempotent: missing entries are not
417/// an error. Used by `mur agent secret delete`.
418pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
419    if keychain_blocked() {
420        return Ok(());
421    }
422    let svc = service.to_string();
423    let acct = account.to_string();
424    tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
425        let entry = keyring::Entry::new(&svc, &acct)
426            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
427        match entry.delete_credential() {
428            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
429            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
430        }
431    })
432    .await
433    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
434}
435
436async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
437    let mut parts = shell_words::split(spec)
438        .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
439    if parts.is_empty() {
440        return Err(SecretError::Parse("empty cmd".into()));
441    }
442    let program = parts.remove(0);
443    let output = tokio::process::Command::new(&program)
444        .args(&parts)
445        .output()
446        .await
447        .map_err(|e| SecretError::Cmd {
448            cmd: format!("{spec} ({e})"),
449            status: -1,
450        })?;
451    if !output.status.success() {
452        return Err(SecretError::Cmd {
453            cmd: spec.to_string(),
454            status: output.status.code().unwrap_or(-1),
455        });
456    }
457    let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
458        cmd: format!("{spec} (non-utf8 stdout: {e})"),
459        status: -2,
460    })?;
461    Ok(SecretString::from(
462        s.trim_end_matches(['\n', '\r']).to_string(),
463    ))
464}
465
466async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
467    let expanded = shellexpand::full(&path.to_string_lossy())
468        .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
469        .to_string();
470    let p = std::path::PathBuf::from(expanded);
471
472    #[cfg(unix)]
473    {
474        use std::os::unix::fs::PermissionsExt;
475        let meta = tokio::fs::metadata(&p)
476            .await
477            .map_err(|e| SecretError::FileRead {
478                path: p.display().to_string(),
479                source: e,
480            })?;
481        let mode = meta.permissions().mode() & 0o777;
482        if mode & 0o077 != 0 {
483            return Err(SecretError::FileMode(format!(
484                "{}: mode {:o} grants group/world access",
485                p.display(),
486                mode
487            )));
488        }
489    }
490
491    let bytes = tokio::fs::read(&p)
492        .await
493        .map_err(|e| SecretError::FileRead {
494            path: p.display().to_string(),
495            source: e,
496        })?;
497
498    let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
499        decrypt_age(&bytes).await?
500    } else {
501        String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
502    };
503    let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
504    Ok(SecretString::from(trimmed))
505}
506
507async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
508    let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
509        Ok(p) => std::path::PathBuf::from(p),
510        Err(_) => dirs::home_dir()
511            .ok_or_else(|| {
512                SecretError::AgeDecrypt(
513                    "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
514                )
515            })?
516            .join(".mur/age/identity.txt"),
517    };
518
519    let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
520        SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
521    })?;
522    let identity: age::x25519::Identity = id_str
523        .trim()
524        .parse()
525        .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
526
527    let decryptor =
528        age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
529    let mut reader = decryptor
530        .decrypt(std::iter::once(&identity as &dyn age::Identity))
531        .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
532    let mut out = String::new();
533    use std::io::Read;
534    reader
535        .read_to_string(&mut out)
536        .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
537    Ok(out)
538}
539
540#[cfg(test)]
541mod tests;
542
543#[cfg(test)]
544mod resolve_env_tests;
545
546#[cfg(test)]
547mod keychain_test_fixture;
548
549#[cfg(test)]
550mod resolve_keychain_tests;
551
552#[cfg(all(test, unix))]
553mod resolve_file_tests {
554    use super::*;
555    use secrecy::ExposeSecret;
556    use std::os::unix::fs::PermissionsExt;
557    use tempfile::tempdir;
558
559    #[tokio::test]
560    async fn reads_plaintext_0600() {
561        let dir = tempdir().unwrap();
562        let p = dir.path().join("k.txt");
563        std::fs::write(&p, "abc\n").unwrap();
564        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
565        let s = SecretRef::File(p);
566        let v = s.resolve().await.unwrap();
567        assert_eq!(v.expose_secret(), "abc"); // trailing newline stripped
568    }
569
570    #[tokio::test]
571    async fn rejects_world_readable() {
572        let dir = tempdir().unwrap();
573        let p = dir.path().join("k.txt");
574        std::fs::write(&p, "abc").unwrap();
575        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
576        let s = SecretRef::File(p);
577        let err = s.resolve().await.unwrap_err();
578        assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
579    }
580
581    #[tokio::test]
582    async fn decrypts_age_recipient_file() {
583        let dir = tempdir().unwrap();
584        let identity = age::x25519::Identity::generate();
585        let recipient = identity.to_public();
586        let payload = b"shh-from-age";
587
588        let mut encrypted: Vec<u8> = Vec::new();
589        let encryptor =
590            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
591                .unwrap();
592        let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
593        std::io::Write::write_all(&mut writer, payload).unwrap();
594        writer.finish().unwrap();
595
596        let enc_path = dir.path().join("k.age");
597        std::fs::write(&enc_path, &encrypted).unwrap();
598        std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
599        let id_path = dir.path().join("identity.txt");
600        use secrecy::ExposeSecret as _;
601        std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
602        std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
603        let _env = crate::test_env::EnvGuard::set([("MUR_AGE_IDENTITY_PATH", &id_path)]);
604        let s = SecretRef::File(enc_path);
605        let v = s.resolve().await.unwrap();
606        assert_eq!(v.expose_secret(), "shh-from-age");
607    }
608}
609
610#[cfg(all(test, unix))]
611mod resolve_cmd_tests {
612    use super::*;
613    use secrecy::ExposeSecret;
614
615    #[tokio::test]
616    async fn echoes_stdout() {
617        let s = SecretRef::Cmd("printf shh-from-cmd".into());
618        let v = s.resolve().await.unwrap();
619        assert_eq!(v.expose_secret(), "shh-from-cmd");
620    }
621
622    #[tokio::test]
623    async fn errors_on_non_zero_exit() {
624        let s = SecretRef::Cmd("sh -c 'exit 7'".into());
625        let err = s.resolve().await.unwrap_err();
626        match err {
627            SecretError::Cmd { status, .. } => assert_eq!(status, 7),
628            other => panic!("unexpected: {other:?}"),
629        }
630    }
631}
632
633#[cfg(test)]
634mod check_tests;
635
636#[cfg(test)]
637mod keychain_helpers_tests;
638
639#[cfg(test)]
640mod resolve_blocking_tests;