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
47impl std::fmt::Display for SecretRef {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            SecretRef::Env(v) => write!(f, "env:{v}"),
51            SecretRef::Keychain { service, account } => {
52                write!(f, "keychain:{service}/{account}")
53            }
54            SecretRef::File(p) => write!(f, "file:{}", p.display()),
55            SecretRef::Cmd(c) => write!(f, "cmd:{c}"),
56        }
57    }
58}
59
60impl std::str::FromStr for SecretRef {
61    type Err = SecretError;
62    fn from_str(s: &str) -> Result<Self, Self::Err> {
63        let (scheme, rest) = s
64            .split_once(':')
65            .ok_or_else(|| SecretError::Parse(format!("missing scheme: {s}")))?;
66        match scheme {
67            "env" => Ok(SecretRef::Env(rest.to_string())),
68            "keychain" => {
69                let (service, account) = rest.split_once('/').ok_or_else(|| {
70                    SecretError::Parse(format!("keychain ref needs service/account: {s}"))
71                })?;
72                Ok(SecretRef::Keychain {
73                    service: service.to_string(),
74                    account: account.to_string(),
75                })
76            }
77            "file" => Ok(SecretRef::File(PathBuf::from(rest))),
78            "cmd" => Ok(SecretRef::Cmd(rest.to_string())),
79            other => Err(SecretError::Parse(format!("unknown scheme: {other}"))),
80        }
81    }
82}
83
84impl Serialize for SecretRef {
85    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
86        s.collect_str(self)
87    }
88}
89
90impl<'de> Deserialize<'de> for SecretRef {
91    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
92        let s = String::deserialize(d)?;
93        s.parse().map_err(serde::de::Error::custom)
94    }
95}
96
97impl SecretRef {
98    pub async fn resolve(&self) -> Result<SecretString, SecretError> {
99        match self {
100            SecretRef::Env(var) => std::env::var(var)
101                .map(SecretString::from)
102                .map_err(|_| SecretError::EnvNotSet(var.clone())),
103            SecretRef::Keychain { service, account } => {
104                let svc = service.clone();
105                let acct = account.clone();
106                let res = tokio::task::spawn_blocking(move || -> Result<String, SecretError> {
107                    let entry = keyring::Entry::new(&svc, &acct)
108                        .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
109                    match entry.get_password() {
110                        Ok(s) => Ok(s),
111                        Err(keyring::Error::NoEntry) => Err(SecretError::KeychainNotFound {
112                            service: svc.clone(),
113                            account: acct.clone(),
114                        }),
115                        Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
116                    }
117                })
118                .await
119                .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?;
120                res.map(SecretString::from)
121            }
122            SecretRef::File(path) => resolve_file(path).await,
123            SecretRef::Cmd(spec) => resolve_cmd(spec).await,
124        }
125    }
126
127    /// Probe whether the secret resolves successfully without surfacing the
128    /// value. Used by GUI/CLI status indicators. Note: for `Cmd` refs this
129    /// actually runs the command, which may have side effects or be slow.
130    pub async fn check(&self) -> bool {
131        self.resolve().await.is_ok()
132    }
133}
134
135/// Read a secret from the OS keychain.
136///
137/// Returns `Ok(None)` when the entry doesn't exist (so callers can fall
138/// through to the next precedence layer cleanly), and `Err(...)` only for
139/// real backend failures (locked keychain, permission denied, malformed
140/// service/account, transport error). Silently swallowing those errors would
141/// mask configuration problems and let the next fallback layer take over
142/// when the user actually expected the keychain entry to be honored.
143///
144/// Pairs with [`keychain_set`] / [`keychain_delete`].
145pub async fn keychain_get(
146    service: &str,
147    account: &str,
148) -> Result<Option<SecretString>, SecretError> {
149    let svc = service.to_string();
150    let acct = account.to_string();
151    tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
152        let entry = keyring::Entry::new(&svc, &acct)
153            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
154        match entry.get_password() {
155            Ok(s) => Ok(Some(s)),
156            Err(keyring::Error::NoEntry) => Ok(None),
157            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
158        }
159    })
160    .await
161    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
162    .map(|opt| opt.map(SecretString::from))
163}
164
165/// Write a secret to the OS keychain. Used by `mur agent secret set` and the
166/// GUI's `set_secret` command.
167pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
168    let svc = service.to_string();
169    let acct = account.to_string();
170    let val = value.to_string();
171    tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
172        let entry = keyring::Entry::new(&svc, &acct)
173            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
174        entry
175            .set_password(&val)
176            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
177        Ok(())
178    })
179    .await
180    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
181}
182
183/// Delete a secret from the OS keychain. Idempotent: missing entries are not
184/// an error. Used by `mur agent secret delete`.
185pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
186    let svc = service.to_string();
187    let acct = account.to_string();
188    tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
189        let entry = keyring::Entry::new(&svc, &acct)
190            .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
191        match entry.delete_credential() {
192            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
193            Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
194        }
195    })
196    .await
197    .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
198}
199
200async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
201    let mut parts = shell_words::split(spec)
202        .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
203    if parts.is_empty() {
204        return Err(SecretError::Parse("empty cmd".into()));
205    }
206    let program = parts.remove(0);
207    let output = tokio::process::Command::new(&program)
208        .args(&parts)
209        .output()
210        .await
211        .map_err(|e| SecretError::Cmd {
212            cmd: format!("{spec} ({e})"),
213            status: -1,
214        })?;
215    if !output.status.success() {
216        return Err(SecretError::Cmd {
217            cmd: spec.to_string(),
218            status: output.status.code().unwrap_or(-1),
219        });
220    }
221    let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
222        cmd: format!("{spec} (non-utf8 stdout: {e})"),
223        status: -2,
224    })?;
225    Ok(SecretString::from(
226        s.trim_end_matches(['\n', '\r']).to_string(),
227    ))
228}
229
230async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
231    let expanded = shellexpand::full(&path.to_string_lossy())
232        .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
233        .to_string();
234    let p = std::path::PathBuf::from(expanded);
235
236    #[cfg(unix)]
237    {
238        use std::os::unix::fs::PermissionsExt;
239        let meta = tokio::fs::metadata(&p)
240            .await
241            .map_err(|e| SecretError::FileRead {
242                path: p.display().to_string(),
243                source: e,
244            })?;
245        let mode = meta.permissions().mode() & 0o777;
246        if mode & 0o077 != 0 {
247            return Err(SecretError::FileMode(format!(
248                "{}: mode {:o} grants group/world access",
249                p.display(),
250                mode
251            )));
252        }
253    }
254
255    let bytes = tokio::fs::read(&p)
256        .await
257        .map_err(|e| SecretError::FileRead {
258            path: p.display().to_string(),
259            source: e,
260        })?;
261
262    let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
263        decrypt_age(&bytes).await?
264    } else {
265        String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
266    };
267    let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
268    Ok(SecretString::from(trimmed))
269}
270
271async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
272    let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
273        Ok(p) => std::path::PathBuf::from(p),
274        Err(_) => dirs::home_dir()
275            .ok_or_else(|| {
276                SecretError::AgeDecrypt(
277                    "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
278                )
279            })?
280            .join(".mur/age/identity.txt"),
281    };
282
283    let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
284        SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
285    })?;
286    let identity: age::x25519::Identity = id_str
287        .trim()
288        .parse()
289        .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
290
291    let decryptor =
292        age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
293    let mut reader = decryptor
294        .decrypt(std::iter::once(&identity as &dyn age::Identity))
295        .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
296    let mut out = String::new();
297    use std::io::Read;
298    reader
299        .read_to_string(&mut out)
300        .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
301    Ok(out)
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use serde_yaml_ng as yaml;
308
309    #[test]
310    fn parses_env_form() {
311        let s: SecretRef = yaml::from_str("env:ANTHROPIC_API_KEY").unwrap();
312        assert_eq!(s, SecretRef::Env("ANTHROPIC_API_KEY".into()));
313    }
314
315    #[test]
316    fn parses_keychain_form() {
317        let s: SecretRef = yaml::from_str("keychain:mur/anthropic-oauth").unwrap();
318        assert_eq!(
319            s,
320            SecretRef::Keychain {
321                service: "mur".into(),
322                account: "anthropic-oauth".into()
323            }
324        );
325    }
326
327    #[test]
328    fn parses_file_form() {
329        let s: SecretRef = yaml::from_str("file:/tmp/foo.age").unwrap();
330        assert_eq!(s, SecretRef::File(PathBuf::from("/tmp/foo.age")));
331    }
332
333    #[test]
334    fn parses_cmd_form() {
335        let s: SecretRef = yaml::from_str("cmd:op read op://vault/item/field").unwrap();
336        assert_eq!(s, SecretRef::Cmd("op read op://vault/item/field".into()));
337    }
338
339    #[test]
340    fn rejects_unknown_scheme() {
341        let r: Result<SecretRef, _> = yaml::from_str("plain:supersecret");
342        assert!(r.is_err());
343    }
344
345    #[test]
346    fn round_trip_serde() {
347        let cases = [
348            "env:X",
349            "keychain:svc/acct",
350            "file:/p",
351            "cmd:bin --flag arg",
352        ];
353        for s in cases {
354            let parsed: SecretRef = yaml::from_str(s).unwrap();
355            let back = yaml::to_string(&parsed).unwrap();
356            // serde-yaml adds a trailing newline / quoting. Strip and compare.
357            let normalized = back
358                .trim()
359                .trim_matches(|c: char| c == '"' || c == '\'')
360                .to_string();
361            let reparsed: SecretRef = yaml::from_str(&normalized).unwrap();
362            assert_eq!(parsed, reparsed, "round-trip drift for {s}");
363        }
364    }
365}
366
367#[cfg(test)]
368mod resolve_env_tests {
369    use super::*;
370    use secrecy::ExposeSecret;
371
372    #[tokio::test]
373    async fn resolves_env_when_set() {
374        // SAFETY: uniquely named env var so concurrent tests don't collide.
375        unsafe {
376            std::env::set_var("MUR_TEST_RESOLVE_ENV", "shhh");
377        }
378        let s = SecretRef::Env("MUR_TEST_RESOLVE_ENV".into());
379        let v = s.resolve().await.unwrap();
380        assert_eq!(v.expose_secret(), "shhh");
381    }
382
383    #[tokio::test]
384    async fn errors_when_env_missing() {
385        let s = SecretRef::Env("MUR_TEST_DEFINITELY_UNSET".into());
386        let err = s.resolve().await.unwrap_err();
387        assert!(matches!(err, SecretError::EnvNotSet(_)), "got {err:?}");
388    }
389}
390
391#[cfg(test)]
392mod keychain_test_fixture {
393    //! Shared mock fixture used by every test module that touches the keyring.
394    //!
395    //! v3's stock `keyring::mock` advertises CredentialPersistence::EntryOnly
396    //! and gives each Entry its own private storage — that breaks our tests
397    //! because resolve() creates a fresh `Entry::new` after setup. The fixture
398    //! below installs a SharedMockBuilder backed by an Arc<Mutex<HashMap>>
399    //! so all Entry instances see the same data.
400    //!
401    //! Tests serialize on a tokio::sync::Mutex (held across await) because
402    //! `set_default_credential_builder` mutates a process-global.
403
404    use keyring::credential::{
405        Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
406    };
407    use std::any::Any;
408    use std::collections::HashMap;
409    use std::sync::{Arc, Mutex};
410    use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};
411
412    type Store = Arc<Mutex<HashMap<(String, String), Vec<u8>>>>;
413
414    struct SharedMockCredential {
415        store: Store,
416        key: (String, String),
417    }
418
419    impl CredentialApi for SharedMockCredential {
420        fn set_secret(&self, password: &[u8]) -> keyring::Result<()> {
421            self.store
422                .lock()
423                .unwrap()
424                .insert(self.key.clone(), password.to_vec());
425            Ok(())
426        }
427        fn get_secret(&self) -> keyring::Result<Vec<u8>> {
428            self.store
429                .lock()
430                .unwrap()
431                .get(&self.key)
432                .cloned()
433                .ok_or(keyring::Error::NoEntry)
434        }
435        fn delete_credential(&self) -> keyring::Result<()> {
436            self.store
437                .lock()
438                .unwrap()
439                .remove(&self.key)
440                .map(|_| ())
441                .ok_or(keyring::Error::NoEntry)
442        }
443        fn as_any(&self) -> &dyn Any {
444            self
445        }
446    }
447
448    struct SharedMockBuilder {
449        store: Store,
450    }
451
452    impl CredentialBuilderApi for SharedMockBuilder {
453        fn build(
454            &self,
455            _target: Option<&str>,
456            service: &str,
457            user: &str,
458        ) -> keyring::Result<Box<Credential>> {
459            Ok(Box::new(SharedMockCredential {
460                store: self.store.clone(),
461                key: (service.to_string(), user.to_string()),
462            }))
463        }
464        fn as_any(&self) -> &dyn Any {
465            self
466        }
467        fn persistence(&self) -> CredentialPersistence {
468            CredentialPersistence::ProcessOnly
469        }
470    }
471
472    static MOCK_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
473
474    pub(super) async fn install_mock(
475        initial: Option<(&str, &str, &str)>,
476    ) -> AsyncMutexGuard<'static, ()> {
477        let g = MOCK_LOCK.lock().await;
478        let store: Store = Arc::new(Mutex::new(HashMap::new()));
479        if let Some((svc, user, pw)) = initial {
480            store
481                .lock()
482                .unwrap()
483                .insert((svc.to_string(), user.to_string()), pw.as_bytes().to_vec());
484        }
485        let builder: Box<CredentialBuilder> = Box::new(SharedMockBuilder { store });
486        keyring::set_default_credential_builder(builder);
487        g
488    }
489}
490
491#[cfg(test)]
492mod resolve_keychain_tests {
493    use super::keychain_test_fixture::install_mock;
494    use super::*;
495    use secrecy::ExposeSecret;
496
497    #[tokio::test]
498    async fn resolves_when_set() {
499        let _g = install_mock(Some(("mur-test", "kc-acct", "kc-secret"))).await;
500        let s = SecretRef::Keychain {
501            service: "mur-test".into(),
502            account: "kc-acct".into(),
503        };
504        let v = s.resolve().await.unwrap();
505        assert_eq!(v.expose_secret(), "kc-secret");
506    }
507
508    #[tokio::test]
509    async fn errors_when_missing() {
510        let _g = install_mock(None).await;
511        let s = SecretRef::Keychain {
512            service: "mur-test".into(),
513            account: "kc-acct".into(),
514        };
515        let err = s.resolve().await.unwrap_err();
516        assert!(
517            matches!(err, SecretError::KeychainNotFound { .. }),
518            "got {err:?}"
519        );
520    }
521}
522
523#[cfg(all(test, unix))]
524mod resolve_file_tests {
525    use super::*;
526    use secrecy::ExposeSecret;
527    use std::os::unix::fs::PermissionsExt;
528    use tempfile::tempdir;
529
530    #[tokio::test]
531    async fn reads_plaintext_0600() {
532        let dir = tempdir().unwrap();
533        let p = dir.path().join("k.txt");
534        std::fs::write(&p, "abc\n").unwrap();
535        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
536        let s = SecretRef::File(p);
537        let v = s.resolve().await.unwrap();
538        assert_eq!(v.expose_secret(), "abc"); // trailing newline stripped
539    }
540
541    #[tokio::test]
542    async fn rejects_world_readable() {
543        let dir = tempdir().unwrap();
544        let p = dir.path().join("k.txt");
545        std::fs::write(&p, "abc").unwrap();
546        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
547        let s = SecretRef::File(p);
548        let err = s.resolve().await.unwrap_err();
549        assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
550    }
551
552    #[tokio::test]
553    async fn decrypts_age_recipient_file() {
554        let dir = tempdir().unwrap();
555        let identity = age::x25519::Identity::generate();
556        let recipient = identity.to_public();
557        let payload = b"shh-from-age";
558
559        let mut encrypted: Vec<u8> = Vec::new();
560        let encryptor =
561            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
562                .unwrap();
563        let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
564        std::io::Write::write_all(&mut writer, payload).unwrap();
565        writer.finish().unwrap();
566
567        let enc_path = dir.path().join("k.age");
568        std::fs::write(&enc_path, &encrypted).unwrap();
569        std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
570        let id_path = dir.path().join("identity.txt");
571        use secrecy::ExposeSecret as _;
572        std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
573        std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
574        // SAFETY: setting an env var read by decrypt_age. Tests serialize on
575        // the same env var, so concurrent writes would race; we serialize via
576        // a Mutex-held guard.
577        unsafe {
578            std::env::set_var("MUR_AGE_IDENTITY_PATH", &id_path);
579        }
580        let s = SecretRef::File(enc_path);
581        let v = s.resolve().await.unwrap();
582        assert_eq!(v.expose_secret(), "shh-from-age");
583        unsafe {
584            std::env::remove_var("MUR_AGE_IDENTITY_PATH");
585        }
586    }
587}
588
589#[cfg(all(test, unix))]
590mod resolve_cmd_tests {
591    use super::*;
592    use secrecy::ExposeSecret;
593
594    #[tokio::test]
595    async fn echoes_stdout() {
596        let s = SecretRef::Cmd("printf shh-from-cmd".into());
597        let v = s.resolve().await.unwrap();
598        assert_eq!(v.expose_secret(), "shh-from-cmd");
599    }
600
601    #[tokio::test]
602    async fn errors_on_non_zero_exit() {
603        let s = SecretRef::Cmd("sh -c 'exit 7'".into());
604        let err = s.resolve().await.unwrap_err();
605        match err {
606            SecretError::Cmd { status, .. } => assert_eq!(status, 7),
607            other => panic!("unexpected: {other:?}"),
608        }
609    }
610}
611
612#[cfg(test)]
613mod check_tests {
614    use super::*;
615
616    #[tokio::test]
617    async fn check_env_present() {
618        // SAFETY: uniquely named env var so concurrent tests don't collide.
619        unsafe {
620            std::env::set_var("MUR_TEST_CHECK_ENV", "1");
621        }
622        assert!(SecretRef::Env("MUR_TEST_CHECK_ENV".into()).check().await);
623    }
624
625    #[tokio::test]
626    async fn check_env_absent() {
627        assert!(
628            !SecretRef::Env("MUR_TEST_CHECK_DEFINITELY_UNSET".into())
629                .check()
630                .await
631        );
632    }
633}
634
635#[cfg(test)]
636mod keychain_helpers_tests {
637    use super::keychain_test_fixture::install_mock;
638    use super::*;
639    use secrecy::ExposeSecret;
640
641    #[tokio::test]
642    async fn set_then_resolve_round_trips() {
643        let _g = install_mock(None).await;
644        keychain_set("mur-test", "round-trip", "v1").await.unwrap();
645        let v = SecretRef::Keychain {
646            service: "mur-test".into(),
647            account: "round-trip".into(),
648        }
649        .resolve()
650        .await
651        .unwrap();
652        assert_eq!(v.expose_secret(), "v1");
653    }
654
655    #[tokio::test]
656    async fn delete_works() {
657        let _g = install_mock(None).await;
658        keychain_set("mur-test", "to-delete", "v").await.unwrap();
659        keychain_delete("mur-test", "to-delete").await.unwrap();
660        let r = SecretRef::Keychain {
661            service: "mur-test".into(),
662            account: "to-delete".into(),
663        }
664        .resolve()
665        .await;
666        assert!(matches!(r, Err(SecretError::KeychainNotFound { .. })));
667    }
668
669    #[tokio::test]
670    async fn delete_missing_is_idempotent() {
671        let _g = install_mock(None).await;
672        // No prior set — must still return Ok.
673        keychain_delete("mur-test", "never-set").await.unwrap();
674    }
675}