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