Skip to main content

linesmith_core/data_context/
credentials.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// --- Tests --------------------------------------------------------------
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013    use tempfile::TempDir;
1014
1015    fn write_creds(dir: &Path, relative: &str, contents: &str) -> PathBuf {
1016        let path = dir.join(relative);
1017        fs::create_dir_all(path.parent().unwrap()).unwrap();
1018        fs::write(&path, contents).unwrap();
1019        path
1020    }
1021
1022    fn valid_credentials_json(token: &str) -> String {
1023        format!(
1024            r#"{{
1025                "claudeAiOauth": {{
1026                    "accessToken": "{token}",
1027                    "refreshToken": null,
1028                    "expiresAt": null,
1029                    "scopes": ["user:inference", "user:profile"],
1030                    "subscriptionType": null
1031                }}
1032            }}"#
1033        )
1034    }
1035
1036    #[test]
1037    fn parses_valid_credentials_bytes() {
1038        let json = valid_credentials_json("test-token-xyz");
1039        let creds = parse_credentials_bytes(
1040            &json,
1041            Path::new("/test"),
1042            CredentialSource::ClaudeLegacy {
1043                path: PathBuf::from("/test"),
1044            },
1045        )
1046        .expect("parse");
1047        assert_eq!(creds.token(), "test-token-xyz");
1048        assert_eq!(creds.scopes().len(), 2);
1049        assert!(matches!(
1050            creds.source(),
1051            CredentialSource::ClaudeLegacy { .. }
1052        ));
1053    }
1054
1055    #[test]
1056    fn rejects_null_token_as_empty_token() {
1057        let json = r#"{ "claudeAiOauth": { "accessToken": null } }"#;
1058        let err = parse_credentials_bytes(
1059            json,
1060            Path::new("/test"),
1061            CredentialSource::ClaudeLegacy {
1062                path: PathBuf::from("/test"),
1063            },
1064        )
1065        .unwrap_err();
1066        assert!(matches!(err, CredentialError::EmptyToken { .. }));
1067    }
1068
1069    #[test]
1070    fn rejects_absent_access_token_key_as_missing_field() {
1071        // Spec: absent key → `MissingField`; `null`/`""` → `EmptyToken`.
1072        // Tested here: oauth block present, key simply missing.
1073        let json = r#"{ "claudeAiOauth": { "scopes": ["x"] } }"#;
1074        let err = parse_credentials_bytes(
1075            json,
1076            Path::new("/test"),
1077            CredentialSource::ClaudeLegacy {
1078                path: PathBuf::from("/test"),
1079            },
1080        )
1081        .unwrap_err();
1082        assert!(matches!(err, CredentialError::MissingField { .. }));
1083    }
1084
1085    #[test]
1086    fn rejects_non_string_access_token() {
1087        let json = r#"{ "claudeAiOauth": { "accessToken": 42 } }"#;
1088        let err = parse_credentials_bytes(
1089            json,
1090            Path::new("/test"),
1091            CredentialSource::ClaudeLegacy {
1092                path: PathBuf::from("/test"),
1093            },
1094        )
1095        .unwrap_err();
1096        assert!(matches!(err, CredentialError::ParseError { .. }));
1097    }
1098
1099    #[test]
1100    fn rejects_empty_token() {
1101        let json = r#"{ "claudeAiOauth": { "accessToken": "" } }"#;
1102        let err = parse_credentials_bytes(
1103            json,
1104            Path::new("/test"),
1105            CredentialSource::ClaudeLegacy {
1106                path: PathBuf::from("/test"),
1107            },
1108        )
1109        .unwrap_err();
1110        assert!(matches!(err, CredentialError::EmptyToken { .. }));
1111    }
1112
1113    #[test]
1114    fn rejects_missing_claude_ai_oauth() {
1115        let json = r#"{ "somethingElse": {} }"#;
1116        let err = parse_credentials_bytes(
1117            json,
1118            Path::new("/test"),
1119            CredentialSource::ClaudeLegacy {
1120                path: PathBuf::from("/test"),
1121            },
1122        )
1123        .unwrap_err();
1124        assert!(matches!(err, CredentialError::MissingField { .. }));
1125    }
1126
1127    #[test]
1128    fn rejects_invalid_json() {
1129        let json = "{ not json at all ";
1130        let err = parse_credentials_bytes(
1131            json,
1132            Path::new("/test"),
1133            CredentialSource::ClaudeLegacy {
1134                path: PathBuf::from("/test"),
1135            },
1136        )
1137        .unwrap_err();
1138        assert!(matches!(err, CredentialError::ParseError { .. }));
1139    }
1140
1141    #[test]
1142    fn scopes_default_to_empty_when_missing() {
1143        let json = r#"{ "claudeAiOauth": { "accessToken": "t" } }"#;
1144        let creds = parse_credentials_bytes(
1145            json,
1146            Path::new("/test"),
1147            CredentialSource::ClaudeLegacy {
1148                path: PathBuf::from("/test"),
1149            },
1150        )
1151        .expect("parse");
1152        assert!(creds.scopes().is_empty());
1153    }
1154
1155    #[test]
1156    fn credentials_debug_redacts_token() {
1157        let creds = Credentials {
1158            token: SecretString::from("super-secret-token".to_string()),
1159            scopes: vec!["x".to_string()],
1160            source: CredentialSource::ClaudeLegacy {
1161                path: PathBuf::from("/etc/x"),
1162            },
1163        };
1164        let debug = format!("{creds:?}");
1165        assert!(
1166            !debug.contains("super-secret-token"),
1167            "debug leaks token: {debug}"
1168        );
1169        assert!(debug.contains("<redacted>"));
1170    }
1171
1172    #[test]
1173    fn credential_error_code_taxonomy() {
1174        // Construct a real serde_json::Error so ParseError's code is
1175        // exercised with a real cause (not a synthetic one).
1176        let parse_err = serde_json::from_str::<i32>("not-a-number").unwrap_err();
1177
1178        let all: [(CredentialError, &str); 6] = [
1179            (CredentialError::NoCredentials, "NoCredentials"),
1180            (
1181                CredentialError::SubprocessFailed(io::Error::other("x")),
1182                "SubprocessFailed",
1183            ),
1184            (
1185                CredentialError::IoError {
1186                    path: PathBuf::from("/x"),
1187                    cause: io::Error::other("x"),
1188                },
1189                "IoError",
1190            ),
1191            (
1192                CredentialError::ParseError {
1193                    path: PathBuf::from("/x"),
1194                    cause: parse_err,
1195                },
1196                "ParseError",
1197            ),
1198            (
1199                CredentialError::MissingField {
1200                    path: PathBuf::from("/x"),
1201                },
1202                "MissingField",
1203            ),
1204            (
1205                CredentialError::EmptyToken {
1206                    path: PathBuf::from("/x"),
1207                },
1208                "EmptyToken",
1209            ),
1210        ];
1211
1212        // Each variant produces its documented code.
1213        for (err, expected) in &all {
1214            assert_eq!(err.code(), *expected);
1215        }
1216
1217        // Codes are unique across the taxonomy (no copy-paste collisions).
1218        let codes: std::collections::HashSet<&'static str> =
1219            all.iter().map(|(e, _)| e.code()).collect();
1220        assert_eq!(codes.len(), all.len());
1221    }
1222
1223    #[test]
1224    fn parse_error_display_does_not_leak_token_bytes() {
1225        // Build a malformed-JSON buffer whose middle contains a
1226        // sentinel "token". Our Display contract promises we never
1227        // forward `serde_json::Error`'s context snippet — verify.
1228        let leaky = r#"{ "claudeAiOauth": { "accessToken": "LEAK-ME-abcdef" "#;
1229        let err = parse_credentials_bytes(
1230            leaky,
1231            Path::new("/etc/creds"),
1232            CredentialSource::ClaudeLegacy {
1233                path: PathBuf::from("/etc/creds"),
1234            },
1235        )
1236        .unwrap_err();
1237        assert!(matches!(err, CredentialError::ParseError { .. }));
1238        let display = format!("{err}");
1239        let debug = format!("{err:?}");
1240        assert!(
1241            !display.contains("LEAK-ME"),
1242            "Display leaked token: {display}"
1243        );
1244        assert!(!debug.contains("LEAK-ME"), "Debug leaked token: {debug}");
1245    }
1246
1247    #[test]
1248    fn oversized_file_rejected_before_parse() {
1249        let tmp = TempDir::new().unwrap();
1250        let path = tmp.path().join("big.json");
1251        // 1 MB of 'x' = exceeds MAX_FILE_SIZE — no JSON round-trip
1252        // possible. Ensures the length check fires before the serde
1253        // parser sees the buffer.
1254        let big = "x".repeat((MAX_FILE_SIZE + 1024) as usize);
1255        fs::write(&path, &big).unwrap();
1256        let err = read_and_parse_file(&path, CredentialSource::ClaudeLegacy { path: path.clone() })
1257            .unwrap_err();
1258        match err {
1259            CredentialError::IoError { cause, .. } => {
1260                assert_eq!(cause.kind(), io::ErrorKind::InvalidData);
1261            }
1262            other => panic!("expected IoError(InvalidData), got {other:?}"),
1263        }
1264    }
1265
1266    /// Tests pass a constructed `FileCascadeEnv` rather than mutating
1267    /// the process env. `set_var` / `remove_var` are `unsafe` in
1268    /// modern Rust and the crate-level `unsafe_code = "forbid"` lint
1269    /// would reject the direct-mutation pattern — and using the real
1270    /// env in parallel tests is inherently racy anyway.
1271    mod cascade {
1272        use super::*;
1273
1274        fn env_from(
1275            claude: Option<&Path>,
1276            xdg: Option<&Path>,
1277            home: Option<&Path>,
1278        ) -> FileCascadeEnv {
1279            FileCascadeEnv {
1280                claude_config_dir: claude.map(Path::to_path_buf),
1281                xdg_config_home: xdg.map(Path::to_path_buf),
1282                home: home.map(Path::to_path_buf),
1283            }
1284        }
1285
1286        #[test]
1287        fn env_dir_candidate_included_when_set() {
1288            let tmp = TempDir::new().unwrap();
1289            let env = env_from(Some(tmp.path()), None, Some(tmp.path()));
1290            let candidates = file_cascade_candidates(&env);
1291            assert!(matches!(candidates[0].1, CredentialSource::EnvDir { .. }));
1292        }
1293
1294        #[test]
1295        fn env_dir_absent_when_not_set() {
1296            // `FileCascadeEnv::from_process_env` already maps empty
1297            // strings to None; this test covers the cascade-level
1298            // behavior: no env-dir field → first candidate is XDG.
1299            let tmp = TempDir::new().unwrap();
1300            let env = env_from(None, None, Some(tmp.path()));
1301            let candidates = file_cascade_candidates(&env);
1302            assert!(
1303                !matches!(candidates[0].1, CredentialSource::EnvDir { .. }),
1304                "no CLAUDE_CONFIG_DIR should omit the EnvDir candidate"
1305            );
1306        }
1307
1308        #[test]
1309        fn xdg_preferred_over_legacy_when_both_roots_present() {
1310            let tmp = TempDir::new().unwrap();
1311            let xdg = tmp.path().join("xdg");
1312            let env = env_from(None, Some(&xdg), Some(tmp.path()));
1313            let candidates = file_cascade_candidates(&env);
1314            let positions: Vec<_> = candidates
1315                .iter()
1316                .map(|(_, s)| match s {
1317                    CredentialSource::XdgConfig { .. } => "xdg",
1318                    CredentialSource::ClaudeLegacy { .. } => "legacy",
1319                    _ => "other",
1320                })
1321                .collect();
1322            assert_eq!(positions, ["xdg", "legacy"]);
1323        }
1324
1325        #[test]
1326        fn xdg_default_root_is_home_dot_config() {
1327            let tmp = TempDir::new().unwrap();
1328            let env = env_from(None, None, Some(tmp.path()));
1329            let candidates = file_cascade_candidates(&env);
1330            let xdg = candidates
1331                .iter()
1332                .find(|(_, s)| matches!(s, CredentialSource::XdgConfig { .. }))
1333                .expect("xdg candidate present");
1334            assert!(xdg.0.starts_with(tmp.path().join(".config").join("claude")));
1335        }
1336
1337        #[test]
1338        fn resolve_reads_existing_env_dir_credentials() {
1339            let tmp = TempDir::new().unwrap();
1340            write_creds(
1341                tmp.path(),
1342                ".credentials.json",
1343                &valid_credentials_json("env-dir-tok"),
1344            );
1345            let env = env_from(Some(tmp.path()), None, Some(tmp.path()));
1346            let creds = try_file_cascade_with(&env).expect("resolve");
1347            assert_eq!(creds.token(), "env-dir-tok");
1348            assert!(matches!(creds.source(), CredentialSource::EnvDir { .. }));
1349        }
1350
1351        #[test]
1352        fn resolve_falls_through_to_xdg() {
1353            let tmp = TempDir::new().unwrap();
1354            let xdg = tmp.path().join("xdg");
1355            write_creds(
1356                &xdg,
1357                "claude/.credentials.json",
1358                &valid_credentials_json("xdg-tok"),
1359            );
1360            let env = env_from(
1361                Some(&tmp.path().join("does-not-exist")),
1362                Some(&xdg),
1363                Some(tmp.path()),
1364            );
1365            let creds = try_file_cascade_with(&env).expect("resolve");
1366            assert_eq!(creds.token(), "xdg-tok");
1367            assert!(matches!(creds.source(), CredentialSource::XdgConfig { .. }));
1368        }
1369
1370        #[test]
1371        fn resolve_falls_through_to_legacy() {
1372            let tmp = TempDir::new().unwrap();
1373            write_creds(
1374                tmp.path(),
1375                ".claude/.credentials.json",
1376                &valid_credentials_json("legacy-tok"),
1377            );
1378            let env = env_from(None, None, Some(tmp.path()));
1379            let creds = try_file_cascade_with(&env).expect("resolve");
1380            assert_eq!(creds.token(), "legacy-tok");
1381            assert!(matches!(
1382                creds.source(),
1383                CredentialSource::ClaudeLegacy { .. }
1384            ));
1385        }
1386
1387        #[test]
1388        fn resolve_no_files_returns_no_credentials() {
1389            let tmp = TempDir::new().unwrap();
1390            let env = env_from(None, None, Some(tmp.path()));
1391            let err = try_file_cascade_with(&env).unwrap_err();
1392            assert!(matches!(err, CredentialError::NoCredentials));
1393        }
1394
1395        #[test]
1396        fn resolve_no_home_returns_no_credentials() {
1397            let env = env_from(None, None, None);
1398            let err = try_file_cascade_with(&env).unwrap_err();
1399            assert!(matches!(err, CredentialError::NoCredentials));
1400        }
1401
1402        #[test]
1403        fn xdg_path_probed_even_when_home_is_unset() {
1404            // Service/CI environments can set $XDG_CONFIG_HOME without
1405            // $HOME; the XDG candidate must not be gated on home.
1406            let tmp = TempDir::new().unwrap();
1407            let xdg = tmp.path().join("xdg");
1408            write_creds(
1409                &xdg,
1410                "claude/.credentials.json",
1411                &valid_credentials_json("xdg-no-home-tok"),
1412            );
1413            let env = env_from(None, Some(&xdg), None);
1414            let creds = try_file_cascade_with(&env).expect("resolve");
1415            assert_eq!(creds.token(), "xdg-no-home-tok");
1416            assert!(matches!(creds.source(), CredentialSource::XdgConfig { .. }));
1417        }
1418
1419        #[test]
1420        fn candidate_list_includes_xdg_when_home_unset() {
1421            let tmp = TempDir::new().unwrap();
1422            let xdg = tmp.path().join("xdg");
1423            let env = env_from(None, Some(&xdg), None);
1424            let candidates = file_cascade_candidates(&env);
1425            assert!(
1426                candidates
1427                    .iter()
1428                    .any(|(_, s)| matches!(s, CredentialSource::XdgConfig { .. })),
1429                "XDG candidate must be present with HOME unset + XDG_CONFIG_HOME set",
1430            );
1431            assert!(
1432                !candidates
1433                    .iter()
1434                    .any(|(_, s)| matches!(s, CredentialSource::ClaudeLegacy { .. })),
1435                "Legacy candidate requires HOME",
1436            );
1437        }
1438
1439        #[test]
1440        fn xdg_wins_when_both_xdg_and_legacy_files_exist() {
1441            let tmp = TempDir::new().unwrap();
1442            let xdg = tmp.path().join("xdg");
1443            write_creds(
1444                &xdg,
1445                "claude/.credentials.json",
1446                &valid_credentials_json("xdg-wins"),
1447            );
1448            write_creds(
1449                tmp.path(),
1450                ".claude/.credentials.json",
1451                &valid_credentials_json("legacy-loses"),
1452            );
1453            let env = env_from(None, Some(&xdg), Some(tmp.path()));
1454            let creds = try_file_cascade_with(&env).expect("resolve");
1455            assert_eq!(creds.token(), "xdg-wins");
1456            assert!(matches!(creds.source(), CredentialSource::XdgConfig { .. }));
1457        }
1458
1459        #[test]
1460        fn env_dir_set_but_empty_dir_falls_through_to_xdg() {
1461            // `CLAUDE_CONFIG_DIR` is a hint, not a declaration — per
1462            // credentials.md §Edge cases. A set-but-empty (no
1463            // credentials.json inside) env dir shouldn't shadow XDG.
1464            let tmp = TempDir::new().unwrap();
1465            let env_dir = tmp.path().join("env-dir");
1466            fs::create_dir_all(&env_dir).unwrap();
1467            let xdg = tmp.path().join("xdg");
1468            write_creds(
1469                &xdg,
1470                "claude/.credentials.json",
1471                &valid_credentials_json("xdg-tok"),
1472            );
1473            let env = env_from(Some(&env_dir), Some(&xdg), Some(tmp.path()));
1474            let creds = try_file_cascade_with(&env).expect("resolve");
1475            assert_eq!(creds.token(), "xdg-tok");
1476        }
1477
1478        #[test]
1479        fn resolve_credentials_end_to_end_no_files() {
1480            // Integration-level smoke: public entry point via
1481            // DataContext::credentials() memoization. With no files
1482            // and no Keychain hit (empty HOME, no CLAUDE_CONFIG_DIR),
1483            // the cascade terminates at NoCredentials.
1484            let tmp = TempDir::new().unwrap();
1485            let env = env_from(None, None, Some(tmp.path()));
1486            let err = try_file_cascade_with(&env).unwrap_err();
1487            assert!(matches!(err, CredentialError::NoCredentials));
1488        }
1489    }
1490}