1use std::fmt;
19use std::fs;
20use std::io::{self, Read};
21use std::path::{Path, PathBuf};
22
23use secrecy::{ExposeSecret, SecretString};
24
25const MAX_FILE_SIZE: u64 = 1_000_000;
30
31#[cfg(target_os = "macos")]
32const KEYCHAIN_SERVICE: &str = "Claude Code-credentials";
33
34#[derive(Clone)]
39pub struct Credentials {
40 token: SecretString,
41 scopes: Vec<String>,
42 source: CredentialSource,
43}
44
45impl Credentials {
46 #[must_use]
49 pub fn token(&self) -> &str {
50 self.token.expose_secret()
51 }
52
53 #[must_use]
55 pub fn scopes(&self) -> &[String] {
56 &self.scopes
57 }
58
59 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum CredentialSource {
103 MacosKeychainPrimary,
105 MacosKeychainMultiAccount {
108 service: String,
109 mdat: Option<String>,
110 },
111 EnvDir { path: PathBuf },
113 XdgConfig { path: PathBuf },
116 ClaudeLegacy { path: PathBuf },
118}
119
120#[non_exhaustive]
125pub enum CredentialError {
126 NoCredentials,
128 SubprocessFailed(io::Error),
132 IoError { path: PathBuf, cause: io::Error },
135 ParseError {
139 path: PathBuf,
140 cause: serde_json::Error,
141 },
142 MissingField { path: PathBuf },
146 EmptyToken { path: PathBuf },
150}
151
152impl CredentialError {
153 #[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
170impl 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#[derive(serde::Deserialize)]
274struct CredentialsFile {
275 #[serde(rename = "claudeAiOauth")]
276 claude_ai_oauth: Option<ClaudeAiOauth>,
277}
278
279#[derive(serde::Deserialize)]
280struct ClaudeAiOauth {
281 #[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
299fn 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
310pub fn resolve_credentials() -> Result<Credentials, CredentialError> {
318 resolve_credentials_with(&FileCascadeEnv::from_process_env())
319}
320
321pub fn resolve_credentials_with(env: &FileCascadeEnv) -> Result<Credentials, CredentialError> {
330 #[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 Err(first_subprocess_err.unwrap_or(CredentialError::NoCredentials))
361 }
362 Err(e) => Err(e),
363 }
364}
365
366#[derive(Debug, Clone, Default)]
390#[non_exhaustive]
391pub struct FileCascadeEnv {
392 pub claude_config_dir: Option<PathBuf>,
394 pub xdg_config_home: Option<PathBuf>,
396 pub home: Option<PathBuf>,
398}
399
400impl FileCascadeEnv {
401 #[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 #[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
436fn 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 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 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 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 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 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#[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 const SECURITY_TIMEOUT: Duration = Duration::from_secs(2);
583 const POLL_INTERVAL: Duration = Duration::from_millis(50);
584 const KILL_GRACE: Duration = Duration::from_millis(500);
589 const MAX_STDERR_IN_ERROR: usize = 512;
593
594 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 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 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 Success,
711 ItemNotFound,
714 Failed(io::Error),
718 }
719
720 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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)]
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 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 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 for (err, expected) in &all {
1214 assert_eq!(err.code(), *expected);
1215 }
1216
1217 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 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 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 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 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 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 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 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}