Skip to main content

linesmith_core/data_context/credentials/
mod.rs

1//! OAuth credential resolution for the OAuth `/api/oauth/usage`
2//! endpoint. Reads the access token from macOS Keychain (primary +
3//! multi-account fallback) or from a file cascade
4//! (`$CLAUDE_CONFIG_DIR`, XDG, `~/.claude/`).
5//!
6//! Canonical spec: `docs/specs/credentials.md`.
7//!
8//! **Sensitivity contract.** The token never appears in [`Debug`] or
9//! [`Display`] output anywhere in this module. [`Credentials`] wraps
10//! the token in [`secrecy::SecretString`] with manual `Debug` that
11//! redacts it. [`CredentialError::ParseError`] carries a
12//! `serde_json::Error` whose Display would include a snippet of the
13//! source bytes — our Display impl prints only the path and line /
14//! column, never the cause's Display. [`std::error::Error::source`]
15//! still chains to the raw cause so callers who opt in (with caution)
16//! can inspect it.
17
18use std::fmt;
19use std::fs;
20use std::io::{self, Read};
21use std::path::{Path, PathBuf};
22
23use secrecy::{ExposeSecret, SecretString};
24
25/// Maximum credentials-file size we'll read. Claude Code writes
26/// small files (typically <2 KB); cap at 1 MB per
27/// `docs/specs/credentials.md` §Edge cases ("Huge credentials file")
28/// to defend against pathological inputs.
29const MAX_FILE_SIZE: u64 = 1_000_000;
30
31#[cfg(target_os = "macos")]
32const KEYCHAIN_SERVICE: &str = "Claude Code-credentials";
33
34// --- Types --------------------------------------------------------------
35
36/// Resolved OAuth credentials. Clone-on-Arc across segments; the
37/// underlying [`SecretString`] is cheap to clone.
38#[derive(Clone)]
39pub struct Credentials {
40    token: SecretString,
41    scopes: Vec<String>,
42    source: CredentialSource,
43}
44
45impl Credentials {
46    /// Access the raw bearer token. Consumers must not log or
47    /// serialize the returned string.
48    #[must_use]
49    pub fn token(&self) -> &str {
50        self.token.expose_secret()
51    }
52
53    /// OAuth scopes granted to this token.
54    #[must_use]
55    pub fn scopes(&self) -> &[String] {
56        &self.scopes
57    }
58
59    /// Which cascade step yielded the token (informational, for
60    /// `linesmith doctor`).
61    #[must_use]
62    pub fn source(&self) -> &CredentialSource {
63        &self.source
64    }
65}
66
67impl fmt::Debug for Credentials {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.debug_struct("Credentials")
70            .field("token", &"<redacted>")
71            .field("scopes", &self.scopes)
72            .field("source", &self.source)
73            .finish()
74    }
75}
76
77#[cfg(test)]
78impl Credentials {
79    /// Test-only constructor. Lets other modules in the crate
80    /// fabricate a `Credentials` without running the cascade.
81    /// Rejects empty tokens so tests can't fabricate a state the
82    /// production resolver would reject as `EmptyToken`.
83    pub(crate) fn for_testing(token: impl Into<String>) -> Self {
84        let token: String = token.into();
85        debug_assert!(
86            !token.is_empty(),
87            "Credentials::for_testing requires a non-empty token",
88        );
89        Self {
90            token: SecretString::from(token),
91            scopes: Vec::new(),
92            source: CredentialSource::ClaudeLegacy {
93                path: PathBuf::from("/test"),
94            },
95        }
96    }
97}
98
99/// Where [`resolve_credentials`] found the token.
100#[derive(Debug, Clone, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum CredentialSource {
103    /// `security find-generic-password -s "Claude Code-credentials"`.
104    MacosKeychainPrimary,
105    /// `security dump-keychain` scan for
106    /// `Claude Code-credentials<suffix>` entries; newest `mdat` wins.
107    MacosKeychainMultiAccount {
108        service: String,
109        mdat: Option<String>,
110    },
111    /// `$CLAUDE_CONFIG_DIR/.credentials.json`.
112    EnvDir { path: PathBuf },
113    /// `$XDG_CONFIG_HOME/claude/.credentials.json` (fallback to
114    /// `~/.config/claude/.credentials.json`).
115    XdgConfig { path: PathBuf },
116    /// `~/.claude/.credentials.json` (Claude Code's legacy path).
117    ClaudeLegacy { path: PathBuf },
118}
119
120/// Failure modes for [`resolve_credentials`]. `Debug` and `Display`
121/// deliberately avoid forwarding `serde_json::Error`'s Display because
122/// its context snippet may include token bytes; the raw cause is still
123/// reachable via [`std::error::Error::source`] for callers who need it.
124#[non_exhaustive]
125pub enum CredentialError {
126    /// No token found in any cascade path.
127    NoCredentials,
128    /// `security` subprocess failed to launch or exited non-zero for
129    /// non-"not-found" reasons (Keychain locked, permission denied,
130    /// binary missing).
131    SubprocessFailed(io::Error),
132    /// Credentials file exists but could not be opened / read
133    /// (permission denied, truncated read, filesystem error).
134    IoError { path: PathBuf, cause: io::Error },
135    /// Credentials file is not valid JSON. Inner cause preserved for
136    /// [`std::error::Error::source`]; neither Display nor Debug
137    /// forwards its text.
138    ParseError {
139        path: PathBuf,
140        cause: serde_json::Error,
141    },
142    /// Credentials file parsed but the `claudeAiOauth` section is
143    /// absent entirely. Typically indicates a stale Claude Code
144    /// writer or a different tool sharing the path.
145    MissingField { path: PathBuf },
146    /// `claudeAiOauth` is present but `accessToken` is missing,
147    /// `null`, or an empty string — the token slot exists but can't
148    /// be used.
149    EmptyToken { path: PathBuf },
150}
151
152impl CredentialError {
153    /// Short plugin-facing error tag per `docs/specs/plugin-api.md`
154    /// §ctx shape exposed to rhai. `MissingField` and `EmptyToken`
155    /// aren't in plugin-api.md's enumerated list yet; spec will add
156    /// them in a v0.2 rev.
157    #[must_use]
158    pub fn code(&self) -> &'static str {
159        match self {
160            Self::NoCredentials => "NoCredentials",
161            Self::SubprocessFailed(_) => "SubprocessFailed",
162            Self::IoError { .. } => "IoError",
163            Self::ParseError { .. } => "ParseError",
164            Self::MissingField { .. } => "MissingField",
165            Self::EmptyToken { .. } => "EmptyToken",
166        }
167    }
168}
169
170/// Lossy `Clone`: `io::Error` and `serde_json::Error` don't implement
171/// `Clone`, so variants carrying them reconstruct near-equivalents
172/// (same `kind()` + textual message; `raw_os_error` and serde line
173/// numbers are lost). The variant tag — which is what [`Self::code`]
174/// and segment renderers key off per `rate-limit-segments.md`
175/// §Error message table — round-trips exactly. The crate's
176/// `unsafe_code = "forbid"` lint forecloses a transmute-based shallow
177/// clone, so this is the cheapest way to preserve variant-level
178/// detail across `Arc<Result<_, Self>>` boundaries like the cascade.
179impl Clone for CredentialError {
180    fn clone(&self) -> Self {
181        match self {
182            Self::NoCredentials => Self::NoCredentials,
183            Self::SubprocessFailed(e) => {
184                Self::SubprocessFailed(io::Error::new(e.kind(), e.to_string()))
185            }
186            Self::IoError { path, cause } => Self::IoError {
187                path: path.clone(),
188                cause: io::Error::new(cause.kind(), cause.to_string()),
189            },
190            Self::ParseError { path, cause } => Self::ParseError {
191                path: path.clone(),
192                cause: serde_json::Error::io(io::Error::other(cause.to_string())),
193            },
194            Self::MissingField { path } => Self::MissingField { path: path.clone() },
195            Self::EmptyToken { path } => Self::EmptyToken { path: path.clone() },
196        }
197    }
198}
199
200impl fmt::Debug for CredentialError {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        match self {
203            Self::NoCredentials => f.write_str("NoCredentials"),
204            Self::SubprocessFailed(e) => {
205                f.debug_tuple("SubprocessFailed").field(&e.kind()).finish()
206            }
207            Self::IoError { path, cause } => f
208                .debug_struct("IoError")
209                .field("path", path)
210                .field("cause_kind", &cause.kind())
211                .finish(),
212            Self::ParseError { path, cause } => f
213                .debug_struct("ParseError")
214                .field("path", path)
215                .field("line", &cause.line())
216                .field("column", &cause.column())
217                .finish(),
218            Self::MissingField { path } => {
219                f.debug_struct("MissingField").field("path", path).finish()
220            }
221            Self::EmptyToken { path } => f.debug_struct("EmptyToken").field("path", path).finish(),
222        }
223    }
224}
225
226impl fmt::Display for CredentialError {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        match self {
229            Self::NoCredentials => f.write_str("no OAuth credentials found"),
230            Self::SubprocessFailed(e) => {
231                write!(f, "security subprocess failed ({kind})", kind = e.kind())
232            }
233            Self::IoError { path, cause } => write!(
234                f,
235                "failed to read credentials file {}: {kind}",
236                path.display(),
237                kind = cause.kind()
238            ),
239            Self::ParseError { path, cause } => write!(
240                f,
241                "credentials file {} failed to parse at line {}, column {}",
242                path.display(),
243                cause.line(),
244                cause.column()
245            ),
246            Self::MissingField { path } => write!(
247                f,
248                "credentials file {} missing claudeAiOauth.accessToken",
249                path.display()
250            ),
251            Self::EmptyToken { path } => write!(
252                f,
253                "credentials file {} has empty accessToken",
254                path.display()
255            ),
256        }
257    }
258}
259
260impl std::error::Error for CredentialError {
261    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
262        match self {
263            Self::SubprocessFailed(e) => Some(e),
264            Self::IoError { cause, .. } => Some(cause),
265            Self::ParseError { cause, .. } => Some(cause),
266            _ => None,
267        }
268    }
269}
270
271// --- Serde shapes -------------------------------------------------------
272
273#[derive(serde::Deserialize)]
274struct CredentialsFile {
275    #[serde(rename = "claudeAiOauth")]
276    claude_ai_oauth: Option<ClaudeAiOauth>,
277}
278
279#[derive(serde::Deserialize)]
280struct ClaudeAiOauth {
281    /// Double-wrapped `Option` so we can tell "key absent" from
282    /// "key present and null" — serde's default `Option<String>`
283    /// collapses both into `None`. With [`deserialize_explicit`]:
284    ///   * key absent       → `None`             → `MissingField`
285    ///   * explicit `null`  → `Some(None)`       → `EmptyToken`
286    ///   * empty string     → `Some(Some(""))`   → `EmptyToken`
287    ///   * non-empty string → `Some(Some(_))`    → token
288    ///   * any other type   → serde fails parse  → `ParseError`
289    #[serde(
290        default,
291        rename = "accessToken",
292        deserialize_with = "deserialize_explicit"
293    )]
294    access_token: Option<Option<String>>,
295    #[serde(default)]
296    scopes: Vec<String>,
297}
298
299/// Deserialize helper that preserves the distinction between an
300/// absent JSON key and one explicitly set to `null`. Invoked only
301/// when the key is present (`#[serde(default)]` handles absence).
302fn deserialize_explicit<'de, D>(de: D) -> Result<Option<Option<String>>, D::Error>
303where
304    D: serde::Deserializer<'de>,
305{
306    use serde::Deserialize;
307    Option::<String>::deserialize(de).map(Some)
308}
309
310// --- Public entry point -------------------------------------------------
311
312/// Resolve the OAuth access token via the cascade in
313/// `docs/specs/credentials.md` §Resolution cascade: macOS Keychain
314/// (primary + multi-account) on macOS, then file-based cascade on all
315/// platforms. Memoization for process-lifetime reuse is the caller's
316/// responsibility; each invocation re-runs the full cascade.
317pub fn resolve_credentials() -> Result<Credentials, CredentialError> {
318    resolve_credentials_with(&FileCascadeEnv::from_process_env())
319}
320
321/// Same cascade as [`resolve_credentials`] but with an explicit
322/// [`FileCascadeEnv`]. Lets doctor and tests pin the file-cascade
323/// inputs without mutating process env, which is racy under Rust's
324/// default parallel test execution.
325///
326/// macOS Keychain probes don't depend on the file-cascade env vars
327/// in `FileCascadeEnv`; they shell out to `security` (which reads
328/// `$USER` independently to scope the lookup).
329pub fn resolve_credentials_with(env: &FileCascadeEnv) -> Result<Credentials, CredentialError> {
330    // Hold the first "fall-through" subprocess error so if every
331    // later cascade step also yields nothing, we surface the real
332    // reason (e.g., Keychain locked) instead of a generic
333    // `NoCredentials`. Per credentials.md §Resolution cascade step 1.
334    #[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
335    let mut first_subprocess_err: Option<CredentialError> = None;
336
337    #[cfg(target_os = "macos")]
338    {
339        match macos::try_keychain_primary() {
340            Ok(Some(creds)) => return Ok(creds),
341            Ok(None) => {}
342            Err(e) => first_subprocess_err = Some(e),
343        }
344        match macos::try_keychain_multi_account() {
345            Ok(Some(creds)) => return Ok(creds),
346            Ok(None) => {}
347            Err(e) => {
348                if first_subprocess_err.is_none() {
349                    first_subprocess_err = Some(e);
350                }
351            }
352        }
353    }
354
355    match try_file_cascade_with(env) {
356        Ok(creds) => Ok(creds),
357        Err(CredentialError::NoCredentials) => {
358            // Prefer a recorded subprocess failure over the generic
359            // "nothing found" terminal.
360            Err(first_subprocess_err.unwrap_or(CredentialError::NoCredentials))
361        }
362        Err(e) => Err(e),
363    }
364}
365
366// --- File cascade -------------------------------------------------------
367
368/// Environmental inputs for the file-cascade portion of credential
369/// resolution. macOS Keychain probes shell out to `security` and
370/// don't depend on these fields, so only the file-cascade env vars
371/// live here. Treats empty string as unset per `credentials.md`
372/// §Edge cases.
373///
374/// Built once at the call boundary — `driver.rs` and `lib.rs::run_*`
375/// pull from process env via [`Self::from_process_env`]; doctor
376/// builds via the same factory; tests construct directly. The
377/// cascade itself never touches `std::env`.
378///
379/// **Construction safety**: prefer [`Self::new`] or
380/// [`Self::from_process_env`] over struct-literal construction. Both
381/// constructors enforce the empty-string-as-unset invariant; direct
382/// field assignment trusts the caller. An empty `PathBuf` (`""`)
383/// passed in via direct construction would make the cascade walk
384/// `"".join(".credentials.json")` = `".credentials.json"` (a
385/// CWD-relative path), which `try_file_cascade_with` would then
386/// stat against whatever directory the binary happened to launch
387/// in. That's a security-relevant footgun for a credential
388/// resolver — use the constructors.
389#[derive(Debug, Clone, Default)]
390#[non_exhaustive]
391pub struct FileCascadeEnv {
392    /// `$CLAUDE_CONFIG_DIR`.
393    pub claude_config_dir: Option<PathBuf>,
394    /// `$XDG_CONFIG_HOME`.
395    pub xdg_config_home: Option<PathBuf>,
396    /// `$HOME`.
397    pub home: Option<PathBuf>,
398}
399
400impl FileCascadeEnv {
401    /// Build a [`FileCascadeEnv`] with empty-string-as-unset
402    /// normalization. Pass `None` for "unset"; pass `Some(path)` for
403    /// "set to this value." Empty-string `OsString` values collapse
404    /// to `None` per `credentials.md` §Edge cases.
405    #[must_use]
406    pub fn new(
407        claude_config_dir: Option<std::ffi::OsString>,
408        xdg_config_home: Option<std::ffi::OsString>,
409        home: Option<std::ffi::OsString>,
410    ) -> Self {
411        fn nonempty(v: Option<std::ffi::OsString>) -> Option<PathBuf> {
412            v.filter(|s| !s.is_empty()).map(PathBuf::from)
413        }
414        Self {
415            claude_config_dir: nonempty(claude_config_dir),
416            xdg_config_home: nonempty(xdg_config_home),
417            home: nonempty(home),
418        }
419    }
420
421    /// Snapshot the three relevant env vars from the process via
422    /// `var_os` so non-UTF-8 values (Unix byte-string paths) survive
423    /// through to the cascade. Empty strings collapse to `None`.
424    /// Used by the bare [`resolve_credentials`] wrapper; explicit-env
425    /// callers go through [`Self::new`].
426    #[must_use]
427    pub fn from_process_env() -> Self {
428        Self::new(
429            std::env::var_os("CLAUDE_CONFIG_DIR"),
430            std::env::var_os("XDG_CONFIG_HOME"),
431            std::env::var_os("HOME"),
432        )
433    }
434}
435
436/// Candidate (path, source) pairs for the file cascade, in order.
437/// Returned as `Vec` rather than an iterator so tests can assert the
438/// full list.
439fn file_cascade_candidates(env: &FileCascadeEnv) -> Vec<(PathBuf, CredentialSource)> {
440    let mut out = Vec::with_capacity(3);
441
442    if let Some(dir) = &env.claude_config_dir {
443        let path = dir.join(".credentials.json");
444        out.push((path.clone(), CredentialSource::EnvDir { path }));
445    }
446
447    // XDG candidate is emitted whenever an XDG root is derivable —
448    // either from `$XDG_CONFIG_HOME` directly, or from `$HOME` via the
449    // default `~/.config`. A HOME-less CI/service environment with
450    // only `$XDG_CONFIG_HOME` set still gets its XDG path probed.
451    let xdg_root = env
452        .xdg_config_home
453        .clone()
454        .or_else(|| env.home.as_ref().map(|h| h.join(".config")));
455    if let Some(xdg_root) = xdg_root {
456        let xdg_path = xdg_root.join("claude").join(".credentials.json");
457        out.push((
458            xdg_path.clone(),
459            CredentialSource::XdgConfig { path: xdg_path },
460        ));
461    }
462
463    // Legacy `~/.claude/` only makes sense when `$HOME` is available.
464    if let Some(home) = &env.home {
465        let legacy_path = home.join(".claude").join(".credentials.json");
466        out.push((
467            legacy_path.clone(),
468            CredentialSource::ClaudeLegacy { path: legacy_path },
469        ));
470    }
471
472    out
473}
474
475fn try_file_cascade_with(env: &FileCascadeEnv) -> Result<Credentials, CredentialError> {
476    // Advance past `NotFound` and `PermissionDenied` — the spec's
477    // "first readable+parseable file wins" (`credentials.md`
478    // §Resolution cascade) means an unreadable path shouldn't shadow
479    // a later path the user can actually open. Other IO errors are
480    // terminal per credentials.md §Edge cases.
481    for (path, source) in file_cascade_candidates(env) {
482        match fs::metadata(&path) {
483            Ok(_) => return read_and_parse_file(&path, source),
484            Err(e)
485                if e.kind() == io::ErrorKind::NotFound
486                    || e.kind() == io::ErrorKind::PermissionDenied =>
487            {
488                continue
489            }
490            Err(cause) => return Err(CredentialError::IoError { path, cause }),
491        }
492    }
493    Err(CredentialError::NoCredentials)
494}
495
496fn read_and_parse_file(
497    path: &Path,
498    source: CredentialSource,
499) -> Result<Credentials, CredentialError> {
500    let file = fs::File::open(path).map_err(|cause| CredentialError::IoError {
501        path: path.to_path_buf(),
502        cause,
503    })?;
504    let mut buf = String::new();
505    // Read up to MAX_FILE_SIZE + 1 so we can detect oversized files
506    // via buffer length. Rejecting oversized files explicitly (rather
507    // than letting serde parse the prefix + padding) avoids a panic
508    // on pathological inputs where the first bytes form a complete
509    // valid JSON followed by trailing whitespace.
510    file.take(MAX_FILE_SIZE + 1)
511        .read_to_string(&mut buf)
512        .map_err(|cause| CredentialError::IoError {
513            path: path.to_path_buf(),
514            cause,
515        })?;
516    if buf.len() as u64 > MAX_FILE_SIZE {
517        return Err(CredentialError::IoError {
518            path: path.to_path_buf(),
519            cause: io::Error::new(
520                io::ErrorKind::InvalidData,
521                format!("credentials file exceeds {MAX_FILE_SIZE} byte limit"),
522            ),
523        });
524    }
525    parse_credentials_bytes(&buf, path, source)
526}
527
528fn parse_credentials_bytes(
529    bytes: &str,
530    path: &Path,
531    source: CredentialSource,
532) -> Result<Credentials, CredentialError> {
533    let file: CredentialsFile =
534        serde_json::from_str(bytes).map_err(|cause| CredentialError::ParseError {
535            path: path.to_path_buf(),
536            cause,
537        })?;
538    let oauth = file
539        .claude_ai_oauth
540        .ok_or_else(|| CredentialError::MissingField {
541            path: path.to_path_buf(),
542        })?;
543    // Spec-driven taxonomy (credentials.md §Interface):
544    //   key absent        → MissingField (shape problem)
545    //   null or "" value  → EmptyToken   (slot present, unusable)
546    //   non-string value  → ParseError   (surfaces earlier during
547    //                       `serde_json::from_str` via the typed
548    //                       `Option<Option<String>>` field)
549    //   non-empty string  → success
550    match oauth.access_token {
551        None => Err(CredentialError::MissingField {
552            path: path.to_path_buf(),
553        }),
554        Some(None) => Err(CredentialError::EmptyToken {
555            path: path.to_path_buf(),
556        }),
557        Some(Some(s)) if s.is_empty() => Err(CredentialError::EmptyToken {
558            path: path.to_path_buf(),
559        }),
560        Some(Some(s)) => Ok(Credentials {
561            token: SecretString::from(s),
562            scopes: oauth.scopes,
563            source,
564        }),
565    }
566}
567
568// --- macOS Keychain -----------------------------------------------------
569
570#[cfg(target_os = "macos")]
571mod macos {
572    use super::*;
573    use std::io::Read;
574    use std::os::unix::process::ExitStatusExt;
575    use std::process::{Command, ExitStatus, Stdio};
576    use std::time::{Duration, Instant};
577
578    /// `security` subprocess budget. Matches the 2s OAuth endpoint
579    /// budget from ADR-0011 §Endpoint contract; a wedged `security`
580    /// process, locked Keychain that doesn't prompt, or pathological
581    /// dump must not hang the statusline.
582    const SECURITY_TIMEOUT: Duration = Duration::from_secs(2);
583    const POLL_INTERVAL: Duration = Duration::from_millis(50);
584    /// Grace period after `kill()` for the kernel to reap the child.
585    /// If the child isn't reaped within this window we detach the
586    /// reader threads instead of blocking — bounded timeout beats
587    /// perfect cleanup.
588    const KILL_GRACE: Duration = Duration::from_millis(500);
589    /// Maximum stderr bytes embedded in a `Failed` error message.
590    /// `dump-keychain` failures can emit multi-KB diagnostics; we
591    /// cap to keep log payloads bounded.
592    const MAX_STDERR_IN_ERROR: usize = 512;
593
594    /// Exit code `security` uses when the requested item isn't in the
595    /// keychain (macOS `errSecItemNotFound`). Any other non-zero exit
596    /// indicates a real failure (locked keychain, permission denied,
597    /// binary missing) that we must preserve for diagnostics.
598    const ERR_SEC_ITEM_NOT_FOUND: i32 = 44;
599
600    pub(super) fn try_keychain_primary() -> Result<Option<Credentials>, CredentialError> {
601        let user = std::env::var("USER").unwrap_or_default();
602        let mut args: Vec<&str> = vec!["find-generic-password"];
603        if !user.is_empty() {
604            args.extend(["-a", &user]);
605        }
606        args.extend(["-w", "-s", KEYCHAIN_SERVICE]);
607
608        let run = run_security(&args).map_err(CredentialError::SubprocessFailed)?;
609        match classify_security_exit(&run) {
610            SecurityResult::ItemNotFound => return Ok(None),
611            SecurityResult::Failed(e) => return Err(CredentialError::SubprocessFailed(e)),
612            SecurityResult::Success => {}
613        }
614        let stdout = String::from_utf8_lossy(&run.stdout);
615        let stdout = stdout.trim();
616        if stdout.is_empty() {
617            return Ok(None);
618        }
619        match parse_credentials_bytes(
620            stdout,
621            Path::new("keychain:Claude Code-credentials"),
622            CredentialSource::MacosKeychainPrimary,
623        ) {
624            Ok(creds) => Ok(Some(creds)),
625            Err(CredentialError::MissingField { .. } | CredentialError::EmptyToken { .. }) => {
626                Ok(None)
627            }
628            Err(e) => Err(e),
629        }
630    }
631
632    pub(super) fn try_keychain_multi_account() -> Result<Option<Credentials>, CredentialError> {
633        let dump = run_security(&["dump-keychain"]).map_err(CredentialError::SubprocessFailed)?;
634        match classify_security_exit(&dump) {
635            SecurityResult::ItemNotFound => return Ok(None),
636            SecurityResult::Failed(e) => return Err(CredentialError::SubprocessFailed(e)),
637            SecurityResult::Success => {}
638        }
639        let dump_text = String::from_utf8_lossy(&dump.stdout);
640        let mut candidates = parse_dump_for_services(&dump_text);
641        // Newest mdat first; entries without mdat sort last (stable by
642        // dump order).
643        candidates.sort_by(|a, b| match (&a.mdat, &b.mdat) {
644            (Some(am), Some(bm)) => bm.cmp(am),
645            (Some(_), None) => std::cmp::Ordering::Less,
646            (None, Some(_)) => std::cmp::Ordering::Greater,
647            (None, None) => std::cmp::Ordering::Equal,
648        });
649
650        // A per-candidate failure (spawn error, timeout, locked-
651        // keychain exit) shouldn't abort the whole multi-account
652        // probe — later candidates may succeed. Capture the first
653        // error and surface it only if every candidate misses.
654        let mut first_err: Option<io::Error> = None;
655        for candidate in candidates {
656            let run = match run_security(&["find-generic-password", "-w", "-s", &candidate.service])
657            {
658                Ok(r) => r,
659                Err(e) => {
660                    if first_err.is_none() {
661                        first_err = Some(e);
662                    }
663                    continue;
664                }
665            };
666            match classify_security_exit(&run) {
667                SecurityResult::ItemNotFound => continue,
668                SecurityResult::Failed(e) => {
669                    if first_err.is_none() {
670                        first_err = Some(e);
671                    }
672                    continue;
673                }
674                SecurityResult::Success => {}
675            }
676            let stdout = String::from_utf8_lossy(&run.stdout);
677            let stdout = stdout.trim();
678            if stdout.is_empty() {
679                continue;
680            }
681            match parse_credentials_bytes(
682                stdout,
683                Path::new("keychain"),
684                CredentialSource::MacosKeychainMultiAccount {
685                    service: candidate.service.clone(),
686                    mdat: candidate.mdat.clone(),
687                },
688            ) {
689                Ok(creds) => return Ok(Some(creds)),
690                Err(CredentialError::MissingField { .. } | CredentialError::EmptyToken { .. }) => {
691                    continue
692                }
693                Err(e) => return Err(e),
694            }
695        }
696        match first_err {
697            Some(e) => Err(CredentialError::SubprocessFailed(e)),
698            None => Ok(None),
699        }
700    }
701
702    pub(super) struct SecurityRun {
703        pub status: ExitStatus,
704        pub stdout: Vec<u8>,
705        pub stderr: Vec<u8>,
706    }
707
708    pub(super) enum SecurityResult {
709        /// Process exited with status 0.
710        Success,
711        /// Process exited with `errSecItemNotFound` — expected miss;
712        /// the cascade should advance to the next step.
713        ItemNotFound,
714        /// Any other non-zero exit. Preserved so the cascade in
715        /// `resolve_credentials` can surface the real reason if no
716        /// later step yields credentials.
717        Failed(io::Error),
718    }
719
720    /// Spawn `security` with stdout/stderr piped, drain both in reader
721    /// threads so the child can't block on pipe-full, and enforce
722    /// [`SECURITY_TIMEOUT`] by polling `try_wait` + killing on
723    /// deadline.
724    ///
725    /// Happy path: child exits, pipes close, readers hit EOF, handles
726    /// join to return accumulated bytes.
727    ///
728    /// Timeout path: `kill`, poll `try_wait` for up to [`KILL_GRACE`]
729    /// so the kernel can reap the child and close the pipes. If reap
730    /// doesn't happen in that window the reader handles are detached
731    /// — they finish on their own once the kernel eventually reaps.
732    /// Total budget is bounded at `SECURITY_TIMEOUT + KILL_GRACE`.
733    pub(super) fn run_security(args: &[&str]) -> io::Result<SecurityRun> {
734        let mut child = Command::new("security")
735            .args(args)
736            .stdin(Stdio::null())
737            .stdout(Stdio::piped())
738            .stderr(Stdio::piped())
739            .spawn()?;
740
741        let stdout = child.stdout.take().expect("stdout piped");
742        let stderr = child.stderr.take().expect("stderr piped");
743        let stdout_handle = std::thread::spawn(move || drain(stdout));
744        let stderr_handle = std::thread::spawn(move || drain(stderr));
745
746        let deadline = Instant::now() + SECURITY_TIMEOUT;
747        let status = loop {
748            match child.try_wait()? {
749                Some(status) => break status,
750                None => {
751                    if Instant::now() >= deadline {
752                        let _ = child.kill();
753                        // Bounded try_wait loop; never `wait()` which
754                        // could block past budget if reap stalls.
755                        let grace_deadline = Instant::now() + KILL_GRACE;
756                        while Instant::now() < grace_deadline {
757                            if let Ok(Some(_)) = child.try_wait() {
758                                break;
759                            }
760                            std::thread::sleep(POLL_INTERVAL);
761                        }
762                        // Detach reader handles; they exit once the
763                        // kernel eventually closes the pipes.
764                        drop(stdout_handle);
765                        drop(stderr_handle);
766                        return Err(io::Error::new(
767                            io::ErrorKind::TimedOut,
768                            format!("security timed out after {}s", SECURITY_TIMEOUT.as_secs()),
769                        ));
770                    }
771                    std::thread::sleep(POLL_INTERVAL);
772                }
773            }
774        };
775
776        // Propagate reader panics as io::Error rather than silently
777        // dropping output — a panicked drain would otherwise look
778        // like clean empty stdout/stderr and mask a real failure.
779        let stdout = stdout_handle
780            .join()
781            .map_err(|_| io::Error::other("security stdout reader thread panicked"))?;
782        let stderr = stderr_handle
783            .join()
784            .map_err(|_| io::Error::other("security stderr reader thread panicked"))?;
785        Ok(SecurityRun {
786            status,
787            stdout,
788            stderr,
789        })
790    }
791
792    fn drain<R: Read>(mut reader: R) -> Vec<u8> {
793        let mut buf = Vec::new();
794        let _ = reader.read_to_end(&mut buf);
795        buf
796    }
797
798    pub(super) fn classify_security_exit(run: &SecurityRun) -> SecurityResult {
799        if run.status.success() {
800            return SecurityResult::Success;
801        }
802        if run.status.code() == Some(ERR_SEC_ITEM_NOT_FOUND) {
803            return SecurityResult::ItemNotFound;
804        }
805        let stderr = String::from_utf8_lossy(&run.stderr);
806        let stderr = truncate_for_error(stderr.trim());
807        let msg = match (run.status.code(), run.status.signal()) {
808            (Some(code), _) if stderr.is_empty() => {
809                format!("security exited with status {code}")
810            }
811            (Some(code), _) => format!("security exited with status {code}: {stderr}"),
812            (None, Some(sig)) if stderr.is_empty() => {
813                format!("security terminated by signal {sig}")
814            }
815            (None, Some(sig)) => format!("security terminated by signal {sig}: {stderr}"),
816            (None, None) if stderr.is_empty() => String::from("security terminated abnormally"),
817            (None, None) => format!("security terminated abnormally: {stderr}"),
818        };
819        SecurityResult::Failed(io::Error::other(msg))
820    }
821
822    /// Cap stderr content embedded in error messages. `dump-keychain`
823    /// failures can emit multi-KB diagnostics; an unbounded stderr
824    /// could balloon log payloads.
825    fn truncate_for_error(s: &str) -> String {
826        if s.len() <= MAX_STDERR_IN_ERROR {
827            return s.to_string();
828        }
829        let mut end = MAX_STDERR_IN_ERROR;
830        while end > 0 && !s.is_char_boundary(end) {
831            end -= 1;
832        }
833        format!("{}... (truncated)", &s[..end])
834    }
835
836    struct Candidate {
837        service: String,
838        mdat: Option<String>,
839    }
840
841    /// Scan `security dump-keychain` output for generic-password
842    /// entries whose `svce` begins with the Claude Code service prefix.
843    /// Pulls optional `mdat` blobs for sort-by-modification-time.
844    fn parse_dump_for_services(dump: &str) -> Vec<Candidate> {
845        let mut out = Vec::new();
846        let mut current_svce: Option<String> = None;
847        let mut current_mdat: Option<String> = None;
848        for line in dump.lines() {
849            let line = line.trim();
850            if line.starts_with("keychain:") {
851                // New entry boundary. Emit the prior if it matches.
852                if let Some(svce) = current_svce.take() {
853                    if svce.starts_with(KEYCHAIN_SERVICE) {
854                        out.push(Candidate {
855                            service: svce,
856                            mdat: current_mdat.take(),
857                        });
858                    } else {
859                        current_mdat = None;
860                    }
861                }
862                continue;
863            }
864            if let Some(val) = extract_quoted_after(line, "\"svce\"") {
865                current_svce = Some(val);
866            } else if let Some(val) = extract_quoted_after(line, "\"mdat\"") {
867                current_mdat = Some(val);
868            }
869        }
870        // Tail entry.
871        if let Some(svce) = current_svce {
872            if svce.starts_with(KEYCHAIN_SERVICE) {
873                out.push(Candidate {
874                    service: svce,
875                    mdat: current_mdat,
876                });
877            }
878        }
879        out
880    }
881
882    /// Pull the first `"<content>"` occurrence on a line prefixed by
883    /// `key`. Tolerates the `security` dump's `<blob>="<value>"` and
884    /// `<blob>=0x...  "<value>"` forms.
885    fn extract_quoted_after(line: &str, key: &str) -> Option<String> {
886        let after_key = line.strip_prefix(key)?;
887        let first_quote = after_key.find('"')?;
888        let rest = &after_key[first_quote + 1..];
889        let close_quote = rest.find('"')?;
890        Some(rest[..close_quote].to_string())
891    }
892
893    #[cfg(test)]
894    mod tests {
895        use super::*;
896        use std::os::unix::process::ExitStatusExt;
897
898        fn run_with(raw_status: i32, stderr: &[u8]) -> SecurityRun {
899            SecurityRun {
900                status: ExitStatus::from_raw(raw_status),
901                stdout: Vec::new(),
902                stderr: stderr.to_vec(),
903            }
904        }
905
906        #[test]
907        fn classify_success_on_zero_exit() {
908            // Raw status 0 = exit code 0 = success.
909            let run = run_with(0, b"");
910            assert!(matches!(
911                classify_security_exit(&run),
912                SecurityResult::Success
913            ));
914        }
915
916        #[test]
917        fn classify_item_not_found_on_exit_44() {
918            // Unix wait status: exit code is in the high byte.
919            let run = run_with(ERR_SEC_ITEM_NOT_FOUND << 8, b"");
920            assert!(matches!(
921                classify_security_exit(&run),
922                SecurityResult::ItemNotFound,
923            ));
924        }
925
926        #[test]
927        fn classify_other_non_zero_exit_is_failed_with_stderr() {
928            let run = run_with(25 << 8, b"keychain locked");
929            let SecurityResult::Failed(e) = classify_security_exit(&run) else {
930                panic!("expected Failed variant for non-zero exit with stderr");
931            };
932            let msg = e.to_string();
933            // Anchor on the format-string contract, not loose substrings.
934            assert!(msg.contains("status 25"), "msg={msg}");
935            assert!(msg.contains(": keychain locked"), "msg={msg}");
936        }
937
938        #[test]
939        fn classify_signal_termination_includes_signal_number() {
940            // Raw wait status 9 = terminated by SIGKILL, no exit code.
941            let run = run_with(9, b"");
942            let SecurityResult::Failed(e) = classify_security_exit(&run) else {
943                panic!("expected Failed variant for signal termination");
944            };
945            let msg = e.to_string();
946            assert!(msg.contains("terminated by signal 9"), "msg={msg}",);
947        }
948
949        #[test]
950        fn classify_signal_termination_includes_stderr() {
951            // SIGSEGV = 11; pair with diagnostic stderr to match the
952            // "terminated by signal N: <stderr>" format arm.
953            let run = run_with(11, b"segfault diag");
954            let SecurityResult::Failed(e) = classify_security_exit(&run) else {
955                panic!("expected Failed variant");
956            };
957            let msg = e.to_string();
958            assert!(msg.contains("terminated by signal 11"), "msg={msg}");
959            assert!(msg.contains(": segfault diag"), "msg={msg}");
960        }
961
962        #[test]
963        fn classify_failed_truncates_long_stderr() {
964            let long_stderr = "x".repeat(MAX_STDERR_IN_ERROR * 2);
965            let run = run_with(25 << 8, long_stderr.as_bytes());
966            let SecurityResult::Failed(e) = classify_security_exit(&run) else {
967                panic!("expected Failed variant");
968            };
969            let msg = e.to_string();
970            assert!(msg.contains("(truncated)"), "msg={msg}");
971            // Embedded stderr bytes capped at MAX_STDERR_IN_ERROR.
972            assert!(
973                msg.len() < long_stderr.len(),
974                "expected truncation, got msg.len()={}",
975                msg.len()
976            );
977        }
978
979        #[test]
980        fn parses_dump_with_single_matching_service() {
981            let dump = r#"keychain: "/Users/alice/Library/Keychains/login.keychain-db"
982    "svce"<blob>="Claude Code-credentials"
983    "acct"<blob>="alice"
984    "mdat"<timedate>=0x30303030  "20260418105500Z"
985"#;
986            let candidates = parse_dump_for_services(dump);
987            assert_eq!(candidates.len(), 1);
988            assert_eq!(candidates[0].service, "Claude Code-credentials");
989            assert_eq!(candidates[0].mdat.as_deref(), Some("20260418105500Z"));
990        }
991
992        #[test]
993        fn skips_non_matching_services() {
994            let dump = r#"keychain: "/path/to/login.keychain"
995    "svce"<blob>="some.other.app"
996    "mdat"<timedate>=0x00 "20260101000000Z"
997keychain: "/path/to/login.keychain"
998    "svce"<blob>="Claude Code-credentials-acct2"
999    "mdat"<timedate>=0x00 "20260420000000Z"
1000"#;
1001            let candidates = parse_dump_for_services(dump);
1002            assert_eq!(candidates.len(), 1);
1003            assert_eq!(candidates[0].service, "Claude Code-credentials-acct2");
1004        }
1005    }
1006}
1007
1008#[cfg(test)]
1009mod tests;