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