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`, `publish`, `get`, `delete`, `status`,
11//! `list`. Callers choose a namespace (`service`) and a key (`account`); values
12//! are 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
28// The in-process keyring path is every platform except macOS. macOS routes
29// every operation — reads, writes, status, deletes, and the availability
30// probe — through `/usr/bin/security` instead, so nothing there opens an
31// `Entry` (Parslee-ai/car#897).
32#[cfg(not(target_os = "macos"))]
33use keyring::Entry;
34use serde::{Deserialize, Serialize};
35use thiserror::Error;
36
37pub mod secure_path;
38pub use secure_path::{
39    atomic_replace_private_file, create_private_file, create_private_file_with_failure_injector,
40    ensure_private_dir, ensure_private_dir_with_failure_injector, harden_owner_only,
41    harden_owner_only_fallible, harden_private_tree, open_private_append,
42    open_private_append_with_failure_injector, open_private_read, open_private_truncate,
43    revalidate_private_file, revalidate_private_path, PrivatePathDurabilityFailureInjector,
44    PrivatePathDurabilityFailurePoint, PrivateTree, PrivateTreePolicy, PrivateTreeReport,
45};
46
47/// Default service (namespace) used when callers don't supply one.
48///
49/// `"car"` is the per-app namespace shared by every CAR component
50/// (`car-cli`, `car-inference` model-key fallback, FFI bindings, WebSocket
51/// `secret.*` methods). One shared bucket means `car secrets put OPENAI_API_KEY`
52/// stores the same entry that `car-inference` reads at runtime — no namespace
53/// translation in users' heads.
54///
55/// Pre-v0.5.2 this was `"car-runtime"`. The rename was a one-time UX change;
56/// any keychain entries written before that date live under the old service
57/// name and need to be migrated (or just `car secrets put` again).
58pub const DEFAULT_SERVICE: &str = "car";
59
60/// Daemon-owned connection credentials and proof metadata. The raw
61/// [`SecretStore`] remains able to lease/delete these entries internally;
62/// generic CLI/FFI/RPC wrappers must reject access to the root slots and any
63/// platform-derived chunk entries.
64pub const OPENROUTER_OAUTH_KEY: &str = "OPENROUTER_OAUTH_API_KEY";
65pub const PARSLEE_ACCESS_TOKEN_KEY: &str = "PARSLEE_ACCESS_TOKEN";
66pub const PARSLEE_REFRESH_TOKEN_KEY: &str = "PARSLEE_REFRESH_TOKEN";
67pub const PARSLEE_EXPIRES_AT_KEY: &str = "PARSLEE_ACCESS_TOKEN_EXPIRES_AT";
68pub const PARSLEE_API_BASE_KEY: &str = "PARSLEE_API_BASE";
69pub const PARSLEE_ACCOUNTS_KEY: &str = "PARSLEE_ACCOUNTS";
70pub const PARSLEE_TOKENS_PREFIX: &str = "PARSLEE_TOKENS_";
71pub const PARSLEE_AUTH_GENERATION_KEY: &str = "PARSLEE_AUTH_GENERATION";
72pub const PARSLEE_AUTH_COMPLETION_KEY: &str = "PARSLEE_AUTH_COMPLETION";
73pub const PARSLEE_ACTIVE_ACCOUNT_ID_KEY: &str = "PARSLEE_ACTIVE_ACCOUNT_ID";
74pub const PARSLEE_AUTH_STATE_V2_KEY: &str = "PARSLEE_AUTH_STATE_V2";
75
76fn is_private_chunk_derivative(key: &str, root: &str) -> bool {
77    key.strip_prefix(root)
78        .is_some_and(|suffix| suffix.starts_with("#chunk"))
79}
80
81pub fn is_daemon_private_secret(service: &str, key: &str) -> bool {
82    service == DEFAULT_SERVICE
83        && (matches!(
84            key,
85            OPENROUTER_OAUTH_KEY
86                | PARSLEE_ACCESS_TOKEN_KEY
87                | PARSLEE_REFRESH_TOKEN_KEY
88                | PARSLEE_EXPIRES_AT_KEY
89                | PARSLEE_API_BASE_KEY
90                | PARSLEE_ACCOUNTS_KEY
91                | PARSLEE_AUTH_GENERATION_KEY
92                | PARSLEE_AUTH_COMPLETION_KEY
93                | PARSLEE_ACTIVE_ACCOUNT_ID_KEY
94                | PARSLEE_AUTH_STATE_V2_KEY
95        ) || key.starts_with(PARSLEE_TOKENS_PREFIX)
96            || [
97                OPENROUTER_OAUTH_KEY,
98                PARSLEE_ACCESS_TOKEN_KEY,
99                PARSLEE_REFRESH_TOKEN_KEY,
100                PARSLEE_EXPIRES_AT_KEY,
101                PARSLEE_API_BASE_KEY,
102                PARSLEE_ACCOUNTS_KEY,
103                PARSLEE_AUTH_GENERATION_KEY,
104                PARSLEE_AUTH_COMPLETION_KEY,
105                PARSLEE_ACTIVE_ACCOUNT_ID_KEY,
106                PARSLEE_AUTH_STATE_V2_KEY,
107            ]
108            .iter()
109            .any(|root| is_private_chunk_derivative(key, root)))
110}
111
112/// Resolve a raw key value for `env_var` from the standard CAR
113/// sources, in priority order:
114///
115/// 1. **Process env var** — `std::env::var(env_var)`. Wins
116///    everything (containers, CI, K8s pods, systemd units).
117///    `~/.car/env` is loaded into the process env at server
118///    startup, so file-based config flows through this path too.
119/// 2. **OS keychain via [`SecretStore`]** — looked up under
120///    [`DEFAULT_SERVICE`] = `"car"` with account = `env_var`.
121///    Skipped silently when [`SecretStore::is_available`] is
122///    false so we never wake pinentry on a locked desktop or
123///    dial DBus on a headless Linux box.
124/// 3. **Missing** — returns `None`.
125///
126/// This is the single source of truth for CAR's API-key
127/// resolution. Every call site that wants "env first, then
128/// keychain" should go through here so the priority can't drift
129/// (`car-inference::key_pool`, `car-voice::elevenlabs_*`, and
130/// any future remote backend land here, not on their own
131/// re-implementation).
132pub fn resolve_env_or_keychain(env_var: &str) -> Option<String> {
133    if let Ok(v) = std::env::var(env_var) {
134        if !v.is_empty() {
135            return Some(v);
136        }
137    }
138    let store = SecretStore::new();
139    if !store.is_available() {
140        return None;
141    }
142    let secret_ref = SecretRef::new(DEFAULT_SERVICE, env_var);
143    match store.get(&secret_ref) {
144        Ok(v) if !v.is_empty() => {
145            tracing::debug!(env_var = %env_var, "resolved API key from OS keychain");
146            Some(v)
147        }
148        Ok(_) => None, // empty value — treat as missing
149        Err(SecretError::NotFound { .. }) => None,
150        Err(e) => {
151            tracing::warn!(env_var = %env_var, error = %e, "keychain lookup failed");
152            None
153        }
154    }
155}
156
157/// Errors the secret store can produce.
158#[derive(Debug, Error)]
159pub enum SecretError {
160    /// No OS backend is available (e.g. headless Linux with no Secret
161    /// Service daemon, or a keychain that refused to unlock).
162    #[error("secret store unavailable: {0}")]
163    Unavailable(String),
164
165    /// The requested entry does not exist.
166    #[error("no entry for service={service:?} key={key:?}")]
167    NotFound { service: String, key: String },
168
169    /// The OS credential store refused access to an existing item.
170    #[error("secret store access denied: {message}")]
171    AccessDenied { message: String },
172
173    /// The user dismissed the OS credential prompt without granting access.
174    #[error("secret store access cancelled: {message}")]
175    UserCancelled { message: String },
176
177    /// The bounded OS credential helper did not complete before its deadline.
178    #[error("secret store helper timed out during {operation}")]
179    HelperTimedOut { operation: String },
180
181    /// An OS-native error the store couldn't classify — usually surfaced
182    /// verbatim from the underlying keychain API.
183    #[error("secret store error: {0}")]
184    Backend(String),
185
186    /// A JSON helper was used but the stored value wasn't valid JSON.
187    #[error("stored value is not valid JSON: {0}")]
188    InvalidJson(String),
189}
190
191/// Status of an entry — no value data, safe to log.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct SecretStatus {
194    pub service: String,
195    pub key: String,
196    pub exists: bool,
197}
198
199/// Result of `SecretStore::availability` — `available` mirrors what
200/// `is_available` returns, and `reason` carries the platform-specific
201/// detail (e.g. "no Secret Service daemon", "keychain locked") so the
202/// FFI surface can report an actionable message instead of a bare
203/// boolean.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct AvailabilityCheck {
206    pub available: bool,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub reason: Option<String>,
209}
210
211/// Process-lifetime counts of secret-store operation attempts.
212///
213/// This is deliberately aggregate-only: it contains no service, key,
214/// credential identity, filesystem path, or secret value.
215#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
216pub struct SecretStoreActivity {
217    pub get_attempts: u64,
218    pub status_attempts: u64,
219    pub availability_attempts: u64,
220    pub write_attempts: u64,
221    pub delete_attempts: u64,
222}
223
224static GET_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
225static STATUS_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
226static AVAILABILITY_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
227static WRITE_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
228static DELETE_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
229
230/// Snapshot the process-lifetime aggregate secret-store counters.
231pub fn secret_store_activity() -> SecretStoreActivity {
232    use std::sync::atomic::Ordering;
233
234    SecretStoreActivity {
235        get_attempts: GET_ATTEMPTS.load(Ordering::Relaxed),
236        status_attempts: STATUS_ATTEMPTS.load(Ordering::Relaxed),
237        availability_attempts: AVAILABILITY_ATTEMPTS.load(Ordering::Relaxed),
238        write_attempts: WRITE_ATTEMPTS.load(Ordering::Relaxed),
239        delete_attempts: DELETE_ATTEMPTS.load(Ordering::Relaxed),
240    }
241}
242
243/// Logical handle for a secret — (service, key) pair.
244#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
245pub struct SecretRef {
246    pub service: String,
247    pub key: String,
248}
249
250impl SecretRef {
251    pub fn new(service: impl Into<String>, key: impl Into<String>) -> Self {
252        Self {
253            service: service.into(),
254            key: key.into(),
255        }
256    }
257
258    pub fn with_default_service(key: impl Into<String>) -> Self {
259        Self {
260            service: DEFAULT_SERVICE.to_string(),
261            key: key.into(),
262        }
263    }
264}
265
266/// Cross-platform secret store backed by the host OS keychain.
267///
268/// Stateless by design — it holds no cached secrets. Every call round-trips
269/// to the OS. That makes concurrent usage safe and avoids any in-process
270/// leak surface beyond the immediate call's return value.
271#[derive(Debug, Default, Clone, Copy)]
272pub struct SecretStore;
273
274impl SecretStore {
275    pub fn new() -> Self {
276        Self
277    }
278
279    /// Store a UTF-8 secret under `(service, key)`. Replaces any existing
280    /// value at the same ref.
281    ///
282    /// On macOS, writes via `/usr/bin/security add-generic-password -U -A`
283    /// so the resulting item has a permissive ACL — readable by any
284    /// binary the user runs. This is necessary because the legacy
285    /// keychain's default ACL binds an item to the calling binary's
286    /// code-signing hash, which changes on every cargo rebuild and
287    /// silently revokes read access from later versions of the same
288    /// CLI tool. (`/usr/bin/security` is Apple-signed with full
289    /// keychain entitlements — the same path reads, status checks,
290    /// and deletes use, and the same path users invoke manually.)
291    ///
292    /// Trade-off: the value transits argv during the spawn (visible to
293    /// `ps` from the same user for ~milliseconds). Acceptable for the
294    /// "single-user developer machine" threat model; any process that
295    /// can see argv on this machine can also read the keychain
296    /// directly via `security`. On other platforms, behavior is
297    /// unchanged (keyring crate's native backend).
298    pub fn put(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
299        WRITE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
300        platform_put(self, r, value)
301    }
302
303    /// Publish one authoritative value without exposing a partially-published
304    /// replacement to readers.
305    ///
306    /// This is deliberately separate from [`Self::put`]. Parslee auth stores
307    /// its entire active credential transaction in one JSON record and uses
308    /// this method as the commit point. macOS updates the existing Keychain item
309    /// in place (no pre-delete gap), the debug file backend renames an
310    /// owner-private staging file, and Windows stages a revision-tagged
311    /// inactive chunk generation before committing the root sentinel last.
312    /// Windows retains the prior generation for concurrent readers and uses
313    /// deterministic A/B slots plus high-water manifests so crashes cannot
314    /// grow Credential Manager entry cardinality without bound.
315    ///
316    /// Multiple publishers for the same ref must be serialized by the caller.
317    /// Parslee auth does this with its coordinator lock; readers may run
318    /// concurrently with a publisher.
319    pub fn publish(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
320        WRITE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
321        platform_publish(self, r, value)
322    }
323
324    /// Store a structured value serialized as JSON.
325    pub fn put_json<T: Serialize>(&self, r: &SecretRef, value: &T) -> Result<(), SecretError> {
326        let s = serde_json::to_string(value)
327            .map_err(|e| SecretError::Backend(format!("serialize: {}", e)))?;
328        self.put(r, &s)
329    }
330
331    /// Read a UTF-8 secret. Returns `NotFound` if no entry exists.
332    ///
333    /// On macOS, reads through `/usr/bin/security` first so repeated
334    /// helper rebuilds do not churn Keychain prompts against each
335    /// binary's CDHash. Backend/authorization failures are returned
336    /// directly instead of falling back to an in-process read path that
337    /// can trigger a second prompt.
338    pub fn get(&self, r: &SecretRef) -> Result<String, SecretError> {
339        GET_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
340        platform_get(self, r)
341    }
342
343    /// Read a structured value previously stored via `put_json`.
344    pub fn get_json<T: for<'de> Deserialize<'de>>(&self, r: &SecretRef) -> Result<T, SecretError> {
345        let raw = self.get(r)?;
346        serde_json::from_str(&raw).map_err(|e| SecretError::InvalidJson(e.to_string()))
347    }
348
349    /// Delete an entry. Returns Ok even if the entry didn't exist — idempotent
350    /// from the caller's perspective.
351    ///
352    /// On macOS, deletes through `/usr/bin/security` first so the
353    /// Apple-signed helper, not the rebuilt caller binary, owns
354    /// Keychain authorization.
355    pub fn delete(&self, r: &SecretRef) -> Result<(), SecretError> {
356        DELETE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
357        platform_delete(self, r)
358    }
359
360    /// Existence check without returning the value. Safe to log.
361    ///
362    /// On macOS, checks status through `/usr/bin/security` first for
363    /// the same CDHash-stable authorization behavior as `get`.
364    pub fn status(&self, r: &SecretRef) -> Result<SecretStatus, SecretError> {
365        STATUS_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
366        platform_status(self, r)
367    }
368
369    /// Reserved internal service name used for availability probing.
370    /// Consumers must not write user secrets under this service. Kept
371    /// in sync with `DEFAULT_SERVICE` ("car") so all CAR-owned
372    /// keychain entries share the `car-` prefix and a future cleanup
373    /// pass can sweep them with one wildcard.
374    const PROBE_SERVICE: &'static str = "car-internal";
375    const PROBE_KEY: &'static str = "__availability_probe__";
376
377    /// Probe whether the OS secret store is reachable.
378    ///
379    /// Opens an Entry for an internal-only sentinel and attempts to read
380    /// it. Returns `true` iff the backend responds with either a value or
381    /// `NoEntry` — both mean the store is reachable; `PlatformFailure` /
382    /// `NoStorageAccess` mean it isn't.
383    ///
384    /// # Side effects
385    ///
386    /// - On macOS the probe is an existence-only `/usr/bin/security`
387    ///   call — no `-g`, so it reads no password bytes and cannot raise
388    ///   an authorization dialog — and it inherits that helper's
389    ///   deadline. A locked keychain can still make it wait, but it is
390    ///   bounded and it names itself in the log while it waits.
391    /// - On Linux it opens a DBus connection to Secret Service.
392    /// - Performance: one round-trip to the OS store. Not cached.
393    pub fn is_available(&self) -> bool {
394        self.availability().available
395    }
396
397    /// Detailed availability probe. Same round-trip as `is_available`,
398    /// but distinguishes "no backend at all" from a specific platform
399    /// failure so the FFI surface can emit a `reason` matching the
400    /// pattern used by the other v0.4 capability probes
401    /// (`accountsList`, `calendarList`, etc.).
402    pub fn availability(&self) -> AvailabilityCheck {
403        AVAILABILITY_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
404        // Reason is only populated when `available == false`. Reachable
405        // backends never carry a reason — callers can rely on
406        // `available && reason.is_none()` for happy-path branching.
407        // The opt-in file backend (test/headless redirect) is always
408        // "available" — it is just the local filesystem.
409        if file_backend_dir().is_some() {
410            return AvailabilityCheck {
411                available: true,
412                reason: None,
413            };
414        }
415        platform_availability(self)
416    }
417
418    /// Not compiled on macOS — and that gate is the regression barrier here,
419    /// not a test. Every macOS operation goes through `/usr/bin/security`, so
420    /// re-routing one of them back through the in-process keyring path would
421    /// reach for `self.entry` and fail to compile. Worth stating because a
422    /// test cannot cover it: the tests below inject a fake `SecurityCli`, so
423    /// they exercise the helper and say nothing about which body
424    /// `platform_availability` dispatches to.
425    ///
426    /// The gate is soft in exactly one way — a fully-qualified
427    /// `keyring::Entry::new(...)` still compiles on macOS, because `keyring`
428    /// is an unconditional dependency. That is a deliberate re-introduction,
429    /// not a regression something could slip into.
430    #[cfg(not(target_os = "macos"))]
431    fn entry(&self, r: &SecretRef) -> Result<Entry, SecretError> {
432        Entry::new(&r.service, &r.key).map_err(|e| classify(e, "entry"))
433    }
434}
435
436// ---------------------------------------------------------------------------
437// Platform-dispatched keychain operations.
438//
439// macOS: shell out to `/usr/bin/security` for reads, writes, status checks,
440// deletes, and the availability probe. The Apple-signed helper keeps Keychain
441// authorization stable across rebuilt CAR helper binaries whose CDHash
442// changes. Writes also use `-A` so the item itself is not bound to one
443// transient debug binary.
444//
445// Other platforms: pass through to keyring (its native backends behave
446// correctly).
447// ---------------------------------------------------------------------------
448
449/// Test/headless redirect: when `CAR_SECRETS_FILE_DIR` names a directory, the
450/// store is backed by plaintext files there instead of the OS keychain. This is
451/// the SAME env-keyed-redirect idiom as `resolve_env_or_keychain`'s
452/// process-env precedence — a no-op in production (the daemon never sets this
453/// var), but it lets tests drive the real `put`/`get`/`delete` code path WITHOUT
454/// the macOS keychain's interactive-authorization prompt (which cancels
455/// unattended, `code=154`). Each secret lands at `<dir>/<service>.<key>`.
456///
457/// SECURITY: plaintext on disk — acceptable ONLY because this is opt-in via an
458/// env var production never sets. The keychain remains the sole production
459/// backing store.
460///
461/// Two hard guards make a production leak structurally impossible:
462///
463/// 1. **Release builds refuse it entirely.** The redirect is honored ONLY under
464///    `cfg!(debug_assertions)` (debug/test builds). A RELEASE binary — which is
465///    what production ships — returns `None` even when the env var is set, so a
466///    stray `CAR_SECRETS_FILE_DIR` can never route real secrets to plaintext in
467///    prod.
468/// 2. **First engagement warns loudly.** The first time the redirect is honored
469///    in a process, a one-time `tracing::warn!` fires so a misconfigured dev /
470///    CI run is visible, not silent.
471fn file_backend_dir() -> Option<std::path::PathBuf> {
472    // Release builds (production) NEVER honor the redirect — secrets always go
473    // to the OS keychain. The env var is a debug/test-only seam.
474    if !cfg!(debug_assertions) {
475        return None;
476    }
477    match std::env::var_os("CAR_SECRETS_FILE_DIR") {
478        Some(d) if !d.is_empty() => {
479            // One-time loud warning the first time the plaintext file backend
480            // engages in this process.
481            static WARNED: std::sync::Once = std::sync::Once::new();
482            WARNED.call_once(|| {
483                tracing::warn!(
484                    "CAR_SECRETS_FILE_DIR set — secrets are PLAINTEXT ON DISK; \
485                     test-only, never production"
486                );
487            });
488            Some(std::path::PathBuf::from(d))
489        }
490        _ => None,
491    }
492}
493
494fn file_backend_path(dir: &std::path::Path, r: &SecretRef) -> std::path::PathBuf {
495    // Sanitize path separators so a service/key never escapes the dir.
496    let sanitize = |s: &str| s.replace(['/', '\\', '.'], "_");
497    dir.join(format!("{}.{}", sanitize(&r.service), sanitize(&r.key)))
498}
499
500fn file_backend_put(dir: &std::path::Path, r: &SecretRef, value: &str) -> Result<(), SecretError> {
501    std::fs::create_dir_all(dir)
502        .map_err(|e| SecretError::Backend(format!("file backend mkdir: {e}")))?;
503    std::fs::write(file_backend_path(dir, r), value)
504        .map_err(|e| SecretError::Backend(format!("file backend write: {e}")))
505}
506
507fn file_backend_publish(
508    dir: &std::path::Path,
509    r: &SecretRef,
510    value: &str,
511) -> Result<(), SecretError> {
512    use std::io::Write;
513
514    std::fs::create_dir_all(dir)
515        .map_err(|e| SecretError::Backend(format!("file backend mkdir: {e}")))?;
516    let destination = file_backend_path(dir, r);
517    let nonce = publication_nonce();
518    let staging = destination.with_extension(format!("stage-{nonce}"));
519    let mut options = std::fs::OpenOptions::new();
520    options.create_new(true).write(true);
521    #[cfg(unix)]
522    {
523        use std::os::unix::fs::OpenOptionsExt;
524        options.mode(0o600);
525    }
526    let mut file = options
527        .open(&staging)
528        .map_err(|e| SecretError::Backend(format!("file backend stage: {e}")))?;
529    file.write_all(value.as_bytes())
530        .and_then(|_| file.sync_all())
531        .map_err(|e| SecretError::Backend(format!("file backend stage write: {e}")))?;
532    drop(file);
533    if let Err(error) = std::fs::rename(&staging, &destination) {
534        let _ = std::fs::remove_file(&staging);
535        return Err(SecretError::Backend(format!(
536            "file backend publish rename: {error}"
537        )));
538    }
539    Ok(())
540}
541
542/// Whether a `NotFound` from an operation under `dir` means "this entry is
543/// absent" rather than "this store is unusable".
544///
545/// The two are the same errno on one platform and not on the other. With a
546/// REGULAR FILE configured where the secrets directory should be, opening an
547/// entry under it fails with `ENOTDIR` on unix — which is not `NotFound`, so it
548/// surfaced as a backend error — but with `ERROR_PATH_NOT_FOUND` on Windows,
549/// which Rust maps straight to `io::ErrorKind::NotFound`. The store then
550/// answered "that secret does not exist" for a store it could not read at all.
551///
552/// The consequence was not cosmetic. `car_auth`'s credential resolution treats
553/// `NotFound` as "no V2 record yet" and runs a legacy-migration sweep — five
554/// further reads of the same broken store — instead of failing on the first one
555/// and entering keychain cooldown (Parslee-ai/car#1014). `delete` reported
556/// success on a store it never touched, and `status` reported `exists: false`
557/// rather than admitting it could not tell.
558///
559/// The discriminator is whether the configured path is something OTHER than a
560/// directory, and it is the same question on every platform.
561///
562/// Note the second arm: a directory that does not exist YET is not a broken
563/// store. Only `put`/`publish` call `create_dir_all`, so on a first run — before
564/// anything has been written — the path is legitimately absent, and a `get`
565/// there means "no secret yet", exactly as it always has. Treating that as a
566/// backend error would send `car_auth` into keychain cooldown on a fresh
567/// install instead of reporting "not signed in".
568fn file_backend_entry_is_merely_absent(dir: &std::path::Path) -> bool {
569    match std::fs::metadata(dir) {
570        Ok(metadata) => metadata.is_dir(),
571        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
572            // A genuinely absent directory is a normal first run, but Windows
573            // also reports NotFound when an ancestor is a regular file. Walk to
574            // the nearest existing ancestor and require it to be a directory;
575            // any other metadata error means "could not inspect", not "absent".
576            for ancestor in dir.ancestors().skip(1) {
577                match std::fs::metadata(ancestor) {
578                    Ok(metadata) => return metadata.is_dir(),
579                    Err(ancestor_error)
580                        if ancestor_error.kind() == std::io::ErrorKind::NotFound => {}
581                    Err(_) => return false,
582                }
583            }
584            false
585        }
586        Err(_) => false,
587    }
588}
589
590fn file_backend_get(dir: &std::path::Path, r: &SecretRef) -> Result<String, SecretError> {
591    match std::fs::read_to_string(file_backend_path(dir, r)) {
592        Ok(v) => Ok(v),
593        Err(e)
594            if e.kind() == std::io::ErrorKind::NotFound
595                && file_backend_entry_is_merely_absent(dir) =>
596        {
597            Err(SecretError::NotFound {
598                service: r.service.clone(),
599                key: r.key.clone(),
600            })
601        }
602        Err(e) => Err(SecretError::Backend(format!("file backend read: {e}"))),
603    }
604}
605
606fn file_backend_delete(dir: &std::path::Path, r: &SecretRef) -> Result<(), SecretError> {
607    match std::fs::remove_file(file_backend_path(dir, r)) {
608        Ok(()) => Ok(()),
609        Err(e)
610            if e.kind() == std::io::ErrorKind::NotFound
611                && file_backend_entry_is_merely_absent(dir) =>
612        {
613            Ok(())
614        }
615        Err(e) => Err(SecretError::Backend(format!("file backend delete: {e}"))),
616    }
617}
618
619fn file_backend_status(dir: &std::path::Path, r: &SecretRef) -> SecretStatus {
620    SecretStatus {
621        service: r.service.clone(),
622        key: r.key.clone(),
623        // `SecretStatus` has no error/unknown state. Keep the historical false
624        // answer for both an absent entry and an unusable debug store; get/delete
625        // carry the richer error distinction above.
626        exists: file_backend_path(dir, r).exists(),
627    }
628}
629
630#[cfg(target_os = "macos")]
631fn platform_put(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
632    if let Some(dir) = file_backend_dir() {
633        return file_backend_put(&dir, r, value);
634    }
635    mac_put_via_security_cli(&r.service, &r.key, value)
636}
637
638#[cfg(target_os = "macos")]
639fn platform_publish(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
640    if let Some(dir) = file_backend_dir() {
641        return file_backend_publish(&dir, r, value);
642    }
643    mac_publish_via_security_cli(&r.service, &r.key, value)
644}
645
646// --- Windows Credential Manager large-secret chunking ---------------------
647//
648// A single Windows credential's blob is capped well below the length of a
649// Parslee JWT access token — writing one fails with `set_password ... longer
650// than platform limit of 2560 chars`. macOS Keychain and Linux Secret Service
651// have no such tight limit, so this never surfaced until CAR was exercised on
652// real Windows hardware. The workaround, standard for this platform limit, is
653// to split an oversized secret across N chunk entries and leave a sentinel
654// under the real key that records the chunk count. Reads reassemble
655// transparently, so every reader (car-auth, car-inference via car-auth) is
656// unaffected. Backward compatible: a value stored as a single entry (no
657// sentinel) is returned verbatim, and only Windows takes this path.
658
659/// Sentinel written under the real key when a secret was chunked. The trailing
660/// number is the chunk count. Distinctive enough that no real token/API key
661/// collides with it.
662#[cfg(any(not(target_os = "macos"), test))]
663const CHUNK_SENTINEL: &str = "__car_secrets_chunked_v1__:";
664#[cfg(any(target_os = "windows", test))]
665const CHUNK_SENTINEL_V2: &str = "__car_secrets_chunked_v2__:";
666#[cfg(any(target_os = "windows", test))]
667const CHUNK_SENTINEL_V3: &str = "__car_secrets_chunked_v3__:";
668#[cfg(any(target_os = "windows", test))]
669const CHUNK_VALUE_V3: &str = "__car_secrets_chunk_v3__:";
670/// UTF-16 length above which we chunk. Comfortably under the ~2560 platform cap
671/// with headroom for the credential's other attributes.
672#[cfg(any(not(target_os = "macos"), test))]
673const CHUNK_THRESHOLD_UTF16: usize = 2000;
674/// Characters per chunk. 1000 chars ≤ 2000 UTF-16 units even for all-BMP text.
675#[cfg(any(not(target_os = "macos"), test))]
676const CHUNK_CHARS: usize = 1000;
677/// Hard ceiling for deterministic Windows chunk slots. Publication cardinality
678/// is therefore bounded at two generations of at most this many chunks, even
679/// when a process repeatedly crashes before swapping the root credential.
680#[cfg(any(target_os = "windows", test))]
681const WINDOWS_MAX_CHUNKS: usize = 1024;
682#[cfg(any(target_os = "windows", test))]
683const WINDOWS_READ_ATTEMPTS: usize = 4;
684
685/// Derived ref for chunk `i` of a chunked secret.
686#[cfg(not(target_os = "macos"))]
687fn chunk_ref(r: &SecretRef, i: usize) -> SecretRef {
688    SecretRef::new(r.service.clone(), format!("{}#chunk{}", r.key, i))
689}
690
691#[cfg(target_os = "windows")]
692fn chunk_v2_ref(r: &SecretRef, nonce: &str, i: usize) -> SecretRef {
693    SecretRef::new(r.service.clone(), format!("{}#chunkv2#{nonce}#{i}", r.key))
694}
695
696#[cfg(target_os = "windows")]
697fn chunk_v3_ref(r: &SecretRef, generation: ChunkGeneration, i: usize) -> SecretRef {
698    SecretRef::new(
699        r.service.clone(),
700        format!("{}#chunkv3#{}#{i}", r.key, generation.label()),
701    )
702}
703
704#[cfg(target_os = "windows")]
705fn chunk_v3_manifest_ref(r: &SecretRef, generation: ChunkGeneration) -> SecretRef {
706    SecretRef::new(
707        r.service.clone(),
708        format!("{}#chunkv3#{}#manifest", r.key, generation.label()),
709    )
710}
711
712#[cfg(target_os = "windows")]
713fn chunk_v3_retired_v2_ref(r: &SecretRef) -> SecretRef {
714    SecretRef::new(r.service.clone(), format!("{}#chunkv3#retired-v2", r.key))
715}
716
717/// Split a string into pieces of at most `n` chars, on char boundaries.
718#[cfg(any(not(target_os = "macos"), test))]
719fn split_on_chars(s: &str, n: usize) -> Vec<String> {
720    let mut out = Vec::new();
721    let mut cur = String::new();
722    let mut count = 0usize;
723    for ch in s.chars() {
724        cur.push(ch);
725        count += 1;
726        if count == n {
727            out.push(std::mem::take(&mut cur));
728            count = 0;
729        }
730    }
731    if !cur.is_empty() {
732        out.push(cur);
733    }
734    out
735}
736
737fn publication_nonce() -> String {
738    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
739    let sequence = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
740    let nanos = std::time::SystemTime::now()
741        .duration_since(std::time::UNIX_EPOCH)
742        .map(|duration| duration.as_nanos())
743        .unwrap_or_default();
744    format!("{:x}-{:x}-{:x}", std::process::id(), nanos, sequence)
745}
746
747#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
748#[cfg(any(target_os = "windows", test))]
749enum ChunkGeneration {
750    A,
751    B,
752}
753
754#[cfg(any(target_os = "windows", test))]
755impl ChunkGeneration {
756    fn label(self) -> &'static str {
757        match self {
758            Self::A => "a",
759            Self::B => "b",
760        }
761    }
762
763    fn inactive(self) -> Self {
764        match self {
765            Self::A => Self::B,
766            Self::B => Self::A,
767        }
768    }
769}
770
771#[derive(Debug, Clone, PartialEq, Eq)]
772#[cfg(any(target_os = "windows", test))]
773struct ChunkPublicationPlan {
774    generation: ChunkGeneration,
775    revision: String,
776    chunks: Vec<String>,
777    root: String,
778}
779
780#[cfg(any(target_os = "windows", test))]
781fn chunk_publication_plan(
782    value: &str,
783    generation: ChunkGeneration,
784    revision: &str,
785) -> Result<ChunkPublicationPlan, SecretError> {
786    if revision.is_empty() || revision.contains(':') {
787        return Err(SecretError::Backend(
788            "invalid Windows credential publication revision".to_string(),
789        ));
790    }
791    let mut chunks = split_on_chars(value, CHUNK_CHARS);
792    if chunks.is_empty() {
793        chunks.push(String::new());
794    }
795    if chunks.len() > WINDOWS_MAX_CHUNKS {
796        return Err(SecretError::Backend(format!(
797            "Windows credential publication requires {} chunks; maximum is {WINDOWS_MAX_CHUNKS}",
798            chunks.len()
799        )));
800    }
801    Ok(ChunkPublicationPlan {
802        generation,
803        revision: revision.to_string(),
804        root: format!(
805            "{CHUNK_SENTINEL_V3}{}:{revision}:{}",
806            generation.label(),
807            chunks.len()
808        ),
809        chunks,
810    })
811}
812
813#[cfg(any(target_os = "windows", test))]
814fn encode_v3_chunk(revision: &str, value: &str) -> String {
815    format!("{CHUNK_VALUE_V3}{revision}:{value}")
816}
817
818#[cfg(any(target_os = "windows", test))]
819fn decode_v3_chunk<'a>(raw: &'a str, revision: &str) -> Result<&'a str, SecretError> {
820    let payload = raw.strip_prefix(CHUNK_VALUE_V3).ok_or_else(|| {
821        SecretError::Backend("invalid Windows v3 credential chunk metadata".to_string())
822    })?;
823    let (stored_revision, value) = payload.split_once(':').ok_or_else(|| {
824        SecretError::Backend("invalid Windows v3 credential chunk metadata".to_string())
825    })?;
826    if stored_revision != revision {
827        return Err(SecretError::Backend(
828            "Windows credential chunk revision changed during read".to_string(),
829        ));
830    }
831    Ok(value)
832}
833
834#[cfg(any(target_os = "windows", test))]
835fn parse_v2_sentinel(raw: &str) -> Option<(&str, usize)> {
836    let payload = raw.strip_prefix(CHUNK_SENTINEL_V2)?;
837    let (nonce, count) = payload.rsplit_once(':')?;
838    let count = count.parse::<usize>().ok()?;
839    if nonce.is_empty() || count == 0 || count > WINDOWS_MAX_CHUNKS {
840        return None;
841    }
842    Some((nonce, count))
843}
844
845#[derive(Debug, Clone, PartialEq, Eq)]
846#[cfg(any(target_os = "windows", test))]
847enum WindowsRootLayout {
848    Inline,
849    LegacyV1 {
850        count: usize,
851    },
852    LegacyV2 {
853        nonce: String,
854        count: usize,
855    },
856    V3 {
857        generation: ChunkGeneration,
858        revision: String,
859        count: usize,
860    },
861}
862
863#[cfg(any(target_os = "windows", test))]
864fn windows_root_layout(raw: &str) -> Result<WindowsRootLayout, SecretError> {
865    if let Some(payload) = raw.strip_prefix(CHUNK_SENTINEL_V3) {
866        let (publication, count) = payload.rsplit_once(':').ok_or_else(|| {
867            SecretError::Backend("invalid Windows v3 credential root metadata".to_string())
868        })?;
869        let (generation, revision) = publication.split_once(':').ok_or_else(|| {
870            SecretError::Backend("invalid Windows v3 credential root metadata".to_string())
871        })?;
872        let generation = match generation {
873            "a" => ChunkGeneration::A,
874            "b" => ChunkGeneration::B,
875            _ => {
876                return Err(SecretError::Backend(
877                    "invalid Windows v3 credential generation".to_string(),
878                ))
879            }
880        };
881        if revision.is_empty() {
882            return Err(SecretError::Backend(
883                "invalid Windows v3 credential publication revision".to_string(),
884            ));
885        }
886        let count = count
887            .parse::<usize>()
888            .ok()
889            .filter(|count| *count > 0 && *count <= WINDOWS_MAX_CHUNKS);
890        return count
891            .map(|count| WindowsRootLayout::V3 {
892                generation,
893                revision: revision.to_string(),
894                count,
895            })
896            .ok_or_else(|| {
897                SecretError::Backend("invalid Windows v3 credential chunk count".to_string())
898            });
899    }
900
901    if raw.starts_with(CHUNK_SENTINEL_V2) {
902        return parse_v2_sentinel(raw)
903            .map(|(nonce, count)| WindowsRootLayout::LegacyV2 {
904                nonce: nonce.to_string(),
905                count,
906            })
907            .ok_or_else(|| {
908                SecretError::Backend("invalid Windows v2 credential root metadata".to_string())
909            });
910    }
911
912    if let Some(count) = raw.strip_prefix(CHUNK_SENTINEL) {
913        return count
914            .parse::<usize>()
915            .ok()
916            .filter(|count| *count > 0 && *count <= WINDOWS_MAX_CHUNKS)
917            .map(|count| WindowsRootLayout::LegacyV1 { count })
918            .ok_or_else(|| {
919                SecretError::Backend("invalid Windows v1 credential chunk count".to_string())
920            });
921    }
922
923    Ok(WindowsRootLayout::Inline)
924}
925
926#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
927#[cfg(any(target_os = "windows", test))]
928enum WindowsCredentialSlot {
929    Root,
930    LegacyV1Chunk(usize),
931    LegacyV2Chunk {
932        nonce: String,
933        index: usize,
934    },
935    V3Chunk {
936        generation: ChunkGeneration,
937        index: usize,
938    },
939    V3Manifest(ChunkGeneration),
940    RetiredV2Manifest,
941}
942
943#[cfg(any(target_os = "windows", test))]
944trait WindowsCredentialBackend {
945    fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError>;
946    fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError>;
947    fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError>;
948}
949
950#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
951#[cfg(any(target_os = "windows", test))]
952struct WindowsCleanupReport {
953    failures: usize,
954}
955
956#[cfg(any(target_os = "windows", test))]
957fn cleanup_windows_slot(
958    backend: &mut impl WindowsCredentialBackend,
959    slot: WindowsCredentialSlot,
960    report: &mut WindowsCleanupReport,
961) {
962    if backend.delete(&slot).is_err() {
963        report.failures += 1;
964    }
965}
966
967#[cfg(any(target_os = "windows", test))]
968fn read_generation_manifest(
969    backend: &mut impl WindowsCredentialBackend,
970    generation: ChunkGeneration,
971) -> Result<usize, SecretError> {
972    let Some(raw) = backend.read(&WindowsCredentialSlot::V3Manifest(generation))? else {
973        return Ok(0);
974    };
975    raw.parse::<usize>()
976        .ok()
977        .filter(|count| *count <= WINDOWS_MAX_CHUNKS)
978        .ok_or_else(|| {
979            SecretError::Backend("invalid Windows credential generation manifest".to_string())
980        })
981}
982
983#[cfg(any(target_os = "windows", test))]
984fn read_retired_v2_manifest(
985    backend: &mut impl WindowsCredentialBackend,
986) -> Result<Option<(String, usize)>, SecretError> {
987    let Some(raw) = backend.read(&WindowsCredentialSlot::RetiredV2Manifest)? else {
988        return Ok(None);
989    };
990    match windows_root_layout(&raw)? {
991        WindowsRootLayout::LegacyV2 { nonce, count } => Ok(Some((nonce, count))),
992        _ => Err(SecretError::Backend(
993            "invalid retired Windows v2 credential manifest".to_string(),
994        )),
995    }
996}
997
998#[cfg(any(target_os = "windows", test))]
999fn cleanup_retired_v2(
1000    backend: &mut impl WindowsCredentialBackend,
1001    nonce: &str,
1002    count: usize,
1003    report: &mut WindowsCleanupReport,
1004) {
1005    let failures_before = report.failures;
1006    for index in 0..count {
1007        cleanup_windows_slot(
1008            backend,
1009            WindowsCredentialSlot::LegacyV2Chunk {
1010                nonce: nonce.to_string(),
1011                index,
1012            },
1013            report,
1014        );
1015    }
1016    // Keep the deterministic manifest when any chunk cleanup failed so the
1017    // next publication/delete can resume the sweep without guessing a nonce.
1018    if report.failures == failures_before {
1019        cleanup_windows_slot(backend, WindowsCredentialSlot::RetiredV2Manifest, report);
1020    }
1021}
1022
1023#[cfg(any(target_os = "windows", test))]
1024fn publish_windows_value(
1025    backend: &mut impl WindowsCredentialBackend,
1026    value: &str,
1027) -> Result<WindowsCleanupReport, SecretError> {
1028    let previous_root = backend.read(&WindowsCredentialSlot::Root)?;
1029    let previous_layout = previous_root
1030        .as_deref()
1031        .map(windows_root_layout)
1032        .transpose()?;
1033    let retired_v2_before = read_retired_v2_manifest(backend)?;
1034    let newly_retired_v2 = match previous_layout.as_ref() {
1035        Some(WindowsRootLayout::LegacyV2 { nonce, count }) => {
1036            let root = previous_root
1037                .as_deref()
1038                .expect("a parsed legacy root came from a present credential");
1039            backend.write(&WindowsCredentialSlot::RetiredV2Manifest, root)?;
1040            Some((nonce.clone(), *count))
1041        }
1042        _ => None,
1043    };
1044    let generation = match previous_layout {
1045        Some(WindowsRootLayout::V3 { generation, .. }) => generation.inactive(),
1046        _ => ChunkGeneration::A,
1047    };
1048    let plan = chunk_publication_plan(value, generation, &publication_nonce())?;
1049
1050    // This non-secret high-water mark is persisted before any chunk mutation.
1051    // If the process dies while staging, the next writer knows exactly how far
1052    // it must sweep. Repeated crashes overwrite the same bounded slot set.
1053    let previous_bound = read_generation_manifest(backend, generation)?;
1054    let high_water = previous_bound.max(plan.chunks.len());
1055    backend.write(
1056        &WindowsCredentialSlot::V3Manifest(generation),
1057        &high_water.to_string(),
1058    )?;
1059
1060    let mut staged = 0;
1061    for (index, chunk) in plan.chunks.iter().enumerate() {
1062        let slot = WindowsCredentialSlot::V3Chunk { generation, index };
1063        if let Err(error) = backend.write(&slot, &encode_v3_chunk(&plan.revision, chunk)) {
1064            let mut ignored_cleanup = WindowsCleanupReport::default();
1065            for staged_index in 0..staged {
1066                cleanup_windows_slot(
1067                    backend,
1068                    WindowsCredentialSlot::V3Chunk {
1069                        generation,
1070                        index: staged_index,
1071                    },
1072                    &mut ignored_cleanup,
1073                );
1074            }
1075            return Err(error);
1076        }
1077        staged += 1;
1078    }
1079
1080    // The root write is the sole commit point. The previous active generation
1081    // is deliberately retained so a reader that captured its root can finish.
1082    if let Err(error) = backend.write(&WindowsCredentialSlot::Root, &plan.root) {
1083        let mut ignored_cleanup = WindowsCleanupReport::default();
1084        for staged_index in 0..staged {
1085            cleanup_windows_slot(
1086                backend,
1087                WindowsCredentialSlot::V3Chunk {
1088                    generation,
1089                    index: staged_index,
1090                },
1091                &mut ignored_cleanup,
1092            );
1093        }
1094        return Err(error);
1095    }
1096
1097    let mut cleanup = WindowsCleanupReport::default();
1098    let tail_failures_before = cleanup.failures;
1099    for index in plan.chunks.len()..high_water {
1100        cleanup_windows_slot(
1101            backend,
1102            WindowsCredentialSlot::V3Chunk { generation, index },
1103            &mut cleanup,
1104        );
1105    }
1106    if cleanup.failures == tail_failures_before
1107        && backend
1108            .write(
1109                &WindowsCredentialSlot::V3Manifest(generation),
1110                &plan.chunks.len().to_string(),
1111            )
1112            .is_err()
1113    {
1114        cleanup.failures += 1;
1115    }
1116
1117    // A v2 generation retired by this commit survives at least one full v3
1118    // publication. That lets a reader which captured the old nonce finish.
1119    // A later commit reclaims it through the deterministic manifest; failures
1120    // leave the manifest in place for the next recovery sweep.
1121    if let Some((nonce, count)) = retired_v2_before {
1122        if newly_retired_v2.as_ref() != Some(&(nonce.clone(), count)) {
1123            cleanup_retired_v2(backend, &nonce, count, &mut cleanup);
1124        }
1125    }
1126
1127    Ok(cleanup)
1128}
1129
1130/// Best-effort delete of any chunk entries `#chunk0..` for `r`, stopping at the
1131/// first that doesn't exist. Used before a rewrite and on delete so stale
1132/// chunks from a previous large value never linger.
1133#[cfg(not(target_os = "macos"))]
1134fn clear_chunks(store: &SecretStore, r: &SecretRef) {
1135    for i in 0..1024 {
1136        let cr = chunk_ref(r, i);
1137        let Ok(entry) = store.entry(&cr) else { break };
1138        match entry.delete_credential() {
1139            Ok(_) => {}
1140            Err(keyring::Error::NoEntry) => break,
1141            Err(_) => break,
1142        }
1143    }
1144}
1145
1146#[cfg(any(target_os = "windows", test))]
1147fn read_windows_value(
1148    backend: &mut impl WindowsCredentialBackend,
1149) -> Result<Option<String>, SecretError> {
1150    for attempt in 0..WINDOWS_READ_ATTEMPTS {
1151        let Some(root) = backend.read(&WindowsCredentialSlot::Root)? else {
1152            return Ok(None);
1153        };
1154        let (slots, expected_revision) = match windows_root_layout(&root)? {
1155            WindowsRootLayout::Inline => return Ok(Some(root)),
1156            WindowsRootLayout::LegacyV1 { count } => (
1157                (0..count)
1158                    .map(WindowsCredentialSlot::LegacyV1Chunk)
1159                    .collect::<Vec<_>>(),
1160                None,
1161            ),
1162            WindowsRootLayout::LegacyV2 { nonce, count } => (
1163                (0..count)
1164                    .map(|index| WindowsCredentialSlot::LegacyV2Chunk {
1165                        nonce: nonce.clone(),
1166                        index,
1167                    })
1168                    .collect::<Vec<_>>(),
1169                None,
1170            ),
1171            WindowsRootLayout::V3 {
1172                generation,
1173                revision,
1174                count,
1175            } => (
1176                (0..count)
1177                    .map(|index| WindowsCredentialSlot::V3Chunk { generation, index })
1178                    .collect::<Vec<_>>(),
1179                Some(revision),
1180            ),
1181        };
1182
1183        let mut value = String::new();
1184        let mut chunk_error = None;
1185        for slot in slots {
1186            match backend.read(&slot) {
1187                Ok(Some(chunk)) => {
1188                    if let Some(revision) = expected_revision.as_deref() {
1189                        match decode_v3_chunk(&chunk, revision) {
1190                            Ok(chunk) => value.push_str(chunk),
1191                            Err(error) => {
1192                                chunk_error = Some(error);
1193                                break;
1194                            }
1195                        }
1196                    } else {
1197                        value.push_str(&chunk);
1198                    }
1199                }
1200                Ok(None) => {
1201                    chunk_error = Some(SecretError::Backend(
1202                        "Windows credential publication is incomplete".to_string(),
1203                    ));
1204                    break;
1205                }
1206                Err(error) => {
1207                    chunk_error = Some(error);
1208                    break;
1209                }
1210            }
1211        }
1212
1213        let root_after = backend.read(&WindowsCredentialSlot::Root);
1214        if matches!(&root_after, Ok(Some(current)) if current != &root) {
1215            if chunk_error.is_none() {
1216                // Every chunk carried the captured revision (or was a retained
1217                // legacy generation), so this complete old value is safe.
1218                return Ok(Some(value));
1219            }
1220            if attempt + 1 < WINDOWS_READ_ATTEMPTS {
1221                continue;
1222            }
1223            return Err(SecretError::Backend(
1224                "Windows credential root changed during every read attempt".to_string(),
1225            ));
1226        }
1227        if let Some(error) = chunk_error {
1228            return Err(error);
1229        }
1230        match root_after {
1231            Ok(Some(current)) if current == root => return Ok(Some(value)),
1232            Ok(_) if attempt + 1 < WINDOWS_READ_ATTEMPTS => continue,
1233            Ok(_) => {
1234                return Err(SecretError::Backend(
1235                    "Windows credential root changed during every read attempt".to_string(),
1236                ))
1237            }
1238            Err(error) => return Err(error),
1239        }
1240    }
1241    Err(SecretError::Backend(
1242        "Windows credential read retry limit reached".to_string(),
1243    ))
1244}
1245
1246#[cfg(any(target_os = "windows", test))]
1247fn delete_windows_value(
1248    backend: &mut impl WindowsCredentialBackend,
1249) -> Result<WindowsCleanupReport, SecretError> {
1250    let root = backend.read(&WindowsCredentialSlot::Root)?;
1251    let layout = root.as_deref().map(windows_root_layout).transpose()?;
1252    let retired_v2 = read_retired_v2_manifest(backend)?;
1253
1254    // Resolve every cleanup bound before deleting the root. A metadata/backend
1255    // error therefore leaves the only authoritative generation untouched.
1256    let mut generation_bounds = [
1257        (
1258            ChunkGeneration::A,
1259            read_generation_manifest(backend, ChunkGeneration::A)?,
1260        ),
1261        (
1262            ChunkGeneration::B,
1263            read_generation_manifest(backend, ChunkGeneration::B)?,
1264        ),
1265    ];
1266    if let Some(WindowsRootLayout::V3 {
1267        generation, count, ..
1268    }) = layout.as_ref()
1269    {
1270        let (_, bound) = generation_bounds
1271            .iter_mut()
1272            .find(|(candidate, _)| candidate == generation)
1273            .expect("both deterministic generations are present");
1274        *bound = (*bound).max(*count);
1275    }
1276
1277    backend.delete(&WindowsCredentialSlot::Root)?;
1278
1279    let mut cleanup = WindowsCleanupReport::default();
1280    for (generation, bound) in generation_bounds {
1281        let failures_before = cleanup.failures;
1282        for index in 0..bound {
1283            cleanup_windows_slot(
1284                backend,
1285                WindowsCredentialSlot::V3Chunk { generation, index },
1286                &mut cleanup,
1287            );
1288        }
1289        if cleanup.failures == failures_before {
1290            cleanup_windows_slot(
1291                backend,
1292                WindowsCredentialSlot::V3Manifest(generation),
1293                &mut cleanup,
1294            );
1295        }
1296    }
1297    match layout {
1298        Some(WindowsRootLayout::LegacyV1 { count }) => {
1299            for index in 0..count {
1300                cleanup_windows_slot(
1301                    backend,
1302                    WindowsCredentialSlot::LegacyV1Chunk(index),
1303                    &mut cleanup,
1304                );
1305            }
1306        }
1307        Some(WindowsRootLayout::LegacyV2 { nonce, count }) => {
1308            for index in 0..count {
1309                cleanup_windows_slot(
1310                    backend,
1311                    WindowsCredentialSlot::LegacyV2Chunk {
1312                        nonce: nonce.clone(),
1313                        index,
1314                    },
1315                    &mut cleanup,
1316                );
1317            }
1318        }
1319        _ => {}
1320    }
1321    if let Some((nonce, count)) = retired_v2 {
1322        cleanup_retired_v2(backend, &nonce, count, &mut cleanup);
1323    }
1324    Ok(cleanup)
1325}
1326
1327#[cfg(not(target_os = "macos"))]
1328fn platform_put(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1329    if let Some(dir) = file_backend_dir() {
1330        return file_backend_put(&dir, r, value);
1331    }
1332    // Windows-only: chunk an oversized secret. Runtime-gated so Linux Secret
1333    // Service (no size limit) keeps its exact single-entry behavior.
1334    if cfg!(windows) {
1335        // Always clear stale chunks first so a shrink (large → small) can't
1336        // leave orphans behind.
1337        clear_chunks(store, r);
1338        if value.encode_utf16().count() > CHUNK_THRESHOLD_UTF16 {
1339            let parts = split_on_chars(value, CHUNK_CHARS);
1340            for (i, part) in parts.iter().enumerate() {
1341                let cr = chunk_ref(r, i);
1342                store
1343                    .entry(&cr)?
1344                    .set_password(part)
1345                    .map_err(|e| classify(e, "set_password(chunk)"))?;
1346            }
1347            // The sentinel goes last so a reader never sees it before its
1348            // chunks exist.
1349            let sentinel = format!("{CHUNK_SENTINEL}{}", parts.len());
1350            return store
1351                .entry(r)?
1352                .set_password(&sentinel)
1353                .map_err(|e| classify(e, "set_password(sentinel)"));
1354        }
1355    }
1356    let entry = store.entry(r)?;
1357    entry
1358        .set_password(value)
1359        .map_err(|e| classify(e, "set_password"))
1360}
1361
1362#[cfg(target_os = "windows")]
1363struct KeyringWindowsBackend<'a> {
1364    store: &'a SecretStore,
1365    root: &'a SecretRef,
1366}
1367
1368#[cfg(target_os = "windows")]
1369impl KeyringWindowsBackend<'_> {
1370    fn secret_ref(&self, slot: &WindowsCredentialSlot) -> SecretRef {
1371        match slot {
1372            WindowsCredentialSlot::Root => self.root.clone(),
1373            WindowsCredentialSlot::LegacyV1Chunk(index) => chunk_ref(self.root, *index),
1374            WindowsCredentialSlot::LegacyV2Chunk { nonce, index } => {
1375                chunk_v2_ref(self.root, nonce, *index)
1376            }
1377            WindowsCredentialSlot::V3Chunk { generation, index } => {
1378                chunk_v3_ref(self.root, *generation, *index)
1379            }
1380            WindowsCredentialSlot::V3Manifest(generation) => {
1381                chunk_v3_manifest_ref(self.root, *generation)
1382            }
1383            WindowsCredentialSlot::RetiredV2Manifest => chunk_v3_retired_v2_ref(self.root),
1384        }
1385    }
1386}
1387
1388#[cfg(target_os = "windows")]
1389impl WindowsCredentialBackend for KeyringWindowsBackend<'_> {
1390    fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
1391        match self.store.entry(&self.secret_ref(slot))?.get_password() {
1392            Ok(value) => Ok(Some(value)),
1393            Err(keyring::Error::NoEntry) => Ok(None),
1394            Err(error) => Err(classify(error, "get_password(windows-publish)")),
1395        }
1396    }
1397
1398    fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
1399        self.store
1400            .entry(&self.secret_ref(slot))?
1401            .set_password(value)
1402            .map_err(|error| classify(error, "set_password(windows-publish)"))
1403    }
1404
1405    fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
1406        match self
1407            .store
1408            .entry(&self.secret_ref(slot))?
1409            .delete_credential()
1410        {
1411            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
1412            Err(error) => Err(classify(error, "delete_credential(windows-publish)")),
1413        }
1414    }
1415}
1416
1417#[cfg(target_os = "windows")]
1418fn platform_publish(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1419    if let Some(dir) = file_backend_dir() {
1420        return file_backend_publish(&dir, r, value);
1421    }
1422    let mut backend = KeyringWindowsBackend { store, root: r };
1423    let cleanup = publish_windows_value(&mut backend, value)?;
1424    if cleanup.failures > 0 {
1425        tracing::warn!(
1426            cleanup_failures = cleanup.failures,
1427            "Windows credential publication committed; bounded cleanup deferred"
1428        );
1429    }
1430    Ok(())
1431}
1432
1433#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1434fn platform_publish(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1435    if let Some(dir) = file_backend_dir() {
1436        return file_backend_publish(&dir, r, value);
1437    }
1438    store
1439        .entry(r)?
1440        .set_password(value)
1441        .map_err(|error| classify(error, "publish_password"))
1442}
1443
1444#[cfg(target_os = "macos")]
1445fn platform_get(_store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1446    if let Some(dir) = file_backend_dir() {
1447        return file_backend_get(&dir, r);
1448    }
1449    mac_get_via_security_cli(r)
1450}
1451
1452#[cfg(target_os = "windows")]
1453fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1454    if let Some(dir) = file_backend_dir() {
1455        return file_backend_get(&dir, r);
1456    }
1457    let mut backend = KeyringWindowsBackend { store, root: r };
1458    match read_windows_value(&mut backend)? {
1459        Some(value) => Ok(value),
1460        None => Err(SecretError::NotFound {
1461            service: r.service.clone(),
1462            key: r.key.clone(),
1463        }),
1464    }
1465}
1466
1467#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1468fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1469    if let Some(dir) = file_backend_dir() {
1470        return file_backend_get(&dir, r);
1471    }
1472    match store.entry(r)?.get_password() {
1473        Ok(value) => Ok(value),
1474        Err(keyring::Error::NoEntry) => Err(SecretError::NotFound {
1475            service: r.service.clone(),
1476            key: r.key.clone(),
1477        }),
1478        Err(error) => Err(classify(error, "get_password")),
1479    }
1480}
1481
1482#[cfg(target_os = "macos")]
1483fn platform_delete(_store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1484    if let Some(dir) = file_backend_dir() {
1485        return file_backend_delete(&dir, r);
1486    }
1487    mac_delete_via_security_cli(r)
1488}
1489
1490#[cfg(target_os = "windows")]
1491fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1492    if let Some(dir) = file_backend_dir() {
1493        return file_backend_delete(&dir, r);
1494    }
1495    let mut backend = KeyringWindowsBackend { store, root: r };
1496    let cleanup = delete_windows_value(&mut backend)?;
1497    if cleanup.failures > 0 {
1498        tracing::warn!(
1499            cleanup_failures = cleanup.failures,
1500            "Windows credential root deleted; bounded cleanup deferred"
1501        );
1502    }
1503    Ok(())
1504}
1505
1506#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1507fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1508    if let Some(dir) = file_backend_dir() {
1509        return file_backend_delete(&dir, r);
1510    }
1511    match store.entry(r)?.delete_credential() {
1512        Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
1513        Err(error) => Err(classify(error, "delete_credential")),
1514    }
1515}
1516
1517#[cfg(target_os = "macos")]
1518fn platform_status(_store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
1519    if let Some(dir) = file_backend_dir() {
1520        return Ok(file_backend_status(&dir, r));
1521    }
1522    mac_status_via_security_cli(r)
1523}
1524
1525#[cfg(not(target_os = "macos"))]
1526fn platform_status(store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
1527    if let Some(dir) = file_backend_dir() {
1528        return Ok(file_backend_status(&dir, r));
1529    }
1530    let entry = store.entry(r)?;
1531    let exists = match entry.get_password() {
1532        Ok(_) => true,
1533        Err(keyring::Error::NoEntry) => false,
1534        Err(other) => return Err(classify(other, "status")),
1535    };
1536    Ok(SecretStatus {
1537        service: r.service.clone(),
1538        key: r.key.clone(),
1539        exists,
1540    })
1541}
1542
1543/// Reachability probe on macOS: the same Apple-signed helper every other
1544/// macOS operation uses.
1545///
1546/// This was the one keychain operation still going through the in-process
1547/// `keyring` crate, and it is the operation that runs *most*:
1548/// [`resolve_env_or_keychain`] calls [`SecretStore::is_available`] before
1549/// every single credential resolution, uncached, and a `car-inference`
1550/// catalog refresh resolves one credential per distinct provider. So the call
1551/// in front of every credential read was the call with no deadline, no log
1552/// line naming what it was blocked on, and authorization bound to the CAR
1553/// binary's own code-signing hash rather than to `/usr/bin/security`
1554/// (Parslee-ai/car#897).
1555///
1556/// Routing it through [`mac_exists_via_security_cli_with`] fixes all three at
1557/// once, and adds nothing new: that call is existence-only — **no `-g`**, so
1558/// it reads no password bytes and cannot raise an authorization dialog — and
1559/// it runs under `bounded_command_output`, so it is capped at the shared
1560/// deadline and emits [`keychain_prompt_notice`] naming
1561/// `car-internal/__availability_probe__` if it ever does block.
1562///
1563/// One deliberate semantic change against the non-macOS body: there, an
1564/// unclassified `keyring` error (`BadEncoding` and friends) reports
1565/// *available* so the caller can still try real ops. Here, a non-zero exit
1566/// that is not `SECURITY_ERR_SEC_ITEM_NOT_FOUND` reports *unavailable* with
1567/// the helper's own reason. That is the rule
1568/// [`mac_exists_via_security_cli_with`] already documents for status checks —
1569/// those exits are backend/authorization failures, not encoding oddities — so
1570/// the probe and the status check now agree instead of disagreeing.
1571#[cfg(target_os = "macos")]
1572fn platform_availability(_store: &SecretStore) -> AvailabilityCheck {
1573    mac_availability_via_security_cli_with(&SystemSecurityCli)
1574}
1575
1576#[cfg(target_os = "macos")]
1577fn mac_availability_via_security_cli_with(cli: &impl SecurityCli) -> AvailabilityCheck {
1578    let probe = SecretRef::new(SecretStore::PROBE_SERVICE, SecretStore::PROBE_KEY);
1579    match mac_exists_via_security_cli_with(&probe, cli) {
1580        // Present or absent both mean the store answered, which is the only
1581        // thing the probe asks. Mirrors the `Ok(_) | Err(NoEntry)` arm below.
1582        Ok(_) => AvailabilityCheck {
1583            available: true,
1584            reason: None,
1585        },
1586        Err(error) => AvailabilityCheck {
1587            available: false,
1588            reason: Some(error.to_string()),
1589        },
1590    }
1591}
1592
1593#[cfg(not(target_os = "macos"))]
1594fn platform_availability(store: &SecretStore) -> AvailabilityCheck {
1595    let probe = SecretRef::new(SecretStore::PROBE_SERVICE, SecretStore::PROBE_KEY);
1596    match store.entry(&probe) {
1597        Ok(entry) => match entry.get_password() {
1598            Ok(_) | Err(keyring::Error::NoEntry) => AvailabilityCheck {
1599                available: true,
1600                reason: None,
1601            },
1602            Err(keyring::Error::PlatformFailure(e)) => AvailabilityCheck {
1603                available: false,
1604                reason: Some(format!("platform failure: {e}")),
1605            },
1606            Err(keyring::Error::NoStorageAccess(e)) => AvailabilityCheck {
1607                available: false,
1608                reason: Some(format!("no storage access: {e}")),
1609            },
1610            // Other keyring errors (BadEncoding etc.) on the
1611            // probe key indicate the backend responded but
1612            // returned something unexpected. Treat as available
1613            // so the caller can still try real ops; the failure
1614            // mode shows up at the next put/get with proper
1615            // typed error.
1616            Err(_) => AvailabilityCheck {
1617                available: true,
1618                reason: None,
1619            },
1620        },
1621        Err(SecretError::Unavailable(reason)) => AvailabilityCheck {
1622            available: false,
1623            reason: Some(reason),
1624        },
1625        Err(other) => AvailabilityCheck {
1626            available: false,
1627            reason: Some(other.to_string()),
1628        },
1629    }
1630}
1631
1632/// Shell-out write with the `-A` flag (any-app ACL).
1633///
1634/// `service`/`account` are passed as separate argv tokens so shell
1635/// metacharacters in either are inert. The value is the only argv slot
1636/// that's a secret; document the trade-off at the call site.
1637///
1638/// Always issues `delete-generic-password` first (best-effort, errors
1639/// ignored) so the subsequent `add-generic-password` creates a fresh
1640/// keychain item with a fresh ACL. Without the pre-delete,
1641/// `add-generic-password -U` would update the value in place but
1642/// preserve any existing CDHash-bound ACL from a previous binary —
1643/// causing the Apple-signed `/usr/bin/security` reader to be prompted
1644/// for authorization on every subsequent `-g` retrieval. The `-U` flag
1645/// is retained on `add` as a safety net for the (rare) case where
1646/// delete returned non-zero non-NotFound and the entry is somehow
1647/// still present.
1648#[cfg(target_os = "macos")]
1649fn mac_put_via_security_cli(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
1650    mac_put_via_security_cli_with(service, account, value, &SystemSecurityCli)
1651}
1652
1653#[cfg(target_os = "macos")]
1654fn mac_publish_via_security_cli(
1655    service: &str,
1656    account: &str,
1657    value: &str,
1658) -> Result<(), SecretError> {
1659    mac_publish_via_security_cli_with(service, account, value, &SystemSecurityCli)
1660}
1661
1662#[cfg(target_os = "macos")]
1663fn mac_publish_via_security_cli_with(
1664    service: &str,
1665    account: &str,
1666    value: &str,
1667    cli: &impl SecurityCli,
1668) -> Result<(), SecretError> {
1669    let output = cli
1670        .output(&[
1671            "add-generic-password",
1672            "-U",
1673            "-A",
1674            "-s",
1675            service,
1676            "-a",
1677            account,
1678            "-w",
1679            value,
1680        ])
1681        .map_err(|e| security_cli_spawn_error("add-generic-password", e))?;
1682    if output.success {
1683        Ok(())
1684    } else {
1685        Err(security_cli_backend_error("add-generic-password", output))
1686    }
1687}
1688
1689#[cfg(target_os = "macos")]
1690fn mac_put_via_security_cli_with(
1691    service: &str,
1692    account: &str,
1693    value: &str,
1694    cli: &impl SecurityCli,
1695) -> Result<(), SecretError> {
1696    // Best-effort delete: clears any pre-existing item so the add below
1697    // installs a brand-new ACL via `-A`. Failures (including NotFound) are
1698    // ignored — the add path handles the residual-item case via `-U`.
1699    let _ = cli.output(&["delete-generic-password", "-s", service, "-a", account]);
1700
1701    let output = cli
1702        .output(&[
1703            "add-generic-password",
1704            "-U", // safety net if the pre-delete didn't actually remove the item
1705            "-A", // permissive ACL — any app can read
1706            "-s",
1707            service,
1708            "-a",
1709            account,
1710            "-w",
1711            value,
1712        ])
1713        .map_err(|e| security_cli_spawn_error("add-generic-password", e))?;
1714    if output.success {
1715        return Ok(());
1716    }
1717    Err(security_cli_backend_error("add-generic-password", output))
1718}
1719
1720#[cfg(target_os = "macos")]
1721const SECURITY_ERR_SEC_ITEM_NOT_FOUND: i32 = 44;
1722
1723#[cfg(target_os = "macos")]
1724#[derive(Debug)]
1725struct SecurityCliOutput {
1726    success: bool,
1727    code: Option<i32>,
1728    stdout: Vec<u8>,
1729    stderr: Vec<u8>,
1730    /// Whether *this* call is the one macOS drew a keychain dialog for.
1731    ///
1732    /// The signal already existed inside `bounded_command_output` (it is what
1733    /// extends the deadline); it just was not surfaced. It is the evidence that
1734    /// an item's ACL is bound to a binary that no longer matches — which is the
1735    /// condition `mac_repair_item_acl_with` exists to end.
1736    ///
1737    /// Narrower than "a dialog was on screen": see
1738    /// [`dialog_is_evidence_for_this_read`]. `SecurityAgent` is machine-wide, so
1739    /// a bare sighting is not attributable to this read, and acting on an
1740    /// unattributable one rewrites a healthy credential.
1741    prompted: bool,
1742    /// Whether CAR killed the helper after its bounded deadline.
1743    timed_out: bool,
1744}
1745
1746#[cfg(target_os = "macos")]
1747trait SecurityCli {
1748    fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput>;
1749}
1750
1751#[cfg(target_os = "macos")]
1752struct SystemSecurityCli;
1753
1754#[cfg(target_os = "macos")]
1755impl SecurityCli for SystemSecurityCli {
1756    fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
1757        let mut command = std::process::Command::new("/usr/bin/security");
1758        command.args(args);
1759        if let Some(keychain_path) = selected_keychain_path()? {
1760            command.arg(keychain_path);
1761        }
1762        let run = bounded_command_output(&mut command, SECURITY_CLI_TIMEOUT, &describe_item(args))?;
1763        Ok(SecurityCliOutput {
1764            success: run.output.status.success(),
1765            code: run.output.status.code(),
1766            stdout: run.output.stdout,
1767            stderr: run.output.stderr,
1768            prompted: run.prompted,
1769            timed_out: run.timed_out,
1770        })
1771    }
1772}
1773
1774#[cfg(target_os = "macos")]
1775const KEYCHAIN_PATH_ENV: &str = "CAR_KEYCHAIN_PATH";
1776
1777#[cfg(target_os = "macos")]
1778const KEYCHAIN_PROOF_ROOT_ENV: &str = "CAR_KEYCHAIN_PROOF_ROOT";
1779
1780/// Resolve the optional release-proof Keychain selector. Production behavior
1781/// is unchanged when `CAR_KEYCHAIN_PATH` is absent.
1782#[cfg(target_os = "macos")]
1783fn selected_keychain_path() -> std::io::Result<Option<std::path::PathBuf>> {
1784    let Some(path) = std::env::var_os(KEYCHAIN_PATH_ENV).filter(|value| !value.is_empty()) else {
1785        return Ok(None);
1786    };
1787    let proof_root = std::env::var_os(KEYCHAIN_PROOF_ROOT_ENV)
1788        .filter(|value| !value.is_empty())
1789        .ok_or_else(|| {
1790            std::io::Error::new(
1791                std::io::ErrorKind::InvalidInput,
1792                format!("{KEYCHAIN_PATH_ENV} requires {KEYCHAIN_PROOF_ROOT_ENV}"),
1793            )
1794        })?;
1795    validate_keychain_path(
1796        std::path::Path::new(&path),
1797        std::path::Path::new(&proof_root),
1798    )
1799    .map(Some)
1800}
1801
1802#[cfg(target_os = "macos")]
1803fn validate_keychain_path(
1804    path: &std::path::Path,
1805    proof_root: &std::path::Path,
1806) -> std::io::Result<std::path::PathBuf> {
1807    use std::os::unix::fs::{MetadataExt, PermissionsExt};
1808
1809    if !path.is_absolute() || !proof_root.is_absolute() {
1810        return Err(std::io::Error::new(
1811            std::io::ErrorKind::InvalidInput,
1812            "isolated Keychain path and proof root must be absolute",
1813        ));
1814    }
1815
1816    let expected_uid = current_effective_uid();
1817    let root_metadata = std::fs::symlink_metadata(proof_root)?;
1818    if root_metadata.file_type().is_symlink()
1819        || !root_metadata.is_dir()
1820        || root_metadata.uid() != expected_uid
1821        || root_metadata.permissions().mode() & 0o077 != 0
1822    {
1823        return Err(std::io::Error::new(
1824            std::io::ErrorKind::PermissionDenied,
1825            "Keychain proof root must be an owner-private, non-symlink directory owned by the current user",
1826        ));
1827    }
1828
1829    let path_metadata = std::fs::symlink_metadata(path)?;
1830    if path_metadata.file_type().is_symlink()
1831        || !path_metadata.is_file()
1832        || path_metadata.uid() != expected_uid
1833        || path_metadata.permissions().mode() & 0o077 != 0
1834    {
1835        return Err(std::io::Error::new(
1836            std::io::ErrorKind::PermissionDenied,
1837            "isolated Keychain must be an owner-private, non-symlink regular file owned by the current user",
1838        ));
1839    }
1840
1841    let canonical_root = std::fs::canonicalize(proof_root)?;
1842    let canonical_path = std::fs::canonicalize(path)?;
1843    if !canonical_path.starts_with(&canonical_root) || canonical_path == canonical_root {
1844        return Err(std::io::Error::new(
1845            std::io::ErrorKind::PermissionDenied,
1846            "isolated Keychain must be canonically contained by its proof root",
1847        ));
1848    }
1849    Ok(canonical_path)
1850}
1851
1852#[cfg(target_os = "macos")]
1853fn current_effective_uid() -> u32 {
1854    unsafe extern "C" {
1855        fn geteuid() -> u32;
1856    }
1857    // SAFETY: `geteuid` takes no arguments and has no preconditions.
1858    unsafe { geteuid() }
1859}
1860
1861#[cfg(target_os = "macos")]
1862const SECURITY_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
1863
1864/// Deadline used instead of [`SECURITY_CLI_TIMEOUT`] while macOS is showing a
1865/// keychain dialog. Generous on purpose: it is a bound on *human* response, not
1866/// on a hung process, and the cost of being too tight is that the credential
1867/// becomes permanently unreachable (see `bounded_command_output`).
1868#[cfg(target_os = "macos")]
1869const SECURITY_CLI_INTERACTIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
1870
1871/// Is macOS currently drawing a keychain prompt?
1872///
1873/// `SecurityAgent` is the process that renders the unlock / "allow access"
1874/// dialogs. Its presence is the difference between "the helper is hung" and
1875/// "the helper is waiting for the user" — a distinction the bare deadline
1876/// cannot make, and getting it wrong kills the dialog mid-typing.
1877///
1878/// Deliberately fail-CLOSED to the short deadline: if this cannot be
1879/// determined, treat the helper as non-interactive, so a lookup failure can
1880/// never extend a genuinely hung child to three minutes.
1881#[cfg(target_os = "macos")]
1882fn security_agent_is_prompting() -> bool {
1883    std::process::Command::new("/usr/bin/pgrep")
1884        .arg("-x")
1885        .arg("SecurityAgent")
1886        .stdout(std::process::Stdio::null())
1887        .stderr(std::process::Stdio::null())
1888        .status()
1889        .map(|s| s.success())
1890        .unwrap_or(false)
1891}
1892
1893/// How long a `security` call must still be running, with a dialog on screen,
1894/// before that dialog counts as evidence that *this* call is the one being
1895/// authorized.
1896///
1897/// `SecurityAgent` is machine-wide. It is up whenever anything on the Mac is
1898/// asking for authorization — an installer, a `sudo` GUI prompt, a browser
1899/// saving a password, or a *different* CAR process blocked on its own keychain
1900/// dialog. None of those say anything about the item this call is reading.
1901///
1902/// The duration is what makes the sighting attributable: a `security
1903/// find-generic-password -g` that nobody has to authorize returns in tens of
1904/// milliseconds (the v0.47.0 notes derive ~14ms per credential probe), and this
1905/// loop has already broken out on `try_wait` long before the
1906/// threshold. A call that genuinely drew a dialog is still running, because it
1907/// is blocked until a human answers.
1908///
1909/// Duration, rather than "SecurityAgent was absent at spawn and appeared
1910/// later": during the prompt storm this exists to cure, a dialog is *already*
1911/// up when the next read spawns, so a transition rule would go blind exactly
1912/// when it matters most.
1913///
1914/// **This is a heuristic with real margins, not a proof.** Half a second is
1915/// ~35× that derived per-probe cost, but a cold first read on a busy machine can be
1916/// far slower than that, so a false positive is still reachable — it is rarer,
1917/// not impossible. In the other direction the margin is thinner: a dialog has
1918/// to render (~100-200ms) and a human has to react (~250ms), so a very fast
1919/// repeat-click could finish inside 500ms and go unattributed. The asymmetry
1920/// says which way to lean, and it is why the threshold sits nearer the human
1921/// end than the middle: a missed repair costs one more dialog on the next read,
1922/// which repairs then; a false one rewrites a healthy credential and spends the
1923/// one repair attempt this process is allowed per item (see
1924/// `acl_repair_attempted`), so the stale item that prompts later never gets
1925/// fixed at all.
1926///
1927/// Known residual: a **locked** login keychain blocks every read past the
1928/// threshold, so an unlock dialog is attributed like an authorization one and
1929/// the repair fires on a healthy item. Unlocking is not an ACL condition and
1930/// this rule does not distinguish it; the cost is one needless rewrite plus
1931/// that item's repair attempt.
1932#[cfg(target_os = "macos")]
1933const PROMPT_EVIDENCE_MIN: std::time::Duration = std::time::Duration::from_millis(500);
1934
1935/// Does a dialog seen at `elapsed` into a still-running call belong to *this*
1936/// call?
1937///
1938/// Split out from the poll loop so the rule is testable without a real keychain
1939/// or a real dialog. See [`PROMPT_EVIDENCE_MIN`] for why both terms are needed.
1940#[cfg(target_os = "macos")]
1941fn dialog_is_evidence_for_this_read(dialog_on_screen: bool, elapsed: std::time::Duration) -> bool {
1942    dialog_on_screen && elapsed >= PROMPT_EVIDENCE_MIN
1943}
1944
1945/// A finished helper run, plus whether the dialog macOS drew was this run's.
1946#[cfg(target_os = "macos")]
1947#[derive(Debug)]
1948struct BoundedRun {
1949    output: std::process::Output,
1950    prompted: bool,
1951    timed_out: bool,
1952}
1953
1954/// Name the keychain item a `security` invocation is acting on, for the notice
1955/// below. `service/account` when both are present, else whichever is, else the
1956/// subcommand.
1957///
1958/// **Which item** is the datum that turns a repeat-prompt report into a
1959/// diagnosis, and it is the one thing the log never carried: the macOS dialog
1960/// names the item, but reading it requires being at the screen when it appears.
1961/// The reported scenario is precisely the one where nobody is — an operator
1962/// stepped away and came back to a stack of ~18 prompts, by which point the
1963/// only question that matters ("which item keeps asking?") is unanswerable
1964/// (Parslee-ai/car#897).
1965///
1966/// Reads only `-s`/`-a`, which are a service name and an account name. The
1967/// secret itself arrives on the child's stdout and is never touched here.
1968#[cfg(target_os = "macos")]
1969fn describe_item(args: &[&str]) -> String {
1970    let flag = |name: &str| {
1971        args.iter()
1972            .position(|a| *a == name)
1973            .and_then(|i| args.get(i + 1))
1974            .copied()
1975    };
1976    match (flag("-s"), flag("-a")) {
1977        (Some(service), Some(account)) => format!("{service}/{account}"),
1978        (Some(service), None) => service.to_string(),
1979        (None, Some(account)) => account.to_string(),
1980        // Not an item-scoped call (`unlock-keychain`, `list-keychains`, …).
1981        // Naming the subcommand still beats naming nothing.
1982        (None, None) => args.first().copied().unwrap_or("security").to_string(),
1983    }
1984}
1985
1986/// The line emitted the moment a keychain dialog is attributed to this read.
1987///
1988/// Split out so a test can assert the wording without a real dialog, and so the
1989/// promptly-emitted notice and the on-expiry message stay coherent — they
1990/// describe the same condition three minutes apart and must not drift into
1991/// giving different remedies.
1992#[cfg(target_os = "macos")]
1993fn keychain_prompt_notice(item: &str) -> String {
1994    format!(
1995        "waiting on a macOS keychain prompt for \"{item}\" (up to {}s) — CAR is not \
1996         hung. Click \"Always Allow\" on the dialog (it may be behind another \
1997         window), or grant the \"car\" service access in Keychain Access.",
1998        SECURITY_CLI_INTERACTIVE_TIMEOUT.as_secs()
1999    )
2000}
2001
2002#[cfg(target_os = "macos")]
2003fn bounded_command_output(
2004    command: &mut std::process::Command,
2005    timeout: std::time::Duration,
2006    item: &str,
2007) -> std::io::Result<BoundedRun> {
2008    bounded_command_output_with(command, timeout, security_agent_is_prompting, || {
2009        // `tracing`, not `eprintln!`, and it reaches both audiences that matter
2010        // here: `car-cli` installs a stderr `fmt` layer defaulting to `info`
2011        // (`car_telemetry::init_tracing`), so a person at a terminal sees the
2012        // line; and a daemon blocked the same way gets it in its log, which is
2013        // the second half of what Parslee-ai/car#878 asked for — and the only
2014        // channel that survives an operator who has walked away (#897).
2015        tracing::warn!("{}", keychain_prompt_notice(item));
2016    })
2017}
2018
2019/// `bounded_command_output` with the dialog probe and the notice sink injected.
2020///
2021/// The probe is a parameter so a test can hold "a dialog is on screen" true for
2022/// the whole run. Without that, every assertion about attribution is decided by
2023/// whether the machine running the tests happens to have an authorization
2024/// dialog up — which on CI it never does, so the test would pass whatever the
2025/// attribution rule said, including no rule at all.
2026///
2027/// `on_waiting_for_user` is injected for the same reason one step further on:
2028/// the property worth testing is that it fires **exactly once** per blocked
2029/// read, and a counter is the only honest way to assert that.
2030#[cfg(target_os = "macos")]
2031fn bounded_command_output_with(
2032    command: &mut std::process::Command,
2033    timeout: std::time::Duration,
2034    dialog_probe: impl Fn() -> bool,
2035    on_waiting_for_user: impl Fn(),
2036) -> std::io::Result<BoundedRun> {
2037    use std::io::Read;
2038    use std::process::Stdio;
2039    use std::time::Instant;
2040
2041    command.stdout(Stdio::piped()).stderr(Stdio::piped());
2042    let mut child = command.spawn()?;
2043    let stdout = child
2044        .stdout
2045        .take()
2046        .ok_or_else(|| std::io::Error::other("keychain helper stdout was not piped"))?;
2047    let stderr = child
2048        .stderr
2049        .take()
2050        .ok_or_else(|| std::io::Error::other("keychain helper stderr was not piped"))?;
2051    let stdout_reader = std::thread::spawn(move || {
2052        let mut bytes = Vec::new();
2053        let mut stdout = stdout;
2054        stdout.read_to_end(&mut bytes)?;
2055        Ok::<_, std::io::Error>(bytes)
2056    });
2057    let stderr_reader = std::thread::spawn(move || {
2058        let mut bytes = Vec::new();
2059        let mut stderr = stderr;
2060        stderr.read_to_end(&mut bytes)?;
2061        Ok::<_, std::io::Error>(bytes)
2062    });
2063    let started = Instant::now();
2064    // Sticky: once a dialog is attributed to this run it stays attributed, and
2065    // SecurityAgent exits as soon as the user answers.
2066    let mut prompted = false;
2067    let (status, timed_out) = loop {
2068        if let Some(status) = child.try_wait()? {
2069            break (status, false);
2070        }
2071        // The deadline exists to bound a HUNG helper. A helper waiting on the
2072        // user is not hung, and killing it there is actively harmful: macOS
2073        // shows the unlock/authorize dialog, the person starts typing, the
2074        // deadline fires, and the dialog is torn down before the unlock can
2075        // commit. The next request opens a fresh dialog, so entering the
2076        // correct password over and over never succeeds — the timeout makes the
2077        // prompt UNSATISFIABLE rather than merely noisy. Nobody reliably finds a
2078        // window, types a password and submits inside 15s, let alone repeatedly.
2079        //
2080        // So while SecurityAgent (the process that draws those dialogs) is up,
2081        // extend to `SECURITY_CLI_INTERACTIVE_TIMEOUT`. A genuinely hung helper
2082        // has no dialog and still dies at the short deadline.
2083        let dialog_on_screen = dialog_probe();
2084        // Extending the deadline on any sighting is the right call — waiting
2085        // longer is cheap and reversible, and the cost of being wrong the other
2086        // way is a torn-down dialog nobody can satisfy.
2087        //
2088        // Treating that same sighting as proof that THIS read is the one being
2089        // authorized is not cheap: it sends the value through
2090        // `mac_repair_item_acl_with`, which delete-then-adds the item. On a
2091        // healthy credential that is a pointless rewrite, and on the Parslee
2092        // auth record it reopens the delete→add window `publish` exists to
2093        // avoid — a concurrent reader landing in it sees no credential and
2094        // reports a spurious sign-out mid token-refresh. It also spends the one
2095        // repair attempt allowed per item per process on the wrong item.
2096        //
2097        // The two conditions differ in what they need to be true, so they get
2098        // different evidence. See `dialog_is_evidence_for_this_read`.
2099        if !prompted && dialog_is_evidence_for_this_read(dialog_on_screen, started.elapsed()) {
2100            prompted = true;
2101            // Say so NOW, not on expiry (Parslee-ai/car#878). Everything needed
2102            // to explain the wait is true at this instant, and the on-expiry
2103            // message below only prints if the user waits out the full 180s —
2104            // so an operator who kills the "hung" command at two minutes, or a
2105            // wrapper/CI step with a shorter timeout, never saw it at all. That
2106            // is the reported experience exactly: three runs killed, three
2107            // fresh dialogs raised, and the explanation the code was ready to
2108            // give never reached anyone.
2109            //
2110            // Fires on the ATTRIBUTED predicate rather than a bare sighting.
2111            // `SecurityAgent` is machine-wide (Parslee-ai/car#897), so a bare
2112            // sighting can belong to another process's dialog; requiring that
2113            // this read has also been blocked for `PROMPT_EVIDENCE_MIN` keeps
2114            // the notice off fast reads that merely coincided with someone
2115            // else's prompt. Reusing `prompted` as the latch is deliberate —
2116            // one blocked read, one line, and no second piece of state that
2117            // could disagree with it.
2118            on_waiting_for_user();
2119        }
2120        let deadline = if dialog_on_screen {
2121            SECURITY_CLI_INTERACTIVE_TIMEOUT
2122        } else {
2123            timeout
2124        };
2125        if started.elapsed() >= deadline {
2126            let _ = child.kill();
2127            break (child.wait()?, true);
2128        }
2129        std::thread::sleep(std::time::Duration::from_millis(10));
2130    };
2131    let join_reader = |reader: std::thread::JoinHandle<std::io::Result<Vec<u8>>>,
2132                       stream: &str|
2133     -> std::io::Result<Vec<u8>> {
2134        reader.join().map_err(|_| {
2135            std::io::Error::other(format!("keychain helper {stream} reader panicked"))
2136        })?
2137    };
2138    let stdout = join_reader(stdout_reader, "stdout")?;
2139    let mut stderr = join_reader(stderr_reader, "stderr")?;
2140    if timed_out {
2141        // Name the cause. `security` blocks here when macOS is showing a
2142        // keychain authorization dialog, so the deadline is nearly always "a
2143        // prompt nobody clicked" rather than a hung helper. Without saying so,
2144        // this surfaces to the user as `no inference backend is available` —
2145        // which points at models and accounts, i.e. everywhere except the
2146        // dialog actually waiting on screen.
2147        stderr.extend_from_slice(
2148            format!(
2149                "\nCAR killed the keychain helper after {}ms. This usually means a macOS \
2150                 keychain prompt is open and waiting: click \"Always Allow\" (or grant access \
2151                 to the \"car\" service in Keychain Access). Until it is answered, CAR cannot \
2152                 read your saved credentials and will report that no account is signed in.",
2153                timeout.as_millis()
2154            )
2155            .as_bytes(),
2156        );
2157    }
2158    Ok(BoundedRun {
2159        output: std::process::Output {
2160            status,
2161            stdout,
2162            stderr,
2163        },
2164        prompted,
2165        timed_out,
2166    })
2167}
2168
2169/// Primary macOS value read:
2170/// `/usr/bin/security find-generic-password -s SVC -a KEY -g`.
2171/// `-g` prints the password metadata line to stderr and preserves the
2172/// password bytes as hex when the value contains non-printable UTF-8
2173/// bytes. Service/key are passed as separate argv values, never
2174/// interpolated into a shell, so there's no injection surface even if a
2175/// key contains shell metacharacters.
2176#[cfg(target_os = "macos")]
2177fn mac_get_via_security_cli(r: &SecretRef) -> Result<String, SecretError> {
2178    mac_get_via_security_cli_with(r, &SystemSecurityCli)
2179}
2180
2181/// Opt out of the ACL repair below: `CAR_KEYCHAIN_NO_ACL_REPAIR=1`.
2182///
2183/// An escape hatch rather than a setting. Repair rewrites a keychain item, and
2184/// an operator who does not want CAR touching their keychain — a shared machine,
2185/// a managed item, an audit — deserves a way to say so without giving up
2186/// credential reads entirely.
2187#[cfg(target_os = "macos")]
2188const ACL_REPAIR_OPT_OUT_ENV: &str = "CAR_KEYCHAIN_NO_ACL_REPAIR";
2189
2190/// Refs this process has already tried to repair.
2191///
2192/// At most one attempt per ref per process. If a repair does not take, the next
2193/// read would prompt again and re-attempt forever — turning one bad dialog into
2194/// an endless pair of them. One attempt, then let it be.
2195#[cfg(target_os = "macos")]
2196fn acl_repair_attempted() -> &'static std::sync::Mutex<std::collections::HashSet<(String, String)>>
2197{
2198    static ATTEMPTED: std::sync::OnceLock<
2199        std::sync::Mutex<std::collections::HashSet<(String, String)>>,
2200    > = std::sync::OnceLock::new();
2201    ATTEMPTED.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
2202}
2203
2204/// Rewrite an item so its ACL stops being bound to a binary that no longer
2205/// matches.
2206///
2207/// ## Why this exists
2208///
2209/// The legacy keychain binds an item to the **code-signing hash** of whatever
2210/// wrote it. That hash changes on every release and every rebuild, so an item
2211/// written by an older CAR is unreadable-without-asking by a newer one, forever:
2212/// macOS prompts on every single read. Writes have passed `-A` (permissive ACL)
2213/// since #142 (v0.5.2) and have pre-deleted first — so the flag lands on a
2214/// genuine *create*, since `-U` on an existing item ignores it — since v0.18.0.
2215/// Both only help items written *after* them; nothing ever repaired the ones
2216/// already on disk, so anyone who signed in before v0.41.0 kept being prompted
2217/// with no way out except signing out and back in.
2218///
2219/// A successful read that had to prompt is proof of exactly that condition, and
2220/// it hands us the value. So repair it in place: one dialog, then silence.
2221///
2222/// ## Why it cannot lose the credential
2223///
2224/// Repair is delete-then-add, which has a window where the item does not exist.
2225/// Losing a credential here would be far worse than the prompt it cures, so:
2226///
2227/// - it runs **only** with a non-empty value already in hand;
2228/// - after writing it **reads back** and compares, and if that does not match it
2229///   re-adds once more;
2230/// - every failure path is swallowed. The caller's read already succeeded, and a
2231///   failed repair must never turn a working read into an error.
2232#[cfg(target_os = "macos")]
2233fn mac_repair_item_acl_with(r: &SecretRef, value: &str, cli: &impl SecurityCli) {
2234    if value.is_empty() {
2235        return;
2236    }
2237    if std::env::var(ACL_REPAIR_OPT_OUT_ENV).is_ok_and(|v| v == "1") {
2238        return;
2239    }
2240    {
2241        let mut attempted = match acl_repair_attempted().lock() {
2242            Ok(guard) => guard,
2243            Err(poisoned) => poisoned.into_inner(),
2244        };
2245        if !attempted.insert((r.service.clone(), r.key.clone())) {
2246            return;
2247        }
2248    }
2249
2250    if mac_put_via_security_cli_with(&r.service, &r.key, value, cli).is_err() {
2251        // The add failed. If the pre-delete had already landed the item is now
2252        // gone, so put the value back rather than leaving the user signed out.
2253        let _ = mac_put_via_security_cli_with(&r.service, &r.key, value, cli);
2254        return;
2255    }
2256
2257    // Verify rather than assume. A silent mismatch here would mean we replaced a
2258    // working credential with something else.
2259    let restored = matches!(
2260        mac_get_via_security_cli_raw(r, cli),
2261        Ok(ref got) if got == value
2262    );
2263    if !restored {
2264        let _ = mac_put_via_security_cli_with(&r.service, &r.key, value, cli);
2265    }
2266}
2267
2268/// The read itself, with no repair attached. Split out so the repair path can
2269/// verify its own work without recursing back into it.
2270#[cfg(target_os = "macos")]
2271fn mac_get_via_security_cli_raw(
2272    r: &SecretRef,
2273    cli: &impl SecurityCli,
2274) -> Result<String, SecretError> {
2275    let output = cli
2276        .output(&[
2277            "find-generic-password",
2278            "-s",
2279            &r.service,
2280            "-a",
2281            &r.key,
2282            "-g",
2283        ])
2284        .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
2285    if !output.success {
2286        return security_cli_not_found_or_backend("find-generic-password", r, output);
2287    }
2288    mac_parse_security_cli_password(&output)
2289}
2290
2291#[cfg(target_os = "macos")]
2292fn mac_get_via_security_cli_with(
2293    r: &SecretRef,
2294    cli: &impl SecurityCli,
2295) -> Result<String, SecretError> {
2296    let output = cli
2297        .output(&[
2298            "find-generic-password",
2299            "-s",
2300            &r.service,
2301            "-a",
2302            &r.key,
2303            "-g",
2304        ])
2305        .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
2306    if !output.success {
2307        return security_cli_not_found_or_backend("find-generic-password", r, output);
2308    }
2309    let prompted = output.prompted;
2310    let value = mac_parse_security_cli_password(&output)?;
2311    // A read that had to ask is the evidence of a stale, hash-bound ACL. Repair
2312    // it now, while the value is in hand, so the next read is silent.
2313    if prompted {
2314        mac_repair_item_acl_with(r, &value, cli);
2315    }
2316    Ok(value)
2317}
2318
2319#[cfg(target_os = "macos")]
2320fn mac_parse_security_cli_password(output: &SecurityCliOutput) -> Result<String, SecretError> {
2321    let line = mac_security_cli_text(&output.stderr, "stderr")?
2322        .lines()
2323        .find(|line| line.starts_with("password:"))
2324        .or_else(|| {
2325            mac_security_cli_text(&output.stdout, "stdout")
2326                .ok()
2327                .and_then(|stdout| stdout.lines().find(|line| line.starts_with("password:")))
2328        })
2329        .ok_or_else(|| {
2330            SecretError::Backend(
2331                "/usr/bin/security find-generic-password -g did not print a password line"
2332                    .to_string(),
2333            )
2334        })?;
2335
2336    let payload = line
2337        .strip_prefix("password:")
2338        .expect("password line prefix was checked")
2339        .trim_start();
2340
2341    if payload.is_empty() {
2342        return Ok(String::new());
2343    }
2344
2345    let bytes = if let Some(hex_and_preview) = payload.strip_prefix("0x") {
2346        mac_decode_security_cli_hex_password(hex_and_preview)?
2347    } else {
2348        mac_decode_security_cli_quoted_password(payload)?
2349    };
2350
2351    String::from_utf8(bytes).map_err(|e| {
2352        SecretError::Backend(format!(
2353            "/usr/bin/security find-generic-password password was not valid utf-8: {}",
2354            e
2355        ))
2356    })
2357}
2358
2359#[cfg(target_os = "macos")]
2360fn mac_security_cli_text<'a>(bytes: &'a [u8], stream: &str) -> Result<&'a str, SecretError> {
2361    std::str::from_utf8(bytes).map_err(|e| {
2362        SecretError::Backend(format!(
2363            "/usr/bin/security find-generic-password {stream} was not valid utf-8: {e}"
2364        ))
2365    })
2366}
2367
2368#[cfg(target_os = "macos")]
2369fn mac_decode_security_cli_hex_password(hex_and_preview: &str) -> Result<Vec<u8>, SecretError> {
2370    let hex: String = hex_and_preview
2371        .chars()
2372        .take_while(|c| c.is_ascii_hexdigit())
2373        .collect();
2374    if hex.is_empty() || !hex.len().is_multiple_of(2) {
2375        return Err(SecretError::Backend(format!(
2376            "/usr/bin/security find-generic-password printed invalid password hex: {hex:?}"
2377        )));
2378    }
2379
2380    (0..hex.len())
2381        .step_by(2)
2382        .map(|i| {
2383            u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| {
2384                SecretError::Backend(format!(
2385                    "/usr/bin/security find-generic-password printed invalid password hex: {e}"
2386                ))
2387            })
2388        })
2389        .collect()
2390}
2391
2392#[cfg(target_os = "macos")]
2393fn mac_decode_security_cli_quoted_password(payload: &str) -> Result<Vec<u8>, SecretError> {
2394    let quoted = payload.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
2395    match quoted {
2396        Some(value) => Ok(value.as_bytes().to_vec()),
2397        None => Err(SecretError::Backend(
2398            "/usr/bin/security find-generic-password printed an unrecognized password line"
2399                .to_string(),
2400        )),
2401    }
2402}
2403
2404#[cfg(target_os = "macos")]
2405fn mac_status_via_security_cli(r: &SecretRef) -> Result<SecretStatus, SecretError> {
2406    mac_status_via_security_cli_with(r, &SystemSecurityCli)
2407}
2408
2409#[cfg(target_os = "macos")]
2410fn mac_status_via_security_cli_with(
2411    r: &SecretRef,
2412    cli: &impl SecurityCli,
2413) -> Result<SecretStatus, SecretError> {
2414    let exists = mac_exists_via_security_cli_with(r, cli)?;
2415    Ok(SecretStatus {
2416        service: r.service.clone(),
2417        key: r.key.clone(),
2418        exists,
2419    })
2420}
2421
2422/// Existence-only shell-out: `security find-generic-password -s SVC -a KEY`
2423/// (no `-w`). Exit 0 means found, exit 44 means absent. Other non-zero
2424/// exits are backend/authorization errors and must not fall through to
2425/// an in-process API that can prompt again under the caller binary's CDHash.
2426#[cfg(target_os = "macos")]
2427fn mac_exists_via_security_cli_with(
2428    r: &SecretRef,
2429    cli: &impl SecurityCli,
2430) -> Result<bool, SecretError> {
2431    let output = cli
2432        .output(&["find-generic-password", "-s", &r.service, "-a", &r.key])
2433        .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
2434    if output.success {
2435        return Ok(true);
2436    }
2437    if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2438        return Ok(false);
2439    }
2440    Err(security_cli_backend_error("find-generic-password", output))
2441}
2442
2443#[cfg(target_os = "macos")]
2444fn mac_delete_via_security_cli(r: &SecretRef) -> Result<(), SecretError> {
2445    mac_delete_via_security_cli_with(r, &SystemSecurityCli)
2446}
2447
2448/// Primary macOS delete. Treats "no such item" as success to preserve
2449/// the public idempotent delete contract.
2450#[cfg(target_os = "macos")]
2451fn mac_delete_via_security_cli_with(
2452    r: &SecretRef,
2453    cli: &impl SecurityCli,
2454) -> Result<(), SecretError> {
2455    let output = cli
2456        .output(&["delete-generic-password", "-s", &r.service, "-a", &r.key])
2457        .map_err(|e| security_cli_spawn_error("delete-generic-password", e))?;
2458    if output.success || output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2459        return Ok(());
2460    }
2461    Err(security_cli_backend_error(
2462        "delete-generic-password",
2463        output,
2464    ))
2465}
2466
2467#[cfg(target_os = "macos")]
2468fn security_cli_not_found_or_backend<T>(
2469    command: &str,
2470    r: &SecretRef,
2471    output: SecurityCliOutput,
2472) -> Result<T, SecretError> {
2473    if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2474        return Err(SecretError::NotFound {
2475            service: r.service.clone(),
2476            key: r.key.clone(),
2477        });
2478    }
2479    Err(security_cli_backend_error(command, output))
2480}
2481
2482#[cfg(target_os = "macos")]
2483fn security_cli_spawn_error(command: &str, e: std::io::Error) -> SecretError {
2484    SecretError::Backend(format!("/usr/bin/security {command} spawn: {e}"))
2485}
2486
2487#[cfg(target_os = "macos")]
2488fn security_cli_backend_error(command: &str, output: SecurityCliOutput) -> SecretError {
2489    let stderr = String::from_utf8_lossy(&output.stderr);
2490    if output.timed_out {
2491        return classify_helper_timeout(command);
2492    }
2493    let code = output.code.unwrap_or(-1);
2494    match classify_security_error(code, stderr.trim()) {
2495        SecretError::Backend(_) => SecretError::Backend(format!(
2496            "/usr/bin/security {command} failed: code={code} {}",
2497            stderr.trim()
2498        )),
2499        typed => typed,
2500    }
2501}
2502
2503#[cfg(target_os = "macos")]
2504fn classify_security_error(code: i32, detail: &str) -> SecretError {
2505    let normalized = detail.to_ascii_lowercase();
2506    if code == -128 || (code == 128 && normalized.contains("cancel")) {
2507        return SecretError::UserCancelled {
2508            message: detail.to_string(),
2509        };
2510    }
2511    if code == -25293
2512        || code == 51
2513        || normalized.contains("authorization denied")
2514        || normalized.contains("auth denied")
2515        || normalized.contains("interaction is not allowed")
2516    {
2517        return SecretError::AccessDenied {
2518            message: detail.to_string(),
2519        };
2520    }
2521    SecretError::Backend(format!("macOS security error: code={code} {detail}"))
2522}
2523
2524#[cfg(target_os = "macos")]
2525fn classify_helper_timeout(operation: &str) -> SecretError {
2526    SecretError::HelperTimedOut {
2527        operation: operation.to_string(),
2528    }
2529}
2530
2531/// Map keyring crate errors into our typed error set.
2532///
2533/// Not compiled on macOS: nothing there reaches the keyring crate, so there
2534/// are no keyring errors to classify. The macOS equivalents are
2535/// `security_cli_backend_error` and `security_cli_not_found_or_backend`.
2536#[cfg(not(target_os = "macos"))]
2537fn classify(e: keyring::Error, op: &str) -> SecretError {
2538    use keyring::Error as K;
2539    match e {
2540        K::NoEntry => SecretError::NotFound {
2541            service: String::new(),
2542            key: String::new(),
2543        },
2544        K::PlatformFailure(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
2545        K::NoStorageAccess(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
2546        K::BadEncoding(_) => SecretError::Backend(format!("{}: value encoding", op)),
2547        other => SecretError::Backend(format!("{}: {}", op, other)),
2548    }
2549}
2550
2551#[cfg(test)]
2552mod chunk_tests {
2553    use super::*;
2554    use std::collections::BTreeMap;
2555
2556    #[test]
2557    fn split_on_chars_covers_boundaries() {
2558        assert_eq!(split_on_chars("", 3), Vec::<String>::new());
2559        assert_eq!(split_on_chars("abc", 3), vec!["abc"]);
2560        assert_eq!(split_on_chars("abcd", 3), vec!["abc", "d"]);
2561        assert_eq!(split_on_chars("abcdef", 2), vec!["ab", "cd", "ef"]);
2562        // Reassembly is lossless for a value well past the Windows blob cap.
2563        let big: String = "x".repeat(4000);
2564        let joined: String = split_on_chars(&big, CHUNK_CHARS).concat();
2565        assert_eq!(joined, big);
2566    }
2567
2568    #[test]
2569    fn sentinel_round_trips_the_chunk_count() {
2570        let n = split_on_chars(&"y".repeat(3300), CHUNK_CHARS).len();
2571        let sentinel = format!("{CHUNK_SENTINEL}{n}");
2572        let parsed = sentinel
2573            .strip_prefix(CHUNK_SENTINEL)
2574            .and_then(|s| s.parse::<usize>().ok());
2575        assert_eq!(parsed, Some(4)); // 3300 / 1000 -> 4 chunks
2576                                     // A real (non-chunked) value is never mistaken for a sentinel.
2577        assert!("eyJhbGciOi.reallongjwt"
2578            .strip_prefix(CHUNK_SENTINEL)
2579            .is_none());
2580    }
2581
2582    #[test]
2583    fn threshold_leaves_small_values_inline() {
2584        // A value at/under the threshold must NOT be chunked (single entry,
2585        // backward compatible with pre-existing secrets).
2586        assert!("short-api-key".encode_utf16().count() <= CHUNK_THRESHOLD_UTF16);
2587        assert!("z".repeat(2001).encode_utf16().count() > CHUNK_THRESHOLD_UTF16);
2588    }
2589
2590    #[derive(Debug, Clone)]
2591    struct FailureRule {
2592        slot: WindowsCredentialSlot,
2593        matches_to_skip: usize,
2594    }
2595
2596    #[derive(Debug, Clone, Default)]
2597    struct MemoryWindowsBackend {
2598        entries: BTreeMap<WindowsCredentialSlot, String>,
2599        mutation_calls: usize,
2600        crash_after_mutation: Option<usize>,
2601        fail_write: Option<FailureRule>,
2602        fail_delete: Option<FailureRule>,
2603    }
2604
2605    impl MemoryWindowsBackend {
2606        fn after_mutation(&mut self) {
2607            self.mutation_calls += 1;
2608            if self.crash_after_mutation == Some(self.mutation_calls) {
2609                panic!("injected Windows credential process crash");
2610            }
2611        }
2612
2613        fn should_fail(rule: &mut Option<FailureRule>, slot: &WindowsCredentialSlot) -> bool {
2614            let Some(candidate) = rule.as_mut() else {
2615                return false;
2616            };
2617            if &candidate.slot != slot {
2618                return false;
2619            }
2620            if candidate.matches_to_skip > 0 {
2621                candidate.matches_to_skip -= 1;
2622                return false;
2623            }
2624            *rule = None;
2625            true
2626        }
2627
2628        fn reset_faults(&mut self) {
2629            self.mutation_calls = 0;
2630            self.crash_after_mutation = None;
2631            self.fail_write = None;
2632            self.fail_delete = None;
2633        }
2634
2635        fn root(&self) -> String {
2636            self.entries
2637                .get(&WindowsCredentialSlot::Root)
2638                .expect("root credential")
2639                .clone()
2640        }
2641    }
2642
2643    impl WindowsCredentialBackend for MemoryWindowsBackend {
2644        fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
2645            Ok(self.entries.get(slot).cloned())
2646        }
2647
2648        fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
2649            if Self::should_fail(&mut self.fail_write, slot) {
2650                return Err(SecretError::Backend(
2651                    "injected Windows credential write failure".to_string(),
2652                ));
2653            }
2654            self.entries.insert(slot.clone(), value.to_string());
2655            self.after_mutation();
2656            Ok(())
2657        }
2658
2659        fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
2660            if Self::should_fail(&mut self.fail_delete, slot) {
2661                return Err(SecretError::Backend(
2662                    "injected Windows credential cleanup failure".to_string(),
2663                ));
2664            }
2665            self.entries.remove(slot);
2666            self.after_mutation();
2667            Ok(())
2668        }
2669    }
2670
2671    fn publish(backend: &mut MemoryWindowsBackend, value: &str) -> WindowsCleanupReport {
2672        publish_windows_value(backend, value).expect("publication")
2673    }
2674
2675    fn read(backend: &mut impl WindowsCredentialBackend) -> String {
2676        read_windows_value(backend)
2677            .expect("read succeeds")
2678            .expect("root exists")
2679    }
2680
2681    fn legacy_v2(value: &str, nonce: &str) -> MemoryWindowsBackend {
2682        let mut backend = MemoryWindowsBackend::default();
2683        let chunks = split_on_chars(value, CHUNK_CHARS);
2684        backend.entries.insert(
2685            WindowsCredentialSlot::Root,
2686            format!("{CHUNK_SENTINEL_V2}{nonce}:{}", chunks.len()),
2687        );
2688        for (index, chunk) in chunks.into_iter().enumerate() {
2689            backend.entries.insert(
2690                WindowsCredentialSlot::LegacyV2Chunk {
2691                    nonce: nonce.to_string(),
2692                    index,
2693                },
2694                chunk,
2695            );
2696        }
2697        backend
2698    }
2699
2700    fn assert_backend_error(error: SecretError, needle: &str) {
2701        match error {
2702            SecretError::Backend(message) => assert!(message.contains(needle), "{message}"),
2703            other => panic!("expected backend error, got {other:?}"),
2704        }
2705    }
2706
2707    #[test]
2708    fn v3_publication_uses_revisioned_dual_generation_roots() {
2709        let value = "v".repeat(3300);
2710        let plan = chunk_publication_plan(&value, ChunkGeneration::B, "revision-7").unwrap();
2711
2712        assert_eq!(plan.generation, ChunkGeneration::B);
2713        assert_eq!(plan.chunks.concat(), value);
2714        assert_eq!(
2715            windows_root_layout(&plan.root).unwrap(),
2716            WindowsRootLayout::V3 {
2717                generation: ChunkGeneration::B,
2718                revision: "revision-7".to_string(),
2719                count: 4,
2720            }
2721        );
2722        assert!(
2723            plan.chunks
2724                .iter()
2725                .all(|chunk| chunk.encode_utf16().count() <= CHUNK_CHARS),
2726            "every staged credential must remain below the platform cap"
2727        );
2728    }
2729
2730    #[test]
2731    fn reader_capturing_old_root_finishes_after_writer_swaps_root() {
2732        let old = "old-".repeat(900);
2733        let new = "new-".repeat(900);
2734        let mut backend = MemoryWindowsBackend::default();
2735        publish(&mut backend, &old);
2736
2737        let mut reader = InterleavingReader::new(backend, vec![new.as_str()]);
2738        assert_eq!(read(&mut reader), old);
2739        assert_eq!(read(&mut reader.inner), new);
2740    }
2741
2742    #[test]
2743    fn reader_detects_generation_aba_and_retries_latest_root() {
2744        let old = "old-".repeat(900);
2745        let middle = "mid-".repeat(1100);
2746        let latest = "latest-".repeat(700);
2747        let mut backend = MemoryWindowsBackend::default();
2748        publish(&mut backend, &old);
2749
2750        let mut reader = InterleavingReader::new(backend, vec![middle.as_str(), latest.as_str()]);
2751        assert_eq!(read(&mut reader), latest);
2752        assert!(reader.root_reads >= 4, "the ABA path must consume a retry");
2753    }
2754
2755    #[test]
2756    fn legacy_nonce_chunks_survive_the_first_v3_root_swap_then_recover() {
2757        let old = "legacy-".repeat(700);
2758        let replacement = "replacement-".repeat(500);
2759        let followup = "followup-".repeat(500);
2760        let backend = legacy_v2(&old, "legacy-nonce");
2761
2762        let mut reader = InterleavingReader::new(backend, vec![replacement.as_str()]);
2763        assert_eq!(read(&mut reader), old);
2764        assert!(reader
2765            .inner
2766            .entries
2767            .contains_key(&WindowsCredentialSlot::RetiredV2Manifest));
2768        assert!(reader
2769            .inner
2770            .entries
2771            .contains_key(&WindowsCredentialSlot::LegacyV2Chunk {
2772                nonce: "legacy-nonce".to_string(),
2773                index: 0,
2774            }));
2775
2776        publish(&mut reader.inner, &followup);
2777        assert!(!reader
2778            .inner
2779            .entries
2780            .contains_key(&WindowsCredentialSlot::RetiredV2Manifest));
2781        assert!(!reader.inner.entries.keys().any(|slot| matches!(
2782            slot,
2783            WindowsCredentialSlot::LegacyV2Chunk { nonce, .. } if nonce == "legacy-nonce"
2784        )));
2785    }
2786
2787    #[test]
2788    fn crash_after_every_publish_mutation_preserves_a_readable_generation() {
2789        let old = "old-".repeat(1200);
2790        let current = "current-".repeat(900);
2791        let replacement = "replacement-".repeat(300);
2792        let mut base = MemoryWindowsBackend::default();
2793        publish(&mut base, &old);
2794        publish(&mut base, &current);
2795        base.reset_faults();
2796
2797        let mut successful = base.clone();
2798        publish(&mut successful, &replacement);
2799        let mutation_count = successful.mutation_calls;
2800        assert!(mutation_count >= 7, "exercise stage, commit, and cleanup");
2801
2802        for crash_after in 1..=mutation_count {
2803            let mut crashed = base.clone();
2804            crashed.crash_after_mutation = Some(crash_after);
2805            let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2806                let _ = publish_windows_value(&mut crashed, &replacement);
2807            }));
2808            assert!(unwind.is_err(), "mutation {crash_after} must crash");
2809            crashed.reset_faults();
2810
2811            let observed = read(&mut crashed);
2812            assert!(
2813                observed == current || observed == replacement,
2814                "crash {crash_after} exposed neither committed generation"
2815            );
2816
2817            publish(&mut crashed, &replacement);
2818            publish(&mut crashed, "recovery-pass");
2819            publish(&mut crashed, &replacement);
2820            assert_eq!(read(&mut crashed), replacement);
2821            assert!(
2822                crashed.entries.len() <= 20,
2823                "crash {crash_after} leaked unbounded entries: {:?}",
2824                crashed.entries.keys().collect::<Vec<_>>()
2825            );
2826        }
2827    }
2828
2829    #[test]
2830    fn repeated_precommit_crashes_have_bounded_cardinality_and_recover_cleanup() {
2831        let old = "old-".repeat(900);
2832        let attempted = "attempted-".repeat(900);
2833        let recovered = "ok-".repeat(600);
2834        let attempted_chunks = split_on_chars(&attempted, CHUNK_CHARS).len();
2835        let old_chunks = split_on_chars(&old, CHUNK_CHARS).len();
2836        let mut backend = MemoryWindowsBackend::default();
2837        publish(&mut backend, &old);
2838
2839        for crash_index in 0..64 {
2840            backend.reset_faults();
2841            backend.crash_after_mutation = Some(1 + crash_index % attempted_chunks);
2842            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2843                let _ = publish_windows_value(&mut backend, &attempted);
2844            }));
2845            assert!(
2846                backend.entries.len() <= 1 + 2 + old_chunks + attempted_chunks,
2847                "attempt {crash_index} grew deterministic storage"
2848            );
2849        }
2850
2851        backend.reset_faults();
2852        publish(&mut backend, &recovered);
2853        assert_eq!(read(&mut backend), recovered);
2854        let recovered_chunks = split_on_chars(&recovered, CHUNK_CHARS).len();
2855        assert!(!backend.entries.keys().any(|slot| matches!(
2856            slot,
2857            WindowsCredentialSlot::V3Chunk {
2858                generation: ChunkGeneration::B,
2859                index,
2860            } if *index >= recovered_chunks
2861        )));
2862        assert_eq!(
2863            backend
2864                .entries
2865                .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::B)),
2866            Some(&recovered_chunks.to_string())
2867        );
2868    }
2869
2870    #[test]
2871    fn staging_and_root_failures_leave_the_only_good_generation_readable() {
2872        let old = "old-".repeat(900);
2873        let replacement = "replacement-".repeat(500);
2874        for failed_slot in [
2875            WindowsCredentialSlot::V3Chunk {
2876                generation: ChunkGeneration::B,
2877                index: 1,
2878            },
2879            WindowsCredentialSlot::Root,
2880        ] {
2881            let mut backend = MemoryWindowsBackend::default();
2882            publish(&mut backend, &old);
2883            backend.fail_write = Some(FailureRule {
2884                slot: failed_slot,
2885                matches_to_skip: 0,
2886            });
2887
2888            let error = publish_windows_value(&mut backend, &replacement).unwrap_err();
2889            assert_backend_error(error, "injected");
2890            assert_eq!(read(&mut backend), old);
2891        }
2892    }
2893
2894    #[test]
2895    fn postcommit_cleanup_errors_report_deferred_success_and_recover_later() {
2896        let old = "old-".repeat(1400);
2897        let current = "current-".repeat(900);
2898        let replacement = "replacement-".repeat(200);
2899        let mut backend = MemoryWindowsBackend::default();
2900        publish(&mut backend, &old);
2901        publish(&mut backend, &current);
2902        backend.fail_delete = Some(FailureRule {
2903            slot: WindowsCredentialSlot::V3Chunk {
2904                generation: ChunkGeneration::A,
2905                index: 4,
2906            },
2907            matches_to_skip: 0,
2908        });
2909
2910        let cleanup = publish_windows_value(&mut backend, &replacement).unwrap();
2911        assert_eq!(cleanup.failures, 1);
2912        assert_eq!(read(&mut backend), replacement);
2913        assert_eq!(
2914            backend
2915                .entries
2916                .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)),
2917            Some(&split_on_chars(&old, CHUNK_CHARS).len().to_string()),
2918            "failed cleanup keeps the crash high-water for a later sweep"
2919        );
2920
2921        publish(&mut backend, "rotate-once");
2922        publish(&mut backend, &replacement);
2923        assert!(!backend.entries.keys().any(|slot| matches!(
2924            slot,
2925            WindowsCredentialSlot::V3Chunk {
2926                generation: ChunkGeneration::A,
2927                index,
2928            } if *index >= split_on_chars(&replacement, CHUNK_CHARS).len()
2929        )));
2930    }
2931
2932    #[test]
2933    fn postcommit_manifest_shrink_failure_keeps_recovery_high_water() {
2934        let old = "old-".repeat(1400);
2935        let current = "current-".repeat(900);
2936        let replacement = "replacement-".repeat(200);
2937        let mut backend = MemoryWindowsBackend::default();
2938        publish(&mut backend, &old);
2939        publish(&mut backend, &current);
2940        backend.fail_write = Some(FailureRule {
2941            slot: WindowsCredentialSlot::V3Manifest(ChunkGeneration::A),
2942            matches_to_skip: 1,
2943        });
2944
2945        let cleanup = publish_windows_value(&mut backend, &replacement).unwrap();
2946        assert_eq!(cleanup.failures, 1);
2947        assert_eq!(read(&mut backend), replacement);
2948        assert_eq!(
2949            backend
2950                .entries
2951                .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)),
2952            Some(&split_on_chars(&old, CHUNK_CHARS).len().to_string())
2953        );
2954    }
2955
2956    #[test]
2957    fn delete_cleanup_failure_retains_manifest_for_idempotent_recovery() {
2958        let value = "secret-".repeat(700);
2959        let mut backend = MemoryWindowsBackend::default();
2960        publish(&mut backend, &value);
2961        backend.fail_delete = Some(FailureRule {
2962            slot: WindowsCredentialSlot::V3Chunk {
2963                generation: ChunkGeneration::A,
2964                index: 0,
2965            },
2966            matches_to_skip: 0,
2967        });
2968
2969        let cleanup = delete_windows_value(&mut backend).unwrap();
2970        assert_eq!(cleanup.failures, 1);
2971        assert!(!backend.entries.contains_key(&WindowsCredentialSlot::Root));
2972        assert!(backend
2973            .entries
2974            .contains_key(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)));
2975
2976        backend.reset_faults();
2977        assert_eq!(delete_windows_value(&mut backend).unwrap().failures, 0);
2978        assert!(backend.entries.is_empty());
2979    }
2980
2981    #[test]
2982    fn corrupt_cleanup_metadata_fails_before_root_or_chunks_are_deleted() {
2983        let old = "old-".repeat(900);
2984        let mut backend = MemoryWindowsBackend::default();
2985        publish(&mut backend, &old);
2986        let root_before = backend.root();
2987        backend.entries.insert(
2988            WindowsCredentialSlot::V3Manifest(ChunkGeneration::B),
2989            "not-a-count".to_string(),
2990        );
2991
2992        let error = publish_windows_value(&mut backend, "replacement").unwrap_err();
2993        assert_backend_error(error, "manifest");
2994        assert_eq!(backend.root(), root_before);
2995        assert_eq!(read(&mut backend), old);
2996
2997        let error = delete_windows_value(&mut backend).unwrap_err();
2998        assert_backend_error(error, "manifest");
2999        assert_eq!(backend.root(), root_before);
3000        assert_eq!(read(&mut backend), old);
3001    }
3002
3003    #[test]
3004    fn reader_retry_is_bounded_when_root_never_stabilizes() {
3005        let value_a = "a".repeat(2500);
3006        let value_b = "b".repeat(2500);
3007        let mut backend = MemoryWindowsBackend::default();
3008        publish(&mut backend, &value_a);
3009        let root_a = backend.root();
3010        publish(&mut backend, &value_b);
3011        let root_b = backend.root();
3012        backend.entries.remove(&WindowsCredentialSlot::V3Chunk {
3013            generation: ChunkGeneration::A,
3014            index: 0,
3015        });
3016        let mut churning = AlternatingRootBackend {
3017            inner: backend,
3018            roots: [root_a, root_b],
3019            root_reads: 0,
3020        };
3021
3022        let error = read_windows_value(&mut churning).unwrap_err();
3023        assert_backend_error(error, "changed during every read attempt");
3024        assert_eq!(churning.root_reads, WINDOWS_READ_ATTEMPTS * 2);
3025    }
3026
3027    struct InterleavingReader<'a> {
3028        inner: MemoryWindowsBackend,
3029        publications: Vec<&'a str>,
3030        root_reads: usize,
3031    }
3032
3033    impl<'a> InterleavingReader<'a> {
3034        fn new(inner: MemoryWindowsBackend, publications: Vec<&'a str>) -> Self {
3035            Self {
3036                inner,
3037                publications,
3038                root_reads: 0,
3039            }
3040        }
3041    }
3042
3043    impl WindowsCredentialBackend for InterleavingReader<'_> {
3044        fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
3045            let captured = self.inner.read(slot)?;
3046            if slot == &WindowsCredentialSlot::Root && self.root_reads == 0 {
3047                for value in self.publications.drain(..) {
3048                    publish_windows_value(&mut self.inner, value)?;
3049                }
3050            }
3051            if slot == &WindowsCredentialSlot::Root {
3052                self.root_reads += 1;
3053            }
3054            Ok(captured)
3055        }
3056
3057        fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
3058            self.inner.write(slot, value)
3059        }
3060
3061        fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
3062            self.inner.delete(slot)
3063        }
3064    }
3065
3066    struct AlternatingRootBackend {
3067        inner: MemoryWindowsBackend,
3068        roots: [String; 2],
3069        root_reads: usize,
3070    }
3071
3072    impl WindowsCredentialBackend for AlternatingRootBackend {
3073        fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
3074            if slot == &WindowsCredentialSlot::Root {
3075                let root = self.roots[self.root_reads % self.roots.len()].clone();
3076                self.root_reads += 1;
3077                return Ok(Some(root));
3078            }
3079            self.inner.read(slot)
3080        }
3081
3082        fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
3083            self.inner.write(slot, value)
3084        }
3085
3086        fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
3087            self.inner.delete(slot)
3088        }
3089    }
3090}
3091
3092#[cfg(test)]
3093mod tests {
3094    use super::*;
3095    use serde::{Deserialize, Serialize};
3096
3097    /// An unusable store must not answer "that secret is absent".
3098    ///
3099    /// These three take no env lock: they call the file-backend helpers with an
3100    /// explicit `dir`, so they touch no process-global state.
3101    ///
3102    /// The distinction is invisible on unix, where an entry under a regular file
3103    /// fails with `ENOTDIR`, and load-bearing on Windows, where the same open
3104    /// returns `ERROR_PATH_NOT_FOUND` and Rust maps it to `NotFound`. Asserting
3105    /// it here keeps both platforms honest without needing a Windows runner to
3106    /// notice the regression.
3107    #[test]
3108    fn a_store_that_is_not_a_directory_is_a_backend_error_not_a_missing_secret() {
3109        let parent = tempfile::tempdir().unwrap();
3110        let not_a_dir = parent.path().join("blocked");
3111        std::fs::write(&not_a_dir, b"a regular file where the store should be").unwrap();
3112        let reference = SecretRef::with_default_service("SOME_KEY");
3113
3114        assert!(
3115            !file_backend_entry_is_merely_absent(&not_a_dir),
3116            "the platform-neutral discriminator must reject a regular-file store root"
3117        );
3118
3119        match file_backend_get(&not_a_dir, &reference) {
3120            Err(SecretError::Backend(_)) => {}
3121            other => panic!("unusable store must report a backend error, got {other:?}"),
3122        }
3123        match file_backend_delete(&not_a_dir, &reference) {
3124            Err(SecretError::Backend(_)) => {}
3125            other => panic!("unusable store must not report a successful delete, got {other:?}"),
3126        }
3127        assert!(
3128            !file_backend_status(&not_a_dir, &reference).exists,
3129            "status on an unusable store must not claim knowledge of the entry"
3130        );
3131    }
3132
3133    /// A store directory that has not been created yet is a first run, not a
3134    /// broken store: only `put`/`publish` create it.
3135    #[test]
3136    fn a_store_directory_that_does_not_exist_yet_is_still_not_found() {
3137        let parent = tempfile::tempdir().unwrap();
3138        let never_created = parent.path().join("not-created-yet");
3139        assert!(!never_created.exists());
3140        assert!(
3141            file_backend_entry_is_merely_absent(&never_created),
3142            "a missing directory beneath an existing directory is a normal first run"
3143        );
3144        let reference = SecretRef::with_default_service("SOME_KEY");
3145
3146        match file_backend_get(&never_created, &reference) {
3147            Err(SecretError::NotFound { .. }) => {}
3148            other => panic!("a first-run store has no secrets, it is not broken: {other:?}"),
3149        }
3150        assert!(
3151            file_backend_delete(&never_created, &reference).is_ok(),
3152            "deleting from a store that was never written is a no-op success"
3153        );
3154        assert!(!file_backend_status(&never_created, &reference).exists);
3155    }
3156
3157    #[test]
3158    fn a_missing_entry_in_a_real_directory_is_still_not_found() {
3159        let dir = tempfile::tempdir().unwrap();
3160        let reference = SecretRef::with_default_service("ABSENT_KEY");
3161
3162        match file_backend_get(dir.path(), &reference) {
3163            Err(SecretError::NotFound { .. }) => {}
3164            other => panic!("an absent entry in a usable store is NotFound, got {other:?}"),
3165        }
3166        assert!(
3167            file_backend_delete(dir.path(), &reference).is_ok(),
3168            "deleting an absent entry from a usable store is a no-op success"
3169        );
3170        assert!(!file_backend_status(dir.path(), &reference).exists);
3171    }
3172
3173    /// Process-wide serialization lock for every test that touches the secret
3174    /// store. `CAR_SECRETS_FILE_DIR` is a process-global env var: the file-
3175    /// backend test sets it, and every other test reads it (via
3176    /// `file_backend_dir()` inside `is_available`/`put`/`get`). Without this
3177    /// lock the file-backend test could flip the global redirect while a
3178    /// keychain test is mid-flight, routing it to a temp dir that then gets
3179    /// removed — a real, observed flake. Every test below acquires this guard
3180    /// first, so the store backend is stable for the duration of each test.
3181    /// `unwrap_or_else(into_inner)` keeps the suite running if one test panics
3182    /// while holding it.
3183    static STORE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3184
3185    fn lock_store() -> std::sync::MutexGuard<'static, ()> {
3186        STORE_LOCK.lock().unwrap_or_else(|e| e.into_inner())
3187    }
3188
3189    // Tests use a unique service name per run to avoid colliding with any
3190    // real credentials a developer has in their keychain. On headless Linux
3191    // CI without a Secret Service daemon, these will return Unavailable; we
3192    // skip in that case rather than fake success.
3193    fn test_service() -> String {
3194        format!(
3195            "car-secrets-tests-{}-{}",
3196            std::process::id(),
3197            // Nanos since startup — good enough to isolate tests running
3198            // in parallel inside one process.
3199            std::time::SystemTime::now()
3200                .duration_since(std::time::UNIX_EPOCH)
3201                .map(|d| d.as_nanos())
3202                .unwrap_or(0)
3203        )
3204    }
3205
3206    fn skip_if_unavailable() -> bool {
3207        !SecretStore::new().is_available()
3208    }
3209
3210    /// S2 — the opt-in file backend is honored in a debug/test build, drives a
3211    /// real `put`/`get`/`delete` round-trip through the plaintext file path, and
3212    /// `availability` still reports healthy (it's just the local filesystem).
3213    ///
3214    /// `cargo test` builds with `debug_assertions` on, so `file_backend_dir()`
3215    /// honors `CAR_SECRETS_FILE_DIR` here. A RELEASE binary returns `None` from
3216    /// `file_backend_dir()` regardless — production can never reach this path.
3217    ///
3218    /// The env var is process-global; this test sets it, runs, then removes it.
3219    /// It must be the only test in this module that mutates the env var. (The
3220    /// other tests use the keychain with unique service names and do not read
3221    /// this var.)
3222    /// A `MakeWriter` that appends every emitted log line into a shared buffer
3223    /// so the test can assert the one-time file-backend warning actually fired.
3224    #[derive(Clone)]
3225    struct BufWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
3226
3227    impl std::io::Write for BufWriter {
3228        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
3229            self.0.lock().unwrap().extend_from_slice(buf);
3230            Ok(buf.len())
3231        }
3232        fn flush(&mut self) -> std::io::Result<()> {
3233            Ok(())
3234        }
3235    }
3236
3237    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for BufWriter {
3238        type Writer = BufWriter;
3239        fn make_writer(&'a self) -> Self::Writer {
3240            self.clone()
3241        }
3242    }
3243
3244    #[test]
3245    fn file_backend_roundtrip_and_warn_under_debug() {
3246        const CHILD: &str = "CAR_TEST_FILE_BACKEND_WARNING_CHILD";
3247        if std::env::var_os(CHILD).is_none() {
3248            let output =
3249                std::process::Command::new(std::env::current_exe().expect("test executable"))
3250                    .args([
3251                        "--exact",
3252                        "tests::file_backend_roundtrip_and_warn_under_debug",
3253                        "--nocapture",
3254                    ])
3255                    .env(CHILD, "1")
3256                    .env_remove("CAR_SECRETS_FILE_DIR")
3257                    .env_remove("CAR_TEST_PRECONSUME_FILE_BACKEND_WARNING")
3258                    .env_remove("CAR_KEYCHAIN_PROOF_ROOT")
3259                    .env_remove("CAR_KEYCHAIN_PATH")
3260                    .output()
3261                    .expect("spawn isolated file-backend warning test");
3262            assert!(
3263                output.status.success(),
3264                "isolated file-backend warning test failed\nstdout:\n{}\nstderr:\n{}",
3265                String::from_utf8_lossy(&output.stdout),
3266                String::from_utf8_lossy(&output.stderr),
3267            );
3268            return;
3269        }
3270
3271        // Test hook for the isolation regression: model another parallel test
3272        // engaging the process-wide file backend before this test installs its
3273        // tracing subscriber.
3274        if std::env::var_os("CAR_TEST_PRECONSUME_FILE_BACKEND_WARNING").is_some() {
3275            let _ = file_backend_dir();
3276        }
3277        // Hold the store lock for the WHOLE test: while the global redirect env
3278        // var is set, no parallel keychain test may run.
3279        let _guard = lock_store();
3280        // Sanity: this whole seam only exists in debug builds. The asserted
3281        // value is a compile-time constant on purpose — it documents the
3282        // debug-build dependency, so silence the constant-assertion lint.
3283        #[allow(clippy::assertions_on_constants)]
3284        {
3285            assert!(
3286                cfg!(debug_assertions),
3287                "the crate test suite runs in debug; the file backend depends on it"
3288            );
3289        }
3290
3291        let dir = std::env::temp_dir().join(format!(
3292            "car-secrets-filebackend-{}-{}",
3293            std::process::id(),
3294            std::time::SystemTime::now()
3295                .duration_since(std::time::UNIX_EPOCH)
3296                .map(|d| d.as_nanos())
3297                .unwrap_or(0)
3298        ));
3299        std::fs::create_dir_all(&dir).unwrap();
3300        std::env::set_var("CAR_SECRETS_FILE_DIR", &dir);
3301
3302        // Capture logs so we can assert the one-time warning fires the first
3303        // time the redirect engages in this process.
3304        let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
3305        let subscriber = tracing_subscriber::fmt()
3306            .with_writer(BufWriter(buf.clone()))
3307            .with_max_level(tracing::Level::WARN)
3308            .finish();
3309        tracing::subscriber::with_default(subscriber, || {
3310            // The redirect is honored (Some) — and fires the one-time warn the
3311            // first time it engages in this process.
3312            assert_eq!(
3313                file_backend_dir().as_deref(),
3314                Some(dir.as_path()),
3315                "CAR_SECRETS_FILE_DIR must be honored under debug_assertions"
3316            );
3317        });
3318        let logged = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
3319        assert!(
3320            logged.contains("PLAINTEXT ON DISK"),
3321            "the file backend must emit the one-time PLAINTEXT warning, got logs: {logged:?}"
3322        );
3323
3324        let store = SecretStore::new();
3325        // availability_check still reports healthy on the file backend.
3326        let check = store.availability();
3327        assert!(check.available, "file backend must report available");
3328        assert!(check.reason.is_none());
3329
3330        // Real put/get/delete round-trip through the plaintext file path.
3331        let r = SecretRef::new("svc", "key");
3332        store.put(&r, "xoxb-plaintext-value").unwrap();
3333        assert_eq!(store.get(&r).unwrap(), "xoxb-plaintext-value");
3334        // The value really is plaintext on disk (the documented trade-off).
3335        let on_disk = std::fs::read_to_string(file_backend_path(&dir, &r)).unwrap();
3336        assert_eq!(on_disk, "xoxb-plaintext-value");
3337        store.delete(&r).unwrap();
3338        match store.get(&r) {
3339            Err(SecretError::NotFound { .. }) => {}
3340            other => panic!("expected NotFound after delete, got {other:?}"),
3341        }
3342
3343        // The reserved-slot guard lives in generic wrappers, not SecretStore:
3344        // Daemon-owned code must still be able to lease and delete every exact
3345        // internal slot directly even though generic wrappers reject them.
3346        for key in [
3347            OPENROUTER_OAUTH_KEY,
3348            PARSLEE_ACCESS_TOKEN_KEY,
3349            PARSLEE_REFRESH_TOKEN_KEY,
3350            PARSLEE_EXPIRES_AT_KEY,
3351            PARSLEE_API_BASE_KEY,
3352            PARSLEE_ACCOUNTS_KEY,
3353            "PARSLEE_TOKENS_account-1",
3354            PARSLEE_AUTH_GENERATION_KEY,
3355            PARSLEE_AUTH_COMPLETION_KEY,
3356            PARSLEE_ACTIVE_ACCOUNT_ID_KEY,
3357            PARSLEE_AUTH_STATE_V2_KEY,
3358        ] {
3359            let private = SecretRef::new(DEFAULT_SERVICE, key);
3360            assert!(is_daemon_private_secret(&private.service, &private.key));
3361            store.put(&private, "internal-test-value").unwrap();
3362            assert_eq!(store.get(&private).unwrap(), "internal-test-value");
3363            store.delete(&private).unwrap();
3364            assert!(matches!(
3365                store.get(&private),
3366                Err(SecretError::NotFound { .. })
3367            ));
3368        }
3369
3370        // Restore process state so no parallel/later test inherits the redirect.
3371        std::env::remove_var("CAR_SECRETS_FILE_DIR");
3372        let _ = std::fs::remove_dir_all(&dir);
3373    }
3374
3375    #[test]
3376    fn every_parslee_auth_slot_is_private_to_the_dedicated_auth_surface() {
3377        for key in [
3378            PARSLEE_ACCESS_TOKEN_KEY,
3379            PARSLEE_REFRESH_TOKEN_KEY,
3380            PARSLEE_EXPIRES_AT_KEY,
3381            PARSLEE_API_BASE_KEY,
3382            PARSLEE_ACCOUNTS_KEY,
3383            "PARSLEE_TOKENS_account-1",
3384            PARSLEE_ACTIVE_ACCOUNT_ID_KEY,
3385            PARSLEE_AUTH_GENERATION_KEY,
3386            PARSLEE_AUTH_COMPLETION_KEY,
3387            PARSLEE_AUTH_STATE_V2_KEY,
3388        ] {
3389            assert!(
3390                is_daemon_private_secret(DEFAULT_SERVICE, key),
3391                "{key} must be unreachable through generic secret surfaces"
3392            );
3393            assert!(
3394                !is_daemon_private_secret("other-service", key),
3395                "reservation must remain scoped to the CAR service"
3396            );
3397        }
3398
3399        assert!(!is_daemon_private_secret(
3400            DEFAULT_SERVICE,
3401            "OPENROUTER_API_KEY"
3402        ));
3403        for key in [
3404            format!("{PARSLEE_AUTH_STATE_V2_KEY}#chunk0"),
3405            format!("{PARSLEE_AUTH_STATE_V2_KEY}#chunkv2#nonce-1#0"),
3406            format!("{OPENROUTER_OAUTH_KEY}#chunk17"),
3407            format!("{OPENROUTER_OAUTH_KEY}#chunkv2#nonce-2#3"),
3408        ] {
3409            assert!(
3410                is_daemon_private_secret(DEFAULT_SERVICE, &key),
3411                "{key} is derived from a daemon-private root"
3412            );
3413            assert!(!is_daemon_private_secret("other-service", &key));
3414        }
3415    }
3416
3417    #[cfg(target_os = "macos")]
3418    #[test]
3419    fn bounded_command_output_large_helper() {
3420        if std::env::var_os("CAR_SECURITY_OUTPUT_HELPER").is_none() {
3421            return;
3422        }
3423        use std::io::Write;
3424        let payload = vec![b'x'; 128 * 1024];
3425        std::io::stdout().write_all(&payload).unwrap();
3426        std::io::stdout().flush().unwrap();
3427        std::io::stderr().write_all(&payload).unwrap();
3428        std::io::stderr().flush().unwrap();
3429    }
3430
3431    #[cfg(target_os = "macos")]
3432    #[test]
3433    fn bounded_command_output_drains_large_stdout_and_stderr() {
3434        let mut command = std::process::Command::new(std::env::current_exe().unwrap());
3435        command
3436            .args([
3437                "--exact",
3438                "tests::bounded_command_output_large_helper",
3439                "--nocapture",
3440            ])
3441            .env("CAR_SECURITY_OUTPUT_HELPER", "1");
3442
3443        let output =
3444            bounded_command_output(&mut command, std::time::Duration::from_secs(5), "test")
3445                .unwrap();
3446
3447        assert!(output.output.status.success(), "{output:?}");
3448        assert!(output.output.stdout.len() >= 128 * 1024);
3449        assert!(output.output.stderr.len() >= 128 * 1024);
3450    }
3451
3452    #[cfg(target_os = "macos")]
3453    pub(super) struct FakeSecurityCli {
3454        outputs: std::cell::RefCell<std::collections::VecDeque<std::io::Result<SecurityCliOutput>>>,
3455        calls: std::cell::RefCell<Vec<Vec<String>>>,
3456    }
3457
3458    #[cfg(target_os = "macos")]
3459    impl FakeSecurityCli {
3460        pub(super) fn new(outputs: Vec<std::io::Result<SecurityCliOutput>>) -> Self {
3461            Self {
3462                outputs: std::cell::RefCell::new(outputs.into()),
3463                calls: std::cell::RefCell::new(Vec::new()),
3464            }
3465        }
3466
3467        pub(super) fn calls(&self) -> Vec<Vec<String>> {
3468            self.calls.borrow().clone()
3469        }
3470    }
3471
3472    #[cfg(target_os = "macos")]
3473    impl SecurityCli for FakeSecurityCli {
3474        fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
3475            self.calls
3476                .borrow_mut()
3477                .push(args.iter().map(|arg| (*arg).to_string()).collect());
3478            self.outputs
3479                .borrow_mut()
3480                .pop_front()
3481                .expect("missing fake security output")
3482        }
3483    }
3484
3485    #[cfg(target_os = "macos")]
3486    pub(super) fn security_output(
3487        code: i32,
3488        stdout: impl Into<Vec<u8>>,
3489        stderr: impl Into<Vec<u8>>,
3490    ) -> std::io::Result<SecurityCliOutput> {
3491        Ok(SecurityCliOutput {
3492            success: code == 0,
3493            code: Some(code),
3494            stdout: stdout.into(),
3495            stderr: stderr.into(),
3496            prompted: false,
3497            timed_out: false,
3498        })
3499    }
3500
3501    /// A successful read that macOS had to draw a dialog for — the evidence of
3502    /// a stale, hash-bound ACL.
3503    #[cfg(target_os = "macos")]
3504    pub(super) fn security_output_prompted(
3505        code: i32,
3506        stdout: impl Into<Vec<u8>>,
3507        stderr: impl Into<Vec<u8>>,
3508    ) -> std::io::Result<SecurityCliOutput> {
3509        let mut out = security_output(code, stdout, stderr)?;
3510        out.prompted = true;
3511        Ok(out)
3512    }
3513
3514    #[cfg(target_os = "macos")]
3515    fn args(values: &[&str]) -> Vec<String> {
3516        values.iter().map(|value| (*value).to_string()).collect()
3517    }
3518
3519    /// The probe runs in front of every credential resolution, so the argv it
3520    /// sends is the whole point: existence-only, through the Apple-signed
3521    /// helper, never the in-process keyring path (Parslee-ai/car#897).
3522    #[cfg(target_os = "macos")]
3523    #[test]
3524    fn availability_probe_goes_through_the_security_helper() {
3525        let cli = FakeSecurityCli::new(vec![security_output(0, "", "")]);
3526        let check = mac_availability_via_security_cli_with(&cli);
3527
3528        assert!(check.available);
3529        assert_eq!(
3530            cli.calls(),
3531            vec![args(&[
3532                "find-generic-password",
3533                "-s",
3534                "car-internal",
3535                "-a",
3536                "__availability_probe__",
3537            ])]
3538        );
3539        // `-g` is what makes macOS ask for authorization. A probe that runs
3540        // before every credential read must never be able to raise a dialog,
3541        // so its absence is asserted rather than left to the argv comparison.
3542        assert!(
3543            !cli.calls()[0].iter().any(|arg| arg == "-g"),
3544            "availability probe must not read password bytes: {:?}",
3545            cli.calls()[0]
3546        );
3547    }
3548
3549    /// An absent probe item is the normal case — nothing ever writes it. It
3550    /// still proves the store answered, exactly as `Err(NoEntry)` does off
3551    /// macOS.
3552    #[cfg(target_os = "macos")]
3553    #[test]
3554    fn availability_probe_absent_item_is_still_reachable() {
3555        let cli = FakeSecurityCli::new(vec![security_output(
3556            SECURITY_ERR_SEC_ITEM_NOT_FOUND,
3557            "",
3558            "",
3559        )]);
3560        let check = mac_availability_via_security_cli_with(&cli);
3561
3562        assert!(check.available);
3563        assert!(check.reason.is_none(), "{:?}", check.reason);
3564    }
3565
3566    /// A non-zero exit that is not "no such item" is a backend/authorization
3567    /// failure, and the caller gets the helper's own words for it rather than
3568    /// a bare `false`.
3569    #[cfg(target_os = "macos")]
3570    #[test]
3571    fn availability_probe_backend_error_reports_unavailable() {
3572        let cli = FakeSecurityCli::new(vec![security_output(
3573            51,
3574            "",
3575            "security: SecKeychainSearchCopyNext: User interaction is not allowed.",
3576        )]);
3577        let check = mac_availability_via_security_cli_with(&cli);
3578
3579        assert!(!check.available);
3580        let reason = check.reason.expect("unavailable must carry a reason");
3581        assert!(
3582            reason.contains("User interaction is not allowed"),
3583            "reason should carry the helper's stderr, got {reason:?}"
3584        );
3585    }
3586
3587    /// The property that makes a blocked probe recoverable from `~/.car/logs`
3588    /// by an operator who was not at the screen (Parslee-ai/car#878, #897):
3589    /// the notice names the item, and the probe's argv is item-scoped so it
3590    /// gets a real name instead of a bare subcommand.
3591    #[cfg(target_os = "macos")]
3592    #[test]
3593    fn availability_probe_names_itself_in_the_prompt_notice() {
3594        let cli = FakeSecurityCli::new(vec![security_output(
3595            SECURITY_ERR_SEC_ITEM_NOT_FOUND,
3596            "",
3597            "",
3598        )]);
3599        let _ = mac_availability_via_security_cli_with(&cli);
3600
3601        // Derived from the argv the probe ACTUALLY sent, never a hand-typed
3602        // copy of it. `describe_item` falls back to naming the bare subcommand
3603        // when a call is not item-scoped, so asserting a literal here would
3604        // pass even if the probe stopped carrying `-s`/`-a` — which is exactly
3605        // the regression that would put an unnameable item back in the log.
3606        let sent = cli.calls().remove(0);
3607        let sent: Vec<&str> = sent.iter().map(String::as_str).collect();
3608        let item = describe_item(&sent);
3609
3610        assert_eq!(item, "car-internal/__availability_probe__");
3611        assert!(
3612            keychain_prompt_notice(&item).contains(&item),
3613            "notice must name the blocking item: {}",
3614            keychain_prompt_notice(&item)
3615        );
3616    }
3617
3618    #[cfg(target_os = "macos")]
3619    fn assert_access_denied_contains(err: SecretError, expected: &str) {
3620        match err {
3621            SecretError::AccessDenied { message } => assert!(
3622                message.contains(expected),
3623                "expected access-denied error to contain {expected:?}, got {message:?}"
3624            ),
3625            other => panic!("expected AccessDenied, got {:?}", other),
3626        }
3627    }
3628
3629    #[cfg(target_os = "macos")]
3630    #[test]
3631    fn mac_security_errors_are_typed_for_recovery() {
3632        assert!(matches!(
3633            classify_security_error(-128, "user canceled"),
3634            SecretError::UserCancelled { .. }
3635        ));
3636        assert!(matches!(
3637            classify_security_error(-25293, "authorization denied"),
3638            SecretError::AccessDenied { .. }
3639        ));
3640        assert!(matches!(
3641            classify_helper_timeout("car/PARSLEE_AUTH_STATE_V2"),
3642            SecretError::HelperTimedOut { .. }
3643        ));
3644
3645        let mut timed_out = security_output(9, b"", b"helper killed").unwrap();
3646        timed_out.timed_out = true;
3647        let cli = FakeSecurityCli::new(vec![Ok(timed_out)]);
3648        let secret = SecretRef::new("svc", "key");
3649        assert!(matches!(
3650            mac_get_via_security_cli_with(&secret, &cli),
3651            Err(SecretError::HelperTimedOut { .. })
3652        ));
3653    }
3654
3655    #[cfg(target_os = "macos")]
3656    struct IsolatedKeychainFixture {
3657        _temp: tempfile::TempDir,
3658        proof_root: std::path::PathBuf,
3659        valid_path: std::path::PathBuf,
3660        symlink_path: std::path::PathBuf,
3661        outside_path: std::path::PathBuf,
3662        public_path: std::path::PathBuf,
3663        directory_path: std::path::PathBuf,
3664        public_root: std::path::PathBuf,
3665    }
3666
3667    #[cfg(target_os = "macos")]
3668    impl IsolatedKeychainFixture {
3669        fn new() -> Self {
3670            use std::os::unix::fs::{symlink, PermissionsExt};
3671
3672            let temp = tempfile::tempdir().unwrap();
3673            let proof_root = temp.path().join("proof");
3674            std::fs::create_dir(&proof_root).unwrap();
3675            std::fs::set_permissions(&proof_root, std::fs::Permissions::from_mode(0o700)).unwrap();
3676
3677            let valid_path = proof_root.join("valid.keychain-db");
3678            std::fs::write(&valid_path, b"keychain fixture").unwrap();
3679            std::fs::set_permissions(&valid_path, std::fs::Permissions::from_mode(0o600)).unwrap();
3680
3681            let symlink_path = proof_root.join("linked.keychain-db");
3682            symlink(&valid_path, &symlink_path).unwrap();
3683
3684            let outside_path = temp.path().join("outside.keychain-db");
3685            std::fs::write(&outside_path, b"outside fixture").unwrap();
3686            std::fs::set_permissions(&outside_path, std::fs::Permissions::from_mode(0o600))
3687                .unwrap();
3688
3689            let public_path = proof_root.join("public.keychain-db");
3690            std::fs::write(&public_path, b"public fixture").unwrap();
3691            std::fs::set_permissions(&public_path, std::fs::Permissions::from_mode(0o644)).unwrap();
3692
3693            let directory_path = proof_root.join("directory.keychain-db");
3694            std::fs::create_dir(&directory_path).unwrap();
3695
3696            let public_root = temp.path().join("public-proof");
3697            std::fs::create_dir(&public_root).unwrap();
3698            std::fs::set_permissions(&public_root, std::fs::Permissions::from_mode(0o755)).unwrap();
3699
3700            Self {
3701                _temp: temp,
3702                proof_root,
3703                valid_path,
3704                symlink_path,
3705                outside_path,
3706                public_path,
3707                directory_path,
3708                public_root,
3709            }
3710        }
3711
3712        fn proof_root(&self) -> &std::path::Path {
3713            &self.proof_root
3714        }
3715
3716        fn valid_path(&self) -> &std::path::Path {
3717            &self.valid_path
3718        }
3719
3720        fn symlink_path(&self) -> &std::path::Path {
3721            &self.symlink_path
3722        }
3723
3724        fn outside_path(&self) -> &std::path::Path {
3725            &self.outside_path
3726        }
3727
3728        fn public_path(&self) -> &std::path::Path {
3729            &self.public_path
3730        }
3731
3732        fn directory_path(&self) -> &std::path::Path {
3733            &self.directory_path
3734        }
3735
3736        fn public_root(&self) -> &std::path::Path {
3737            &self.public_root
3738        }
3739    }
3740
3741    #[cfg(target_os = "macos")]
3742    #[test]
3743    fn isolated_keychain_must_be_absolute_private_regular_owned_and_under_proof_root() {
3744        let fixture = IsolatedKeychainFixture::new();
3745        assert!(validate_keychain_path(fixture.valid_path(), fixture.proof_root()).is_ok());
3746        assert!(validate_keychain_path(
3747            std::path::Path::new("relative.keychain-db"),
3748            fixture.proof_root()
3749        )
3750        .is_err());
3751        assert!(validate_keychain_path(fixture.symlink_path(), fixture.proof_root()).is_err());
3752        assert!(validate_keychain_path(fixture.outside_path(), fixture.proof_root()).is_err());
3753        assert!(validate_keychain_path(fixture.public_path(), fixture.proof_root()).is_err());
3754        assert!(validate_keychain_path(fixture.directory_path(), fixture.proof_root()).is_err());
3755        assert!(validate_keychain_path(fixture.valid_path(), fixture.public_root()).is_err());
3756    }
3757
3758    #[test]
3759    fn secret_store_activity_counts_only_aggregate_public_operation_attempts() {
3760        let _guard = lock_store();
3761        let dir = tempfile::tempdir().unwrap();
3762        std::env::set_var("CAR_SECRETS_FILE_DIR", dir.path());
3763        let before = secret_store_activity();
3764        let store = SecretStore::new();
3765        let secret = SecretRef::new("activity-test", "credential");
3766
3767        assert!(store.availability().available);
3768        store.put(&secret, "sensitive-value").unwrap();
3769        let _ = store.get(&secret).unwrap();
3770        let _ = store.status(&secret).unwrap();
3771        store.publish(&secret, "replacement-value").unwrap();
3772        store.delete(&secret).unwrap();
3773
3774        let after = secret_store_activity();
3775        assert_eq!(after.get_attempts - before.get_attempts, 1);
3776        assert_eq!(after.status_attempts - before.status_attempts, 1);
3777        assert_eq!(
3778            after.availability_attempts - before.availability_attempts,
3779            1
3780        );
3781        assert_eq!(after.write_attempts - before.write_attempts, 2);
3782        assert_eq!(after.delete_attempts - before.delete_attempts, 1);
3783
3784        let encoded = serde_json::to_string(&after).unwrap();
3785        assert!(!encoded.contains("activity-test"));
3786        assert!(!encoded.contains("credential"));
3787        assert!(!encoded.contains("sensitive-value"));
3788        assert!(!encoded.contains(dir.path().to_string_lossy().as_ref()));
3789        std::env::remove_var("CAR_SECRETS_FILE_DIR");
3790    }
3791
3792    #[test]
3793    fn roundtrip_string() {
3794        let _guard = lock_store();
3795        if skip_if_unavailable() {
3796            eprintln!("skipping: no secret store backend available");
3797            return;
3798        }
3799        let store = SecretStore::new();
3800        let svc = test_service();
3801        let r = SecretRef::new(&svc, "roundtrip");
3802        store.put(&r, "hello world").unwrap();
3803        assert_eq!(store.get(&r).unwrap(), "hello world");
3804        assert!(store.status(&r).unwrap().exists);
3805        store.delete(&r).unwrap();
3806        assert!(!store.status(&r).unwrap().exists);
3807    }
3808
3809    #[test]
3810    fn roundtrip_string_with_trailing_newline() {
3811        let _guard = lock_store();
3812        if skip_if_unavailable() {
3813            eprintln!("skipping: no secret store backend available");
3814            return;
3815        }
3816        let store = SecretStore::new();
3817        let svc = test_service();
3818        let r = SecretRef::new(&svc, "roundtrip-newline");
3819        let value = "abc\n";
3820        store.put(&r, value).unwrap();
3821        assert_eq!(store.get(&r).unwrap(), value);
3822        store.delete(&r).unwrap();
3823    }
3824
3825    #[test]
3826    fn get_missing_returns_not_found() {
3827        let _guard = lock_store();
3828        if skip_if_unavailable() {
3829            return;
3830        }
3831        let store = SecretStore::new();
3832        let r = SecretRef::new(test_service(), "never_written");
3833        match store.get(&r) {
3834            Err(SecretError::NotFound { .. }) => (),
3835            other => panic!("expected NotFound, got {:?}", other),
3836        }
3837    }
3838
3839    #[test]
3840    fn delete_missing_is_idempotent() {
3841        let _guard = lock_store();
3842        if skip_if_unavailable() {
3843            return;
3844        }
3845        let store = SecretStore::new();
3846        let r = SecretRef::new(test_service(), "missing");
3847        // Two deletes in a row should both succeed.
3848        store.delete(&r).unwrap();
3849        store.delete(&r).unwrap();
3850    }
3851
3852    #[test]
3853    fn json_roundtrip() {
3854        let _guard = lock_store();
3855        if skip_if_unavailable() {
3856            return;
3857        }
3858        #[derive(Serialize, Deserialize, PartialEq, Debug)]
3859        struct Session {
3860            cookies: Vec<String>,
3861            expires_at: i64,
3862        }
3863        let store = SecretStore::new();
3864        let svc = test_service();
3865        let r = SecretRef::new(&svc, "session");
3866        let s = Session {
3867            cookies: vec!["a=1".into(), "b=2".into()],
3868            expires_at: 1_700_000_000,
3869        };
3870        store.put_json(&r, &s).unwrap();
3871        let back: Session = store.get_json(&r).unwrap();
3872        assert_eq!(back, s);
3873        store.delete(&r).unwrap();
3874    }
3875
3876    #[test]
3877    fn status_no_leak() {
3878        let _guard = lock_store();
3879        if skip_if_unavailable() {
3880            return;
3881        }
3882        let store = SecretStore::new();
3883        let r = SecretRef::new(test_service(), "status");
3884        store.put(&r, "secret-payload").unwrap();
3885        let st = store.status(&r).unwrap();
3886        // Status intentionally does not carry the value.
3887        let encoded = serde_json::to_string(&st).unwrap();
3888        assert!(!encoded.contains("secret-payload"));
3889        store.delete(&r).unwrap();
3890    }
3891
3892    #[cfg(target_os = "macos")]
3893    #[test]
3894    fn mac_get_uses_security_cli_and_maps_success() {
3895        let cli = FakeSecurityCli::new(vec![security_output(
3896            0,
3897            b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
3898            b"password: \"secret\"\n",
3899        )]);
3900        let r = SecretRef::new("svc", "key");
3901
3902        assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "secret");
3903        assert_eq!(
3904            cli.calls(),
3905            vec![args(&[
3906                "find-generic-password",
3907                "-s",
3908                "svc",
3909                "-a",
3910                "key",
3911                "-g"
3912            ])]
3913        );
3914    }
3915
3916    #[cfg(target_os = "macos")]
3917    #[test]
3918    fn mac_get_decodes_hex_password_output_with_trailing_newline() {
3919        let cli = FakeSecurityCli::new(vec![security_output(
3920            0,
3921            b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
3922            b"password: 0x6162630A  \"abc\\012\"\n",
3923        )]);
3924        let r = SecretRef::new("svc", "key");
3925
3926        assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "abc\n");
3927        assert_eq!(
3928            cli.calls(),
3929            vec![args(&[
3930                "find-generic-password",
3931                "-s",
3932                "svc",
3933                "-a",
3934                "key",
3935                "-g"
3936            ])]
3937        );
3938    }
3939
3940    #[cfg(target_os = "macos")]
3941    #[test]
3942    fn mac_get_maps_not_found_and_access_denied_without_fallback() {
3943        let r = SecretRef::new("svc", "missing");
3944        let cli = FakeSecurityCli::new(vec![security_output(
3945            SECURITY_ERR_SEC_ITEM_NOT_FOUND,
3946            b"",
3947            b"The specified item could not be found in the keychain.\n",
3948        )]);
3949
3950        match mac_get_via_security_cli_with(&r, &cli) {
3951            Err(SecretError::NotFound { service, key }) => {
3952                assert_eq!(service, "svc");
3953                assert_eq!(key, "missing");
3954            }
3955            other => panic!("expected NotFound, got {:?}", other),
3956        }
3957        assert_eq!(cli.calls().len(), 1);
3958
3959        let cli = FakeSecurityCli::new(vec![security_output(
3960            51,
3961            b"",
3962            b"User interaction is not allowed.\n",
3963        )]);
3964        let err = mac_get_via_security_cli_with(&r, &cli).unwrap_err();
3965        assert_access_denied_contains(err, "User interaction is not allowed.");
3966        assert_eq!(cli.calls().len(), 1);
3967    }
3968
3969    #[cfg(target_os = "macos")]
3970    #[test]
3971    fn mac_status_uses_security_cli_and_maps_results() {
3972        let r = SecretRef::new("svc", "key");
3973        let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
3974
3975        let status = mac_status_via_security_cli_with(&r, &cli).unwrap();
3976        assert!(status.exists);
3977        assert_eq!(
3978            cli.calls(),
3979            vec![args(&["find-generic-password", "-s", "svc", "-a", "key"])]
3980        );
3981
3982        let cli = FakeSecurityCli::new(vec![security_output(
3983            SECURITY_ERR_SEC_ITEM_NOT_FOUND,
3984            b"",
3985            b"The specified item could not be found in the keychain.\n",
3986        )]);
3987        assert!(!mac_status_via_security_cli_with(&r, &cli).unwrap().exists);
3988
3989        let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
3990        let err = mac_status_via_security_cli_with(&r, &cli).unwrap_err();
3991        assert_access_denied_contains(err, "auth denied");
3992    }
3993
3994    #[cfg(target_os = "macos")]
3995    #[test]
3996    fn mac_put_pre_deletes_then_adds_so_acl_is_fresh() {
3997        // Regression for the "3 prompts on car-server startup" bug. Before
3998        // this fix `mac_put_via_security_cli` issued only
3999        // `add-generic-password -U -A`, which updates the value but
4000        // preserves any pre-existing ACL — so items first written by an
4001        // older binary stayed CDHash-bound forever and `find-generic-password -g`
4002        // prompted on every read. The fix: best-effort delete first, then add.
4003        let cli = FakeSecurityCli::new(vec![
4004            // Pre-delete returns NotFound — that's fine, ignored.
4005            security_output(
4006                SECURITY_ERR_SEC_ITEM_NOT_FOUND,
4007                b"",
4008                b"The specified item could not be found in the keychain.\n",
4009            ),
4010            // Add succeeds.
4011            security_output(0, b"", b""),
4012        ]);
4013
4014        mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
4015
4016        assert_eq!(
4017            cli.calls(),
4018            vec![
4019                args(&["delete-generic-password", "-s", "svc", "-a", "key"]),
4020                args(&[
4021                    "add-generic-password",
4022                    "-U",
4023                    "-A",
4024                    "-s",
4025                    "svc",
4026                    "-a",
4027                    "key",
4028                    "-w",
4029                    "secret",
4030                ]),
4031            ]
4032        );
4033    }
4034
4035    #[cfg(target_os = "macos")]
4036    #[test]
4037    fn mac_put_ignores_pre_delete_failure_and_still_adds() {
4038        // If the pre-delete shells back a non-NotFound non-zero (e.g.
4039        // transient backend error), we still attempt the add — `-U` is the
4040        // safety net that lets us update the value even if the old item is
4041        // somehow still around.
4042        let cli = FakeSecurityCli::new(vec![
4043            security_output(128, b"", b"some weird backend error\n"),
4044            security_output(0, b"", b""),
4045        ]);
4046
4047        mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
4048
4049        assert_eq!(cli.calls().len(), 2);
4050        assert_eq!(
4051            cli.calls()[1],
4052            args(&[
4053                "add-generic-password",
4054                "-U",
4055                "-A",
4056                "-s",
4057                "svc",
4058                "-a",
4059                "key",
4060                "-w",
4061                "secret",
4062            ])
4063        );
4064    }
4065
4066    #[cfg(target_os = "macos")]
4067    #[test]
4068    fn mac_put_surfaces_add_failure_as_access_denied() {
4069        let cli = FakeSecurityCli::new(vec![
4070            security_output(0, b"", b""),
4071            security_output(51, b"", b"User interaction is not allowed.\n"),
4072        ]);
4073
4074        let err = mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap_err();
4075        assert_access_denied_contains(err, "User interaction is not allowed.");
4076    }
4077
4078    #[cfg(target_os = "macos")]
4079    #[test]
4080    fn mac_publish_updates_in_place_without_a_pre_delete_gap() {
4081        let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4082
4083        mac_publish_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
4084
4085        assert_eq!(
4086            cli.calls(),
4087            vec![args(&[
4088                "add-generic-password",
4089                "-U",
4090                "-A",
4091                "-s",
4092                "svc",
4093                "-a",
4094                "key",
4095                "-w",
4096                "secret",
4097            ])]
4098        );
4099    }
4100
4101    #[cfg(target_os = "macos")]
4102    #[test]
4103    fn mac_security_child_is_killed_and_reaped_at_its_deadline() {
4104        let mut command = std::process::Command::new("/bin/sh");
4105        command.args(["-c", "sleep 5"]);
4106        let started = std::time::Instant::now();
4107
4108        let output =
4109            bounded_command_output(&mut command, std::time::Duration::from_millis(40), "test")
4110                .unwrap();
4111
4112        assert!(!output.output.status.success());
4113        assert!(
4114            started.elapsed() < std::time::Duration::from_secs(1),
4115            "bounded helper must not wait for the child command's natural exit"
4116        );
4117        let stderr = String::from_utf8_lossy(&output.output.stderr);
4118        assert!(stderr.contains("CAR killed the keychain helper"));
4119        // The deadline is nearly always an unanswered macOS keychain dialog.
4120        // Saying only "helper killed" sends the user looking at models and
4121        // accounts, because that is what the downstream error mentions
4122        // ("no inference backend is available") — the one thing it never named
4123        // was the prompt sitting on screen.
4124        assert!(
4125            stderr.contains("keychain prompt"),
4126            "the timeout must name a pending keychain prompt as the likely cause"
4127        );
4128        assert!(
4129            stderr.contains("Always Allow"),
4130            "the timeout must tell the user what action clears it"
4131        );
4132    }
4133
4134    /// A hung helper with NO dialog on screen must still die at the short
4135    /// deadline. This is the half that keeps the extension honest: if
4136    /// `security_agent_is_prompting()` were ever wrong in the permissive
4137    /// direction, a genuinely stuck child would block for three minutes.
4138    ///
4139    /// The interactive half cannot be unit-tested without a real SecurityAgent,
4140    /// so `security_agent_is_prompting` fails CLOSED — any error determining it
4141    /// yields the short deadline, never the long one.
4142    #[cfg(target_os = "macos")]
4143    #[test]
4144    fn a_hung_helper_with_no_dialog_still_dies_at_the_short_deadline() {
4145        // Nothing here opens a keychain dialog, so the deadline must be the
4146        // short one regardless of what else is running on the machine.
4147        assert!(
4148            SECURITY_CLI_INTERACTIVE_TIMEOUT > SECURITY_CLI_TIMEOUT,
4149            "the interactive allowance must be longer than the hang deadline"
4150        );
4151        assert!(
4152            SECURITY_CLI_INTERACTIVE_TIMEOUT >= std::time::Duration::from_secs(60),
4153            "a human needs to find a window, type a password and submit — 15s \
4154             is why entering the correct password repeatedly never worked"
4155        );
4156
4157        let mut command = std::process::Command::new("/bin/sh");
4158        command.args(["-c", "sleep 5"]);
4159        let started = std::time::Instant::now();
4160        let output =
4161            bounded_command_output(&mut command, std::time::Duration::from_millis(40), "test")
4162                .unwrap();
4163        assert!(!output.output.status.success());
4164        assert!(
4165            started.elapsed() < std::time::Duration::from_secs(1),
4166            "a helper with no dialog must not inherit the interactive allowance"
4167        );
4168    }
4169
4170    /// A dialog belonging to something else must not be read as proof that
4171    /// *this* read was authorized.
4172    ///
4173    /// `SecurityAgent` is machine-wide, so the bare sighting is true whenever
4174    /// anything on the Mac is asking for authorization — including another CAR
4175    /// process blocked on its own prompt, which is exactly the state a
4176    /// prompt storm puts the machine in. Acting on it there sends every healthy
4177    /// item in every concurrently-running process through a delete-then-add
4178    /// rewrite, at the worst possible moment.
4179    #[cfg(target_os = "macos")]
4180    #[test]
4181    fn a_dialog_is_not_attributed_to_a_read_that_did_not_wait_for_it() {
4182        let instant = std::time::Duration::from_millis(0);
4183        let quick = std::time::Duration::from_millis(20);
4184
4185        assert!(
4186            !dialog_is_evidence_for_this_read(true, instant),
4187            "a dialog already on screen at spawn belongs to whatever opened it"
4188        );
4189        assert!(
4190            !dialog_is_evidence_for_this_read(true, quick),
4191            "a read that returned in 20ms was never blocked on a human"
4192        );
4193        assert!(
4194            !dialog_is_evidence_for_this_read(false, std::time::Duration::from_secs(60)),
4195            "no dialog is no evidence, however long the helper took"
4196        );
4197        assert!(
4198            dialog_is_evidence_for_this_read(true, PROMPT_EVIDENCE_MIN),
4199            "a call still blocked with a dialog up is the one being authorized"
4200        );
4201    }
4202
4203    /// The threshold has to sit in the gap between the two populations it
4204    /// separates, and stay well clear of the deadline that ends the call.
4205    #[cfg(target_os = "macos")]
4206    #[test]
4207    fn the_prompt_evidence_threshold_sits_between_a_silent_read_and_a_human() {
4208        assert!(
4209            PROMPT_EVIDENCE_MIN >= std::time::Duration::from_millis(200),
4210            "must be an order of magnitude above a silent `security -g` read, \
4211             which returns in tens of milliseconds"
4212        );
4213        assert!(
4214            PROMPT_EVIDENCE_MIN <= std::time::Duration::from_secs(2),
4215            "must stay below the fastest a human can answer a dialog, or a real \
4216             stale-ACL read never gets repaired and prompts forever"
4217        );
4218        assert!(
4219            PROMPT_EVIDENCE_MIN < SECURITY_CLI_TIMEOUT,
4220            "a prompted read must be attributable before any deadline can end it"
4221        );
4222    }
4223
4224    /// End-to-end over the real poll loop, with a dialog held on screen for the
4225    /// whole run: a helper that exits in milliseconds must still come back
4226    /// unattributed.
4227    ///
4228    /// The probe is injected precisely so this cannot pass by accident. Probing
4229    /// the real `SecurityAgent` would make the assertion depend on whether the
4230    /// machine happens to have a dialog up — false on every CI runner, which
4231    /// would let the test pass with no attribution rule at all.
4232    #[cfg(target_os = "macos")]
4233    #[test]
4234    fn a_fast_helper_is_not_attributed_a_dialog_that_is_on_screen_throughout() {
4235        let mut command = std::process::Command::new("/bin/echo");
4236        command.arg("hi");
4237        let notices = std::sync::atomic::AtomicUsize::new(0);
4238        let run = bounded_command_output_with(
4239            &mut command,
4240            SECURITY_CLI_TIMEOUT,
4241            || true,
4242            || {
4243                notices.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4244            },
4245        )
4246        .unwrap();
4247        assert!(run.output.status.success());
4248        assert!(
4249            !run.prompted,
4250            "a helper that exited in milliseconds was not the one being authorized, \
4251             however many dialogs the machine is showing"
4252        );
4253        assert_eq!(
4254            notices.load(std::sync::atomic::Ordering::Relaxed),
4255            0,
4256            "and it must not tell the user to go answer a dialog it never waited on \
4257             (Parslee-ai/car#878 rides on the same attribution rule as #897)"
4258        );
4259    }
4260
4261    /// The other half: a helper that really is blocked past the threshold with a
4262    /// dialog up *is* attributed one. Without this, the rule could satisfy the
4263    /// test above by never attributing anything.
4264    #[cfg(target_os = "macos")]
4265    #[test]
4266    fn a_helper_still_blocked_past_the_threshold_is_attributed_the_dialog() {
4267        // Exits on its own well after PROMPT_EVIDENCE_MIN, so the run ends by
4268        // natural exit rather than by a deadline — the deadline is extended to
4269        // the interactive allowance while the probe says a dialog is up.
4270        let mut command = std::process::Command::new("/bin/sh");
4271        command.args(["-c", "sleep 1"]);
4272        let notices = std::sync::atomic::AtomicUsize::new(0);
4273        let run = bounded_command_output_with(
4274            &mut command,
4275            SECURITY_CLI_TIMEOUT,
4276            || true,
4277            || {
4278                notices.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4279            },
4280        )
4281        .unwrap();
4282        assert!(run.output.status.success(), "the child must exit naturally");
4283        assert!(
4284            run.prompted,
4285            "a call still running past PROMPT_EVIDENCE_MIN with a dialog up is \
4286             the call that dialog belongs to"
4287        );
4288        // Parslee-ai/car#878: exactly one line, and it arrived while the read
4289        // was still blocked rather than on expiry — this child exits naturally
4290        // at ~1s, so the run never reaches a deadline and the on-timeout
4291        // message is never appended. Only the prompt notice can have fired.
4292        //
4293        // ONE, not one-per-poll: the loop ticks every 10ms, so a notice keyed
4294        // on the condition rather than on the transition would emit ~50 lines
4295        // here and bury the daemon log it is supposed to make readable.
4296        assert_eq!(
4297            notices.load(std::sync::atomic::Ordering::Relaxed),
4298            1,
4299            "a blocked read must explain itself exactly once, promptly"
4300        );
4301    }
4302
4303    /// The notice and the on-expiry message describe the same condition three
4304    /// minutes apart, so they must not drift into giving different remedies.
4305    #[cfg(target_os = "macos")]
4306    #[test]
4307    fn the_prompt_notice_names_the_wait_and_both_remedies() {
4308        let notice = keychain_prompt_notice("car/parslee_access_token");
4309        assert!(
4310            notice.contains("car/parslee_access_token"),
4311            "must name WHICH item is being asked for — the operator who walked away \
4312             and came back to a stack of prompts cannot read the dialog after the \
4313             fact, and the log is the only record (Parslee-ai/car#897): {notice}"
4314        );
4315        assert!(
4316            notice.contains(&SECURITY_CLI_INTERACTIVE_TIMEOUT.as_secs().to_string()),
4317            "must state how long CAR will wait, or it reads as an indefinite hang: {notice}"
4318        );
4319        assert!(
4320            notice.contains("Always Allow"),
4321            "must name the one click that also prevents the NEXT prompt: {notice}"
4322        );
4323        assert!(
4324            notice.contains("Keychain Access"),
4325            "must name the remedy for someone who already dismissed the dialog: {notice}"
4326        );
4327        assert!(
4328            notice.contains("not hung"),
4329            "the reported failure was reading the silence as a hang and killing it: {notice}"
4330        );
4331    }
4332
4333    /// The item label is built from the `security` argv, so it has to survive
4334    /// every shape that argv takes — including the calls that name no item.
4335    #[cfg(target_os = "macos")]
4336    #[test]
4337    fn describe_item_names_the_keychain_item_from_the_argv() {
4338        assert_eq!(
4339            describe_item(&["find-generic-password", "-s", "car", "-a", "token", "-w"]),
4340            "car/token"
4341        );
4342        assert_eq!(
4343            describe_item(&["delete-generic-password", "-s", "car"]),
4344            "car"
4345        );
4346        assert_eq!(
4347            describe_item(&["find-generic-password", "-a", "token"]),
4348            "token"
4349        );
4350        // Not item-scoped: naming the subcommand still beats naming nothing.
4351        assert_eq!(describe_item(&["unlock-keychain"]), "unlock-keychain");
4352        assert_eq!(describe_item(&[]), "security");
4353        // A flag in final position has no value after it — must not panic.
4354        assert_eq!(
4355            describe_item(&["find-generic-password", "-s"]),
4356            "find-generic-password"
4357        );
4358    }
4359
4360    #[cfg(target_os = "macos")]
4361    #[test]
4362    fn mac_delete_uses_security_cli_and_maps_results() {
4363        let r = SecretRef::new("svc", "key");
4364        let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4365
4366        mac_delete_via_security_cli_with(&r, &cli).unwrap();
4367        assert_eq!(
4368            cli.calls(),
4369            vec![args(&["delete-generic-password", "-s", "svc", "-a", "key"])]
4370        );
4371
4372        let cli = FakeSecurityCli::new(vec![security_output(
4373            SECURITY_ERR_SEC_ITEM_NOT_FOUND,
4374            b"",
4375            b"The specified item could not be found in the keychain.\n",
4376        )]);
4377        mac_delete_via_security_cli_with(&r, &cli).unwrap();
4378
4379        let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
4380        let err = mac_delete_via_security_cli_with(&r, &cli).unwrap_err();
4381        assert_access_denied_contains(err, "auth denied");
4382    }
4383}
4384
4385#[cfg(all(test, target_os = "macos"))]
4386mod acl_repair_tests {
4387    use super::tests::*;
4388    use super::*;
4389
4390    fn pw(value: &str) -> Vec<u8> {
4391        format!("password: \"{value}\"\n").into_bytes()
4392    }
4393
4394    /// A ref unique to each test. The "attempted once per process" set is
4395    /// process-global by design, so tests sharing a key race for the single
4396    /// allowed attempt and whichever loses sees no repair at all.
4397    fn r(test: &str) -> SecretRef {
4398        SecretRef::new("ai.parslee.car".to_string(), format!("oauth-{test}"))
4399    }
4400
4401    fn verb(call: &[String]) -> &str {
4402        call.first().map(|s| s.as_str()).unwrap_or("")
4403    }
4404
4405    /// The whole point: a read that had to prompt rewrites the item so the next
4406    /// read does not. Anyone who signed in before v0.41.0 was otherwise
4407    /// prompted forever, because #689 fixed only writes.
4408    #[test]
4409    fn a_prompted_read_repairs_the_acl() {
4410        let cli = FakeSecurityCli::new(vec![
4411            security_output_prompted(0, Vec::new(), pw("tok")), // the read
4412            security_output(0, Vec::new(), Vec::new()),         // delete
4413            security_output(0, Vec::new(), Vec::new()),         // add -A
4414            security_output(0, Vec::new(), pw("tok")),          // verify
4415        ]);
4416        assert_eq!(
4417            mac_get_via_security_cli_with(&r("repairs"), &cli).unwrap(),
4418            "tok"
4419        );
4420        let calls = cli.calls();
4421        let verbs: Vec<&str> = calls.iter().map(|c| verb(c)).collect();
4422        assert_eq!(
4423            verbs,
4424            vec![
4425                "find-generic-password",
4426                "delete-generic-password",
4427                "add-generic-password",
4428                "find-generic-password"
4429            ]
4430        );
4431        let add = cli
4432            .calls()
4433            .into_iter()
4434            .find(|c| verb(c) == "add-generic-password")
4435            .unwrap();
4436        assert!(
4437            add.contains(&"-A".to_string()),
4438            "must write a permissive ACL: {add:?}"
4439        );
4440    }
4441
4442    /// A silent read is already fine. Rewriting it would be a pointless
4443    /// keychain mutation on every single read.
4444    #[test]
4445    fn a_silent_read_is_left_alone() {
4446        let cli = FakeSecurityCli::new(vec![security_output(0, Vec::new(), pw("tok"))]);
4447        assert_eq!(
4448            mac_get_via_security_cli_with(&r("silent"), &cli).unwrap(),
4449            "tok"
4450        );
4451        assert_eq!(cli.calls().len(), 1, "no repair for an unprompted read");
4452    }
4453
4454    /// If the add fails after the pre-delete already removed the item, the
4455    /// credential is GONE. Putting it back matters more than the ACL does.
4456    #[test]
4457    fn a_failed_add_puts_the_credential_back() {
4458        let cli = FakeSecurityCli::new(vec![
4459            security_output_prompted(0, Vec::new(), pw("tok")),
4460            security_output(0, Vec::new(), Vec::new()), // delete ok
4461            security_output(1, Vec::new(), b"boom".to_vec()), // add FAILS
4462            security_output(0, Vec::new(), Vec::new()), // rescue delete
4463            security_output(0, Vec::new(), Vec::new()), // rescue add
4464        ]);
4465        assert_eq!(
4466            mac_get_via_security_cli_with(&r("failed-add"), &cli).unwrap(),
4467            "tok"
4468        );
4469        let adds: Vec<Vec<String>> = cli
4470            .calls()
4471            .into_iter()
4472            .filter(|c| verb(c) == "add-generic-password")
4473            .collect();
4474        assert_eq!(
4475            adds.len(),
4476            2,
4477            "the value must be re-added after a failed add"
4478        );
4479        assert!(
4480            adds[1].contains(&"tok".to_string()),
4481            "and with the ORIGINAL value"
4482        );
4483    }
4484
4485    /// The write reported success but the item does not read back as the value
4486    /// we had. Never leave a credential replaced by something else.
4487    #[test]
4488    fn a_mismatched_readback_rewrites_the_original_value() {
4489        let cli = FakeSecurityCli::new(vec![
4490            security_output_prompted(0, Vec::new(), pw("tok")),
4491            security_output(0, Vec::new(), Vec::new()), // delete
4492            security_output(0, Vec::new(), Vec::new()), // add "succeeds"
4493            security_output(0, Vec::new(), pw("WRONG")), // verify -> mismatch
4494            security_output(0, Vec::new(), Vec::new()), // rescue delete
4495            security_output(0, Vec::new(), Vec::new()), // rescue add
4496        ]);
4497        assert_eq!(
4498            mac_get_via_security_cli_with(&r("mismatch"), &cli).unwrap(),
4499            "tok"
4500        );
4501        let adds: Vec<Vec<String>> = cli
4502            .calls()
4503            .into_iter()
4504            .filter(|c| verb(c) == "add-generic-password")
4505            .collect();
4506        assert_eq!(adds.len(), 2, "a mismatch must be corrected");
4507        assert!(adds[1].contains(&"tok".to_string()));
4508    }
4509
4510    /// One attempt per ref per process. If repair does not take, retrying on
4511    /// every read turns one bad dialog into an endless pair of them.
4512    #[test]
4513    fn repair_is_attempted_at_most_once_per_process() {
4514        let cli = FakeSecurityCli::new(vec![
4515            security_output_prompted(0, Vec::new(), pw("tok")),
4516            security_output(0, Vec::new(), Vec::new()),
4517            security_output(0, Vec::new(), Vec::new()),
4518            security_output(0, Vec::new(), pw("tok")),
4519            security_output_prompted(0, Vec::new(), pw("tok")), // second read, still prompts
4520        ]);
4521        assert_eq!(
4522            mac_get_via_security_cli_with(&r("once"), &cli).unwrap(),
4523            "tok"
4524        );
4525        let after_first = cli.calls().len();
4526        assert_eq!(
4527            mac_get_via_security_cli_with(&r("once"), &cli).unwrap(),
4528            "tok"
4529        );
4530        assert_eq!(
4531            cli.calls().len(),
4532            after_first + 1,
4533            "the second prompted read must not repair again"
4534        );
4535    }
4536
4537    /// A read that legitimately returns empty must not be written back — that
4538    /// would overwrite the item with nothing.
4539    #[test]
4540    fn an_empty_value_is_never_written_back() {
4541        let cli = FakeSecurityCli::new(vec![security_output_prompted(
4542            0,
4543            Vec::new(),
4544            b"password: \n".to_vec(),
4545        )]);
4546        assert_eq!(
4547            mac_get_via_security_cli_with(&r("empty"), &cli).unwrap(),
4548            ""
4549        );
4550        assert_eq!(cli.calls().len(), 1, "no repair for an empty value");
4551    }
4552
4553    /// A failed repair must never turn a working read into an error.
4554    #[test]
4555    fn repair_failure_does_not_fail_the_read() {
4556        let cli = FakeSecurityCli::new(vec![
4557            security_output_prompted(0, Vec::new(), pw("tok")),
4558            security_output(1, Vec::new(), b"nope".to_vec()),
4559            security_output(1, Vec::new(), b"nope".to_vec()),
4560            security_output(1, Vec::new(), b"nope".to_vec()),
4561            security_output(1, Vec::new(), b"nope".to_vec()),
4562        ]);
4563        assert_eq!(
4564            mac_get_via_security_cli_with(&r("failure"), &cli).unwrap(),
4565            "tok",
4566            "the caller's read succeeded; repair is best-effort"
4567        );
4568    }
4569}