Skip to main content

car_secrets/
lib.rs

1//! Cross-platform secret store for Common Agent Runtime.
2//!
3//! Unifies OS-native secure storage across the three platforms CAR targets:
4//!
5//! - **macOS** — `/usr/bin/security` over Keychain Services
6//! - **Windows** — Credential Manager (DPAPI)
7//! - **Linux** — Secret Service (GNOME Keyring / KWallet / KeePassXC /
8//!   anything else that speaks `org.freedesktop.secrets`)
9//!
10//! The API is intentionally small: `put`, `get`, `delete`, `status`, `list`.
11//! Callers choose a namespace (`service`) and a key (`account`); values are
12//! UTF-8 strings. JSON helpers are provided for structured values.
13//!
14//! # Availability
15//!
16//! On headless Linux without a Secret Service daemon, `put`/`get`/`delete`
17//! return [`SecretError::Unavailable`]. This is explicit: there is no silent
18//! plaintext fallback. Callers should probe [`is_available`] before relying on
19//! the store, or handle `Unavailable` with their own fallback.
20//!
21//! # Security boundary
22//!
23//! Secrets never enter CAR memory, state, or prompt context unless a caller
24//! explicitly reads them and passes them into one of those systems. The store
25//! treats a missing backend as a hard error so misconfigured environments are
26//! loud, not silently insecure.
27
28use keyring::Entry;
29use serde::{Deserialize, Serialize};
30use thiserror::Error;
31
32/// Default service (namespace) used when callers don't supply one.
33///
34/// `"car"` is the per-app namespace shared by every CAR component
35/// (`car-cli`, `car-inference` model-key fallback, FFI bindings, WebSocket
36/// `secret.*` methods). One shared bucket means `car secrets put OPENAI_API_KEY`
37/// stores the same entry that `car-inference` reads at runtime — no namespace
38/// translation in users' heads.
39///
40/// Pre-v0.5.2 this was `"car-runtime"`. The rename was a one-time UX change;
41/// any keychain entries written before that date live under the old service
42/// name and need to be migrated (or just `car secrets put` again).
43pub const DEFAULT_SERVICE: &str = "car";
44
45/// Resolve a raw key value for `env_var` from the standard CAR
46/// sources, in priority order:
47///
48/// 1. **Process env var** — `std::env::var(env_var)`. Wins
49///    everything (containers, CI, K8s pods, systemd units).
50///    `~/.car/env` is loaded into the process env at server
51///    startup, so file-based config flows through this path too.
52/// 2. **OS keychain via [`SecretStore`]** — looked up under
53///    [`DEFAULT_SERVICE`] = `"car"` with account = `env_var`.
54///    Skipped silently when [`SecretStore::is_available`] is
55///    false so we never wake pinentry on a locked desktop or
56///    dial DBus on a headless Linux box.
57/// 3. **Missing** — returns `None`.
58///
59/// This is the single source of truth for CAR's API-key
60/// resolution. Every call site that wants "env first, then
61/// keychain" should go through here so the priority can't drift
62/// (`car-inference::key_pool`, `car-voice::elevenlabs_*`, and
63/// any future remote backend land here, not on their own
64/// re-implementation).
65pub fn resolve_env_or_keychain(env_var: &str) -> Option<String> {
66    if let Ok(v) = std::env::var(env_var) {
67        if !v.is_empty() {
68            return Some(v);
69        }
70    }
71    let store = SecretStore::new();
72    if !store.is_available() {
73        return None;
74    }
75    let secret_ref = SecretRef::new(DEFAULT_SERVICE, env_var);
76    match store.get(&secret_ref) {
77        Ok(v) if !v.is_empty() => {
78            tracing::debug!(env_var = %env_var, "resolved API key from OS keychain");
79            Some(v)
80        }
81        Ok(_) => None, // empty value — treat as missing
82        Err(SecretError::NotFound { .. }) => None,
83        Err(e) => {
84            tracing::warn!(env_var = %env_var, error = %e, "keychain lookup failed");
85            None
86        }
87    }
88}
89
90/// Errors the secret store can produce.
91#[derive(Debug, Error)]
92pub enum SecretError {
93    /// No OS backend is available (e.g. headless Linux with no Secret
94    /// Service daemon, or a keychain that refused to unlock).
95    #[error("secret store unavailable: {0}")]
96    Unavailable(String),
97
98    /// The requested entry does not exist.
99    #[error("no entry for service={service:?} key={key:?}")]
100    NotFound { service: String, key: String },
101
102    /// An OS-native error the store couldn't classify — usually surfaced
103    /// verbatim from the underlying keychain API.
104    #[error("secret store error: {0}")]
105    Backend(String),
106
107    /// A JSON helper was used but the stored value wasn't valid JSON.
108    #[error("stored value is not valid JSON: {0}")]
109    InvalidJson(String),
110}
111
112/// Status of an entry — no value data, safe to log.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct SecretStatus {
115    pub service: String,
116    pub key: String,
117    pub exists: bool,
118}
119
120/// Result of `SecretStore::availability` — `available` mirrors what
121/// `is_available` returns, and `reason` carries the platform-specific
122/// detail (e.g. "no Secret Service daemon", "keychain locked") so the
123/// FFI surface can report an actionable message instead of a bare
124/// boolean.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct AvailabilityCheck {
127    pub available: bool,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub reason: Option<String>,
130}
131
132/// Logical handle for a secret — (service, key) pair.
133#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
134pub struct SecretRef {
135    pub service: String,
136    pub key: String,
137}
138
139impl SecretRef {
140    pub fn new(service: impl Into<String>, key: impl Into<String>) -> Self {
141        Self {
142            service: service.into(),
143            key: key.into(),
144        }
145    }
146
147    pub fn with_default_service(key: impl Into<String>) -> Self {
148        Self {
149            service: DEFAULT_SERVICE.to_string(),
150            key: key.into(),
151        }
152    }
153}
154
155/// Cross-platform secret store backed by the host OS keychain.
156///
157/// Stateless by design — it holds no cached secrets. Every call round-trips
158/// to the OS. That makes concurrent usage safe and avoids any in-process
159/// leak surface beyond the immediate call's return value.
160#[derive(Debug, Default, Clone, Copy)]
161pub struct SecretStore;
162
163impl SecretStore {
164    pub fn new() -> Self {
165        Self
166    }
167
168    /// Store a UTF-8 secret under `(service, key)`. Replaces any existing
169    /// value at the same ref.
170    ///
171    /// On macOS, writes via `/usr/bin/security add-generic-password -U -A`
172    /// so the resulting item has a permissive ACL — readable by any
173    /// binary the user runs. This is necessary because the legacy
174    /// keychain's default ACL binds an item to the calling binary's
175    /// code-signing hash, which changes on every cargo rebuild and
176    /// silently revokes read access from later versions of the same
177    /// CLI tool. (`/usr/bin/security` is Apple-signed with full
178    /// keychain entitlements — the same path reads, status checks,
179    /// and deletes use, and the same path users invoke manually.)
180    ///
181    /// Trade-off: the value transits argv during the spawn (visible to
182    /// `ps` from the same user for ~milliseconds). Acceptable for the
183    /// "single-user developer machine" threat model; any process that
184    /// can see argv on this machine can also read the keychain
185    /// directly via `security`. On other platforms, behavior is
186    /// unchanged (keyring crate's native backend).
187    pub fn put(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
188        platform_put(self, r, value)
189    }
190
191    /// Store a structured value serialized as JSON.
192    pub fn put_json<T: Serialize>(&self, r: &SecretRef, value: &T) -> Result<(), SecretError> {
193        let s = serde_json::to_string(value)
194            .map_err(|e| SecretError::Backend(format!("serialize: {}", e)))?;
195        self.put(r, &s)
196    }
197
198    /// Read a UTF-8 secret. Returns `NotFound` if no entry exists.
199    ///
200    /// On macOS, reads through `/usr/bin/security` first so repeated
201    /// helper rebuilds do not churn Keychain prompts against each
202    /// binary's CDHash. Backend/authorization failures are returned
203    /// directly instead of falling back to an in-process read path that
204    /// can trigger a second prompt.
205    pub fn get(&self, r: &SecretRef) -> Result<String, SecretError> {
206        platform_get(self, r)
207    }
208
209    /// Read a structured value previously stored via `put_json`.
210    pub fn get_json<T: for<'de> Deserialize<'de>>(&self, r: &SecretRef) -> Result<T, SecretError> {
211        let raw = self.get(r)?;
212        serde_json::from_str(&raw).map_err(|e| SecretError::InvalidJson(e.to_string()))
213    }
214
215    /// Delete an entry. Returns Ok even if the entry didn't exist — idempotent
216    /// from the caller's perspective.
217    ///
218    /// On macOS, deletes through `/usr/bin/security` first so the
219    /// Apple-signed helper, not the rebuilt caller binary, owns
220    /// Keychain authorization.
221    pub fn delete(&self, r: &SecretRef) -> Result<(), SecretError> {
222        platform_delete(self, r)
223    }
224
225    /// Existence check without returning the value. Safe to log.
226    ///
227    /// On macOS, checks status through `/usr/bin/security` first for
228    /// the same CDHash-stable authorization behavior as `get`.
229    pub fn status(&self, r: &SecretRef) -> Result<SecretStatus, SecretError> {
230        platform_status(self, r)
231    }
232
233    /// Reserved internal service name used for availability probing.
234    /// Consumers must not write user secrets under this service. Kept
235    /// in sync with `DEFAULT_SERVICE` ("car") so all CAR-owned
236    /// keychain entries share the `car-` prefix and a future cleanup
237    /// pass can sweep them with one wildcard.
238    const PROBE_SERVICE: &'static str = "car-internal";
239    const PROBE_KEY: &'static str = "__availability_probe__";
240
241    /// Probe whether the OS secret store is reachable.
242    ///
243    /// Opens an Entry for an internal-only sentinel and attempts to read
244    /// it. Returns `true` iff the backend responds with either a value or
245    /// `NoEntry` — both mean the store is reachable; `PlatformFailure` /
246    /// `NoStorageAccess` mean it isn't.
247    ///
248    /// # Side effects
249    ///
250    /// - On macOS with a locked keychain, this may trigger a user
251    ///   unlock prompt. Call only when the caller is ready to handle
252    ///   that UX.
253    /// - On Linux it opens a DBus connection to Secret Service.
254    /// - Performance: one round-trip to the OS store. Not cached.
255    pub fn is_available(&self) -> bool {
256        self.availability().available
257    }
258
259    /// Detailed availability probe. Same round-trip as `is_available`,
260    /// but distinguishes "no backend at all" from a specific platform
261    /// failure so the FFI surface can emit a `reason` matching the
262    /// pattern used by the other v0.4 capability probes
263    /// (`accountsList`, `calendarList`, etc.).
264    pub fn availability(&self) -> AvailabilityCheck {
265        // Reason is only populated when `available == false`. Reachable
266        // backends never carry a reason — callers can rely on
267        // `available && reason.is_none()` for happy-path branching.
268        let probe = SecretRef::new(Self::PROBE_SERVICE, Self::PROBE_KEY);
269        match self.entry(&probe) {
270            Ok(entry) => match entry.get_password() {
271                Ok(_) | Err(keyring::Error::NoEntry) => AvailabilityCheck {
272                    available: true,
273                    reason: None,
274                },
275                Err(keyring::Error::PlatformFailure(e)) => AvailabilityCheck {
276                    available: false,
277                    reason: Some(format!("platform failure: {e}")),
278                },
279                Err(keyring::Error::NoStorageAccess(e)) => AvailabilityCheck {
280                    available: false,
281                    reason: Some(format!("no storage access: {e}")),
282                },
283                // Other keyring errors (BadEncoding etc.) on the
284                // probe key indicate the backend responded but
285                // returned something unexpected. Treat as available
286                // so the caller can still try real ops; the failure
287                // mode shows up at the next put/get with proper
288                // typed error.
289                Err(_) => AvailabilityCheck {
290                    available: true,
291                    reason: None,
292                },
293            },
294            Err(SecretError::Unavailable(reason)) => AvailabilityCheck {
295                available: false,
296                reason: Some(reason),
297            },
298            Err(other) => AvailabilityCheck {
299                available: false,
300                reason: Some(other.to_string()),
301            },
302        }
303    }
304
305    fn entry(&self, r: &SecretRef) -> Result<Entry, SecretError> {
306        Entry::new(&r.service, &r.key).map_err(|e| classify(e, "entry"))
307    }
308}
309
310// ---------------------------------------------------------------------------
311// Platform-dispatched keychain operations.
312//
313// macOS: shell out to `/usr/bin/security` for reads, writes, status checks,
314// and deletes. The Apple-signed helper keeps Keychain authorization stable
315// across rebuilt CAR helper binaries whose CDHash changes. Writes also use
316// `-A` so the item itself is not bound to one transient debug binary.
317//
318// Other platforms: pass through to keyring (its native backends behave
319// correctly).
320// ---------------------------------------------------------------------------
321
322#[cfg(target_os = "macos")]
323fn platform_put(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
324    mac_put_via_security_cli(&r.service, &r.key, value)
325}
326
327#[cfg(not(target_os = "macos"))]
328fn platform_put(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
329    let entry = store.entry(r)?;
330    entry
331        .set_password(value)
332        .map_err(|e| classify(e, "set_password"))
333}
334
335#[cfg(target_os = "macos")]
336fn platform_get(_store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
337    mac_get_via_security_cli(r)
338}
339
340#[cfg(not(target_os = "macos"))]
341fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
342    let entry = store.entry(r)?;
343    match entry.get_password() {
344        Ok(v) => Ok(v),
345        Err(keyring::Error::NoEntry) => Err(SecretError::NotFound {
346            service: r.service.clone(),
347            key: r.key.clone(),
348        }),
349        Err(other) => Err(classify(other, "get_password")),
350    }
351}
352
353#[cfg(target_os = "macos")]
354fn platform_delete(_store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
355    mac_delete_via_security_cli(r)
356}
357
358#[cfg(not(target_os = "macos"))]
359fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
360    let entry = store.entry(r)?;
361    match entry.delete_credential() {
362        Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
363        Err(other) => Err(classify(other, "delete_credential")),
364    }
365}
366
367#[cfg(target_os = "macos")]
368fn platform_status(_store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
369    mac_status_via_security_cli(r)
370}
371
372#[cfg(not(target_os = "macos"))]
373fn platform_status(store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
374    let entry = store.entry(r)?;
375    let exists = match entry.get_password() {
376        Ok(_) => true,
377        Err(keyring::Error::NoEntry) => false,
378        Err(other) => return Err(classify(other, "status")),
379    };
380    Ok(SecretStatus {
381        service: r.service.clone(),
382        key: r.key.clone(),
383        exists,
384    })
385}
386
387/// Shell-out write with the `-A` flag (any-app ACL).
388///
389/// `service`/`account` are passed as separate argv tokens so shell
390/// metacharacters in either are inert. The value is the only argv slot
391/// that's a secret; document the trade-off at the call site.
392///
393/// Always issues `delete-generic-password` first (best-effort, errors
394/// ignored) so the subsequent `add-generic-password` creates a fresh
395/// keychain item with a fresh ACL. Without the pre-delete,
396/// `add-generic-password -U` would update the value in place but
397/// preserve any existing CDHash-bound ACL from a previous binary —
398/// causing the Apple-signed `/usr/bin/security` reader to be prompted
399/// for authorization on every subsequent `-g` retrieval. The `-U` flag
400/// is retained on `add` as a safety net for the (rare) case where
401/// delete returned non-zero non-NotFound and the entry is somehow
402/// still present.
403#[cfg(target_os = "macos")]
404fn mac_put_via_security_cli(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
405    mac_put_via_security_cli_with(service, account, value, &SystemSecurityCli)
406}
407
408#[cfg(target_os = "macos")]
409fn mac_put_via_security_cli_with(
410    service: &str,
411    account: &str,
412    value: &str,
413    cli: &impl SecurityCli,
414) -> Result<(), SecretError> {
415    // Best-effort delete: clears any pre-existing item so the add below
416    // installs a brand-new ACL via `-A`. Failures (including NotFound) are
417    // ignored — the add path handles the residual-item case via `-U`.
418    let _ = cli.output(&["delete-generic-password", "-s", service, "-a", account]);
419
420    let output = cli
421        .output(&[
422            "add-generic-password",
423            "-U", // safety net if the pre-delete didn't actually remove the item
424            "-A", // permissive ACL — any app can read
425            "-s",
426            service,
427            "-a",
428            account,
429            "-w",
430            value,
431        ])
432        .map_err(|e| security_cli_spawn_error("add-generic-password", e))?;
433    if output.success {
434        return Ok(());
435    }
436    Err(security_cli_backend_error("add-generic-password", output))
437}
438
439#[cfg(target_os = "macos")]
440const SECURITY_ERR_SEC_ITEM_NOT_FOUND: i32 = 44;
441
442#[cfg(target_os = "macos")]
443#[derive(Debug)]
444struct SecurityCliOutput {
445    success: bool,
446    code: Option<i32>,
447    stdout: Vec<u8>,
448    stderr: Vec<u8>,
449}
450
451#[cfg(target_os = "macos")]
452trait SecurityCli {
453    fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput>;
454}
455
456#[cfg(target_os = "macos")]
457struct SystemSecurityCli;
458
459#[cfg(target_os = "macos")]
460impl SecurityCli for SystemSecurityCli {
461    fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
462        use std::process::{Command, Stdio};
463        let output = Command::new("/usr/bin/security")
464            .args(args)
465            .stdout(Stdio::piped())
466            .stderr(Stdio::piped())
467            .output()?;
468        Ok(SecurityCliOutput {
469            success: output.status.success(),
470            code: output.status.code(),
471            stdout: output.stdout,
472            stderr: output.stderr,
473        })
474    }
475}
476
477/// Primary macOS value read:
478/// `/usr/bin/security find-generic-password -s SVC -a KEY -g`.
479/// `-g` prints the password metadata line to stderr and preserves the
480/// password bytes as hex when the value contains non-printable UTF-8
481/// bytes. Service/key are passed as separate argv values, never
482/// interpolated into a shell, so there's no injection surface even if a
483/// key contains shell metacharacters.
484#[cfg(target_os = "macos")]
485fn mac_get_via_security_cli(r: &SecretRef) -> Result<String, SecretError> {
486    mac_get_via_security_cli_with(r, &SystemSecurityCli)
487}
488
489#[cfg(target_os = "macos")]
490fn mac_get_via_security_cli_with(
491    r: &SecretRef,
492    cli: &impl SecurityCli,
493) -> Result<String, SecretError> {
494    let output = cli
495        .output(&[
496            "find-generic-password",
497            "-s",
498            &r.service,
499            "-a",
500            &r.key,
501            "-g",
502        ])
503        .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
504    if !output.success {
505        return security_cli_not_found_or_backend("find-generic-password", r, output);
506    }
507    mac_parse_security_cli_password(&output)
508}
509
510#[cfg(target_os = "macos")]
511fn mac_parse_security_cli_password(output: &SecurityCliOutput) -> Result<String, SecretError> {
512    let line = mac_security_cli_text(&output.stderr, "stderr")?
513        .lines()
514        .find(|line| line.starts_with("password:"))
515        .or_else(|| {
516            mac_security_cli_text(&output.stdout, "stdout")
517                .ok()
518                .and_then(|stdout| stdout.lines().find(|line| line.starts_with("password:")))
519        })
520        .ok_or_else(|| {
521            SecretError::Backend(
522                "/usr/bin/security find-generic-password -g did not print a password line"
523                    .to_string(),
524            )
525        })?;
526
527    let payload = line
528        .strip_prefix("password:")
529        .expect("password line prefix was checked")
530        .trim_start();
531
532    if payload.is_empty() {
533        return Ok(String::new());
534    }
535
536    let bytes = if let Some(hex_and_preview) = payload.strip_prefix("0x") {
537        mac_decode_security_cli_hex_password(hex_and_preview)?
538    } else {
539        mac_decode_security_cli_quoted_password(payload)?
540    };
541
542    String::from_utf8(bytes).map_err(|e| {
543        SecretError::Backend(format!(
544            "/usr/bin/security find-generic-password password was not valid utf-8: {}",
545            e
546        ))
547    })
548}
549
550#[cfg(target_os = "macos")]
551fn mac_security_cli_text<'a>(bytes: &'a [u8], stream: &str) -> Result<&'a str, SecretError> {
552    std::str::from_utf8(bytes).map_err(|e| {
553        SecretError::Backend(format!(
554            "/usr/bin/security find-generic-password {stream} was not valid utf-8: {e}"
555        ))
556    })
557}
558
559#[cfg(target_os = "macos")]
560fn mac_decode_security_cli_hex_password(hex_and_preview: &str) -> Result<Vec<u8>, SecretError> {
561    let hex: String = hex_and_preview
562        .chars()
563        .take_while(|c| c.is_ascii_hexdigit())
564        .collect();
565    if hex.is_empty() || hex.len() % 2 != 0 {
566        return Err(SecretError::Backend(format!(
567            "/usr/bin/security find-generic-password printed invalid password hex: {hex:?}"
568        )));
569    }
570
571    (0..hex.len())
572        .step_by(2)
573        .map(|i| {
574            u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| {
575                SecretError::Backend(format!(
576                    "/usr/bin/security find-generic-password printed invalid password hex: {e}"
577                ))
578            })
579        })
580        .collect()
581}
582
583#[cfg(target_os = "macos")]
584fn mac_decode_security_cli_quoted_password(payload: &str) -> Result<Vec<u8>, SecretError> {
585    let quoted = payload.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
586    match quoted {
587        Some(value) => Ok(value.as_bytes().to_vec()),
588        None => Err(SecretError::Backend(
589            "/usr/bin/security find-generic-password printed an unrecognized password line"
590                .to_string(),
591        )),
592    }
593}
594
595#[cfg(target_os = "macos")]
596fn mac_status_via_security_cli(r: &SecretRef) -> Result<SecretStatus, SecretError> {
597    mac_status_via_security_cli_with(r, &SystemSecurityCli)
598}
599
600#[cfg(target_os = "macos")]
601fn mac_status_via_security_cli_with(
602    r: &SecretRef,
603    cli: &impl SecurityCli,
604) -> Result<SecretStatus, SecretError> {
605    let exists = mac_exists_via_security_cli_with(r, cli)?;
606    Ok(SecretStatus {
607        service: r.service.clone(),
608        key: r.key.clone(),
609        exists,
610    })
611}
612
613/// Existence-only shell-out: `security find-generic-password -s SVC -a KEY`
614/// (no `-w`). Exit 0 means found, exit 44 means absent. Other non-zero
615/// exits are backend/authorization errors and must not fall through to
616/// an in-process API that can prompt again under the caller binary's CDHash.
617#[cfg(target_os = "macos")]
618fn mac_exists_via_security_cli_with(
619    r: &SecretRef,
620    cli: &impl SecurityCli,
621) -> Result<bool, SecretError> {
622    let output = cli
623        .output(&["find-generic-password", "-s", &r.service, "-a", &r.key])
624        .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
625    if output.success {
626        return Ok(true);
627    }
628    if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
629        return Ok(false);
630    }
631    Err(security_cli_backend_error("find-generic-password", output))
632}
633
634#[cfg(target_os = "macos")]
635fn mac_delete_via_security_cli(r: &SecretRef) -> Result<(), SecretError> {
636    mac_delete_via_security_cli_with(r, &SystemSecurityCli)
637}
638
639/// Primary macOS delete. Treats "no such item" as success to preserve
640/// the public idempotent delete contract.
641#[cfg(target_os = "macos")]
642fn mac_delete_via_security_cli_with(
643    r: &SecretRef,
644    cli: &impl SecurityCli,
645) -> Result<(), SecretError> {
646    let output = cli
647        .output(&["delete-generic-password", "-s", &r.service, "-a", &r.key])
648        .map_err(|e| security_cli_spawn_error("delete-generic-password", e))?;
649    if output.success || output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
650        return Ok(());
651    }
652    Err(security_cli_backend_error(
653        "delete-generic-password",
654        output,
655    ))
656}
657
658#[cfg(target_os = "macos")]
659fn security_cli_not_found_or_backend<T>(
660    command: &str,
661    r: &SecretRef,
662    output: SecurityCliOutput,
663) -> Result<T, SecretError> {
664    if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
665        return Err(SecretError::NotFound {
666            service: r.service.clone(),
667            key: r.key.clone(),
668        });
669    }
670    Err(security_cli_backend_error(command, output))
671}
672
673#[cfg(target_os = "macos")]
674fn security_cli_spawn_error(command: &str, e: std::io::Error) -> SecretError {
675    SecretError::Backend(format!("/usr/bin/security {command} spawn: {e}"))
676}
677
678#[cfg(target_os = "macos")]
679fn security_cli_backend_error(command: &str, output: SecurityCliOutput) -> SecretError {
680    let stderr = String::from_utf8_lossy(&output.stderr);
681    SecretError::Backend(format!(
682        "/usr/bin/security {command} failed: code={} {}",
683        output.code.unwrap_or(-1),
684        stderr.trim()
685    ))
686}
687
688/// Map keyring crate errors into our typed error set.
689fn classify(e: keyring::Error, op: &str) -> SecretError {
690    use keyring::Error as K;
691    match e {
692        K::NoEntry => SecretError::NotFound {
693            service: String::new(),
694            key: String::new(),
695        },
696        K::PlatformFailure(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
697        K::NoStorageAccess(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
698        K::BadEncoding(_) => SecretError::Backend(format!("{}: value encoding", op)),
699        other => SecretError::Backend(format!("{}: {}", op, other)),
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use serde::{Deserialize, Serialize};
707
708    // Tests use a unique service name per run to avoid colliding with any
709    // real credentials a developer has in their keychain. On headless Linux
710    // CI without a Secret Service daemon, these will return Unavailable; we
711    // skip in that case rather than fake success.
712    fn test_service() -> String {
713        format!(
714            "car-secrets-tests-{}-{}",
715            std::process::id(),
716            // Nanos since startup — good enough to isolate tests running
717            // in parallel inside one process.
718            std::time::SystemTime::now()
719                .duration_since(std::time::UNIX_EPOCH)
720                .map(|d| d.as_nanos())
721                .unwrap_or(0)
722        )
723    }
724
725    fn skip_if_unavailable() -> bool {
726        !SecretStore::new().is_available()
727    }
728
729    #[cfg(target_os = "macos")]
730    struct FakeSecurityCli {
731        outputs: std::cell::RefCell<std::collections::VecDeque<std::io::Result<SecurityCliOutput>>>,
732        calls: std::cell::RefCell<Vec<Vec<String>>>,
733    }
734
735    #[cfg(target_os = "macos")]
736    impl FakeSecurityCli {
737        fn new(outputs: Vec<std::io::Result<SecurityCliOutput>>) -> Self {
738            Self {
739                outputs: std::cell::RefCell::new(outputs.into()),
740                calls: std::cell::RefCell::new(Vec::new()),
741            }
742        }
743
744        fn calls(&self) -> Vec<Vec<String>> {
745            self.calls.borrow().clone()
746        }
747    }
748
749    #[cfg(target_os = "macos")]
750    impl SecurityCli for FakeSecurityCli {
751        fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
752            self.calls
753                .borrow_mut()
754                .push(args.iter().map(|arg| (*arg).to_string()).collect());
755            self.outputs
756                .borrow_mut()
757                .pop_front()
758                .expect("missing fake security output")
759        }
760    }
761
762    #[cfg(target_os = "macos")]
763    fn security_output(
764        code: i32,
765        stdout: impl Into<Vec<u8>>,
766        stderr: impl Into<Vec<u8>>,
767    ) -> std::io::Result<SecurityCliOutput> {
768        Ok(SecurityCliOutput {
769            success: code == 0,
770            code: Some(code),
771            stdout: stdout.into(),
772            stderr: stderr.into(),
773        })
774    }
775
776    #[cfg(target_os = "macos")]
777    fn args(values: &[&str]) -> Vec<String> {
778        values.iter().map(|value| (*value).to_string()).collect()
779    }
780
781    #[cfg(target_os = "macos")]
782    fn assert_backend_contains(err: SecretError, expected: &str) {
783        match err {
784            SecretError::Backend(message) => assert!(
785                message.contains(expected),
786                "expected backend error to contain {expected:?}, got {message:?}"
787            ),
788            other => panic!("expected Backend, got {:?}", other),
789        }
790    }
791
792    #[test]
793    fn roundtrip_string() {
794        if skip_if_unavailable() {
795            eprintln!("skipping: no secret store backend available");
796            return;
797        }
798        let store = SecretStore::new();
799        let svc = test_service();
800        let r = SecretRef::new(&svc, "roundtrip");
801        store.put(&r, "hello world").unwrap();
802        assert_eq!(store.get(&r).unwrap(), "hello world");
803        assert!(store.status(&r).unwrap().exists);
804        store.delete(&r).unwrap();
805        assert!(!store.status(&r).unwrap().exists);
806    }
807
808    #[test]
809    fn roundtrip_string_with_trailing_newline() {
810        if skip_if_unavailable() {
811            eprintln!("skipping: no secret store backend available");
812            return;
813        }
814        let store = SecretStore::new();
815        let svc = test_service();
816        let r = SecretRef::new(&svc, "roundtrip-newline");
817        let value = "abc\n";
818        store.put(&r, value).unwrap();
819        assert_eq!(store.get(&r).unwrap(), value);
820        store.delete(&r).unwrap();
821    }
822
823    #[test]
824    fn get_missing_returns_not_found() {
825        if skip_if_unavailable() {
826            return;
827        }
828        let store = SecretStore::new();
829        let r = SecretRef::new(test_service(), "never_written");
830        match store.get(&r) {
831            Err(SecretError::NotFound { .. }) => (),
832            other => panic!("expected NotFound, got {:?}", other),
833        }
834    }
835
836    #[test]
837    fn delete_missing_is_idempotent() {
838        if skip_if_unavailable() {
839            return;
840        }
841        let store = SecretStore::new();
842        let r = SecretRef::new(test_service(), "missing");
843        // Two deletes in a row should both succeed.
844        store.delete(&r).unwrap();
845        store.delete(&r).unwrap();
846    }
847
848    #[test]
849    fn json_roundtrip() {
850        if skip_if_unavailable() {
851            return;
852        }
853        #[derive(Serialize, Deserialize, PartialEq, Debug)]
854        struct Session {
855            cookies: Vec<String>,
856            expires_at: i64,
857        }
858        let store = SecretStore::new();
859        let svc = test_service();
860        let r = SecretRef::new(&svc, "session");
861        let s = Session {
862            cookies: vec!["a=1".into(), "b=2".into()],
863            expires_at: 1_700_000_000,
864        };
865        store.put_json(&r, &s).unwrap();
866        let back: Session = store.get_json(&r).unwrap();
867        assert_eq!(back, s);
868        store.delete(&r).unwrap();
869    }
870
871    #[test]
872    fn status_no_leak() {
873        if skip_if_unavailable() {
874            return;
875        }
876        let store = SecretStore::new();
877        let r = SecretRef::new(test_service(), "status");
878        store.put(&r, "secret-payload").unwrap();
879        let st = store.status(&r).unwrap();
880        // Status intentionally does not carry the value.
881        let encoded = serde_json::to_string(&st).unwrap();
882        assert!(!encoded.contains("secret-payload"));
883        store.delete(&r).unwrap();
884    }
885
886    #[cfg(target_os = "macos")]
887    #[test]
888    fn mac_get_uses_security_cli_and_maps_success() {
889        let cli = FakeSecurityCli::new(vec![security_output(
890            0,
891            b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
892            b"password: \"secret\"\n",
893        )]);
894        let r = SecretRef::new("svc", "key");
895
896        assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "secret");
897        assert_eq!(
898            cli.calls(),
899            vec![args(&[
900                "find-generic-password",
901                "-s",
902                "svc",
903                "-a",
904                "key",
905                "-g"
906            ])]
907        );
908    }
909
910    #[cfg(target_os = "macos")]
911    #[test]
912    fn mac_get_decodes_hex_password_output_with_trailing_newline() {
913        let cli = FakeSecurityCli::new(vec![security_output(
914            0,
915            b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
916            b"password: 0x6162630A  \"abc\\012\"\n",
917        )]);
918        let r = SecretRef::new("svc", "key");
919
920        assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "abc\n");
921        assert_eq!(
922            cli.calls(),
923            vec![args(&[
924                "find-generic-password",
925                "-s",
926                "svc",
927                "-a",
928                "key",
929                "-g"
930            ])]
931        );
932    }
933
934    #[cfg(target_os = "macos")]
935    #[test]
936    fn mac_get_maps_not_found_and_backend_errors_without_fallback() {
937        let r = SecretRef::new("svc", "missing");
938        let cli = FakeSecurityCli::new(vec![security_output(
939            SECURITY_ERR_SEC_ITEM_NOT_FOUND,
940            b"",
941            b"The specified item could not be found in the keychain.\n",
942        )]);
943
944        match mac_get_via_security_cli_with(&r, &cli) {
945            Err(SecretError::NotFound { service, key }) => {
946                assert_eq!(service, "svc");
947                assert_eq!(key, "missing");
948            }
949            other => panic!("expected NotFound, got {:?}", other),
950        }
951        assert_eq!(cli.calls().len(), 1);
952
953        let cli = FakeSecurityCli::new(vec![security_output(
954            51,
955            b"",
956            b"User interaction is not allowed.\n",
957        )]);
958        let err = mac_get_via_security_cli_with(&r, &cli).unwrap_err();
959        assert_backend_contains(err, "code=51 User interaction is not allowed.");
960        assert_eq!(cli.calls().len(), 1);
961    }
962
963    #[cfg(target_os = "macos")]
964    #[test]
965    fn mac_status_uses_security_cli_and_maps_results() {
966        let r = SecretRef::new("svc", "key");
967        let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
968
969        let status = mac_status_via_security_cli_with(&r, &cli).unwrap();
970        assert!(status.exists);
971        assert_eq!(
972            cli.calls(),
973            vec![args(&["find-generic-password", "-s", "svc", "-a", "key"])]
974        );
975
976        let cli = FakeSecurityCli::new(vec![security_output(
977            SECURITY_ERR_SEC_ITEM_NOT_FOUND,
978            b"",
979            b"The specified item could not be found in the keychain.\n",
980        )]);
981        assert!(!mac_status_via_security_cli_with(&r, &cli).unwrap().exists);
982
983        let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
984        let err = mac_status_via_security_cli_with(&r, &cli).unwrap_err();
985        assert_backend_contains(err, "code=128 auth denied");
986    }
987
988    #[cfg(target_os = "macos")]
989    #[test]
990    fn mac_put_pre_deletes_then_adds_so_acl_is_fresh() {
991        // Regression for the "3 prompts on car-server startup" bug. Before
992        // this fix `mac_put_via_security_cli` issued only
993        // `add-generic-password -U -A`, which updates the value but
994        // preserves any pre-existing ACL — so items first written by an
995        // older binary stayed CDHash-bound forever and `find-generic-password -g`
996        // prompted on every read. The fix: best-effort delete first, then add.
997        let cli = FakeSecurityCli::new(vec![
998            // Pre-delete returns NotFound — that's fine, ignored.
999            security_output(
1000                SECURITY_ERR_SEC_ITEM_NOT_FOUND,
1001                b"",
1002                b"The specified item could not be found in the keychain.\n",
1003            ),
1004            // Add succeeds.
1005            security_output(0, b"", b""),
1006        ]);
1007
1008        mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
1009
1010        assert_eq!(
1011            cli.calls(),
1012            vec![
1013                args(&["delete-generic-password", "-s", "svc", "-a", "key"]),
1014                args(&[
1015                    "add-generic-password",
1016                    "-U",
1017                    "-A",
1018                    "-s",
1019                    "svc",
1020                    "-a",
1021                    "key",
1022                    "-w",
1023                    "secret",
1024                ]),
1025            ]
1026        );
1027    }
1028
1029    #[cfg(target_os = "macos")]
1030    #[test]
1031    fn mac_put_ignores_pre_delete_failure_and_still_adds() {
1032        // If the pre-delete shells back a non-NotFound non-zero (e.g.
1033        // transient backend error), we still attempt the add — `-U` is the
1034        // safety net that lets us update the value even if the old item is
1035        // somehow still around.
1036        let cli = FakeSecurityCli::new(vec![
1037            security_output(128, b"", b"some weird backend error\n"),
1038            security_output(0, b"", b""),
1039        ]);
1040
1041        mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
1042
1043        assert_eq!(cli.calls().len(), 2);
1044        assert_eq!(
1045            cli.calls()[1],
1046            args(&[
1047                "add-generic-password",
1048                "-U",
1049                "-A",
1050                "-s",
1051                "svc",
1052                "-a",
1053                "key",
1054                "-w",
1055                "secret",
1056            ])
1057        );
1058    }
1059
1060    #[cfg(target_os = "macos")]
1061    #[test]
1062    fn mac_put_surfaces_add_failure_as_backend_error() {
1063        let cli = FakeSecurityCli::new(vec![
1064            security_output(0, b"", b""),
1065            security_output(51, b"", b"User interaction is not allowed.\n"),
1066        ]);
1067
1068        let err = mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap_err();
1069        assert_backend_contains(err, "code=51 User interaction is not allowed.");
1070    }
1071
1072    #[cfg(target_os = "macos")]
1073    #[test]
1074    fn mac_delete_uses_security_cli_and_maps_results() {
1075        let r = SecretRef::new("svc", "key");
1076        let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
1077
1078        mac_delete_via_security_cli_with(&r, &cli).unwrap();
1079        assert_eq!(
1080            cli.calls(),
1081            vec![args(&["delete-generic-password", "-s", "svc", "-a", "key"])]
1082        );
1083
1084        let cli = FakeSecurityCli::new(vec![security_output(
1085            SECURITY_ERR_SEC_ITEM_NOT_FOUND,
1086            b"",
1087            b"The specified item could not be found in the keychain.\n",
1088        )]);
1089        mac_delete_via_security_cli_with(&r, &cli).unwrap();
1090
1091        let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
1092        let err = mac_delete_via_security_cli_with(&r, &cli).unwrap_err();
1093        assert_backend_contains(err, "code=128 auth denied");
1094    }
1095}