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)]
1009mod tests;