use super::{Account, AuthTool, Snapshot};
use crate::paths::Paths;
use crate::secret::Secret;
use anyhow::{bail, Context, Result};
use serde_json::Value;
pub struct Claude;
#[cfg(test)]
mod native_login_tests {
use super::*;
#[test]
fn native_default_and_custom_processes_choose_their_actual_keychain_service() {
assert_eq!(native_service_for_key(None), KEYCHAIN_PREFIX);
assert_eq!(
native_service_for_key(Some("/Users/me/Library/Application Support/Claude Work")),
"Claude Code-credentials-77ae3295"
);
}
#[test]
fn a_native_keychain_blob_is_one_coherent_source() {
let stale_file = br#"{"claudeAiOauth":{"accessToken":"OLD","expiresAt":1}}"#;
let fresh_keychain =
br#"{"claudeAiOauth":{"accessToken":"NEW","expiresAt":9999999999999}}"#;
assert_eq!(
choose_native_blob(
true,
Some(stale_file.to_vec()),
Some(fresh_keychain.to_vec())
),
Some(fresh_keychain.to_vec()),
"token and expiry both come from the authoritative Keychain blob"
);
assert_eq!(
choose_native_blob(true, Some(stale_file.to_vec()), None),
None,
"an unreadable Keychain must not fall back to a stale file"
);
assert_eq!(
choose_native_blob(
false,
Some(stale_file.to_vec()),
Some(fresh_keychain.to_vec())
),
Some(stale_file.to_vec()),
"file-backed platforms read their actual native file"
);
}
}
#[cfg(test)]
mod slot_credential_selection_tests {
use super::*;
const STALE_FILE: &[u8] = br#"{"claudeAiOauth":{"accessToken":"OLD"}}"#;
const FRESH_KEYCHAIN: &[u8] = br#"{"claudeAiOauth":{"accessToken":"NEW"}}"#;
#[test]
fn a_ready_keychain_selects_its_complete_generation() {
let selected =
choose_slot_credential(Some(STALE_FILE.to_vec()), Ok(FRESH_KEYCHAIN.to_vec()))
.expect("Keychain credential");
assert_eq!(selected.bytes(), FRESH_KEYCHAIN);
assert_eq!(selected.source(), SlotCredentialSource::Keychain);
}
#[test]
fn a_locked_or_missing_keychain_never_falls_back_to_a_stale_file() {
for unavailable in [KeychainReadError::Locked, KeychainReadError::Missing] {
assert!(matches!(
choose_slot_credential(Some(STALE_FILE.to_vec()), Err(unavailable)),
Err(error) if error == unavailable
));
}
}
#[test]
fn a_file_is_selected_only_when_keychain_is_not_applicable() {
let selected = choose_slot_credential(
Some(STALE_FILE.to_vec()),
Err(KeychainReadError::NotApplicable),
)
.expect("file credential");
assert_eq!(selected.bytes(), STALE_FILE);
assert_eq!(selected.source(), SlotCredentialSource::File);
}
}
#[cfg(test)]
mod capture_credential_selection_tests {
use super::*;
use std::cell::Cell;
#[test]
fn an_unavailable_explicit_slot_never_falls_back_to_a_live_login() {
let live_alternative = br#"{"claudeAiOauth":{"accessToken":"DEFAULT"}}"#.to_vec();
for unavailable in [KeychainReadError::Locked, KeychainReadError::Missing] {
let live_reads = Cell::new(0);
let selected = choose_capture_credential(
true,
|| Err(unavailable),
|| {
live_reads.set(live_reads.get() + 1);
Some(live_alternative.clone())
},
);
assert!(matches!(selected, Err(error) if error == unavailable));
assert_eq!(live_reads.get(), 0, "default source was still queried");
}
}
#[test]
fn an_ordinary_live_capture_keeps_its_environment_selected_source() {
let live = br#"{"claudeAiOauth":{"accessToken":"LIVE"}}"#.to_vec();
let selected = choose_capture_credential(
false,
|| panic!("an ordinary live capture must not query a slot source"),
|| Some(live.clone()),
)
.unwrap();
assert_eq!(selected, Some(live));
}
}
const SECURITY: &str = "/usr/bin/security";
const KEYCHAIN_PREFIX: &str = "Claude Code-credentials";
fn keychain_enabled() -> bool {
cfg!(target_os = "macos") && !cfg!(test) && std::env::var_os("SWAPDEX_ROOT").is_none()
}
fn keychain_policy(platform_keychain_enabled: bool, sandboxed: bool) -> bool {
platform_keychain_enabled && !sandboxed
}
fn keychain_enabled_for_paths(paths: &Paths) -> bool {
keychain_policy(keychain_enabled(), paths.sandboxed())
}
fn keychain_account_name() -> String {
std::env::var("USER")
.ok()
.filter(|u| !u.is_empty())
.or_else(|| std::env::var("LOGNAME").ok().filter(|u| !u.is_empty()))
.unwrap_or_else(|| "claude-code-user".into())
}
fn parse_kc_attr(line: &str, attr: &str) -> Option<String> {
let needle = format!("\"{attr}\"");
let rest = line.split(&needle).nth(1)?;
let after = rest.split("=\"").nth(1)?;
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
pub(crate) fn native_service_for_key(key: Option<&str>) -> String {
match key {
None => KEYCHAIN_PREFIX.to_string(),
Some(key) => format!("{KEYCHAIN_PREFIX}-{}", &sha256_hex(key.as_bytes())[..8]),
}
}
fn choose_native_blob(
keychain_in_play: bool,
file: Option<Vec<u8>>,
keychain: Option<Vec<u8>>,
) -> Option<Vec<u8>> {
if keychain_in_play {
keychain
} else {
file
}
}
pub(crate) fn native_credentials(
paths: &Paths,
dir: &std::path::Path,
key: Option<&str>,
) -> Option<Vec<u8>> {
let keychain_in_play = keychain_enabled() && !paths.sandboxed();
let file = (!keychain_in_play)
.then(|| crate::atomic::read_regular(&dir.join(".credentials.json")).ok())
.flatten();
let keychain = keychain_in_play
.then(|| keychain_read_service_detail(&native_service_for_key(key)).ok())
.flatten();
choose_native_blob(keychain_in_play, file, keychain)
}
pub(crate) fn native_credential_detail(
paths: &Paths,
dir: &std::path::Path,
key: Option<&str>,
) -> std::result::Result<SlotCredential, KeychainReadError> {
if keychain_enabled_for_paths(paths) {
choose_slot_credential(
None,
keychain_read_service_detail(&native_service_for_key(key)),
)
} else {
choose_slot_credential(
crate::atomic::read_regular(&dir.join(".credentials.json")).ok(),
Err(KeychainReadError::NotApplicable),
)
}
}
pub(crate) fn native_keychain_write(key: Option<&str>, value: &[u8]) -> Result<()> {
if !keychain_enabled() {
anyhow::bail!("no Keychain in this environment");
}
keychain_write_service(&native_service_for_key(key), value)
}
fn env_computed_service() -> Option<String> {
match std::env::var("CLAUDE_SECURESTORAGE_CONFIG_DIR") {
Ok(t) if t.is_empty() => Some(KEYCHAIN_PREFIX.to_string()),
Ok(t) => Some(format!(
"{KEYCHAIN_PREFIX}-{}",
&sha256_hex(t.as_bytes())[..8]
)),
Err(_) => match std::env::var("CLAUDE_CONFIG_DIR") {
Ok(d) if !d.is_empty() => Some(format!(
"{KEYCHAIN_PREFIX}-{}",
&sha256_hex(d.as_bytes())[..8]
)),
_ => None,
},
}
}
fn keychain_service() -> Option<String> {
if !keychain_enabled() {
return None;
}
let computed = effective_computed_service();
pick_service(
computed.clone(),
keychain_item_exists(&computed),
all_claude_services(),
)
}
fn effective_computed_service() -> String {
env_computed_service().unwrap_or_else(|| KEYCHAIN_PREFIX.to_string())
}
fn pick_service(
computed: String,
computed_exists: bool,
discovered: Vec<String>,
) -> Option<String> {
if computed_exists {
return Some(computed);
}
if discovered.len() == 1 {
return discovered.into_iter().next();
}
None
}
fn keychain_item_exists(service: &str) -> bool {
std::process::Command::new(SECURITY)
.args([
"find-generic-password",
"-s",
service,
"-a",
&keychain_account_name(),
])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[derive(Debug, PartialEq)]
pub(crate) enum SlotLogin {
Absent,
Present(Option<i64>),
}
pub(crate) fn slot_login(dir: &std::path::Path) -> SlotLogin {
let file = dir.join(".credentials.json");
let file_present = file.exists();
let file_ts = std::fs::read(&file)
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| v["claudeAiOauth"]["expiresAt"].as_i64());
let (kc_present, kc_ts) = if keychain_enabled() {
let service = format!(
"{KEYCHAIN_PREFIX}-{}",
&sha256_hex(dir.to_string_lossy().as_bytes())[..8]
);
if keychain_item_exists(&service) {
(true, keychain_mdat_ms(&service))
} else {
(false, None)
}
} else {
(false, None)
};
if !file_present && !kc_present {
return SlotLogin::Absent;
}
SlotLogin::Present(match (file_ts, kc_ts) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
})
}
fn keychain_mdat_ms(service: &str) -> Option<i64> {
let out = std::process::Command::new(SECURITY)
.args([
"find-generic-password",
"-s",
service,
"-a",
&keychain_account_name(),
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
text.lines().find_map(parse_mdat_ms)
}
fn parse_mdat_ms(line: &str) -> Option<i64> {
if !line.contains("\"mdat\"<timedate>") {
return None;
}
let tail = line.rsplit('"').nth(1)?; let d: Vec<u32> = tail.chars().filter_map(|c| c.to_digit(10)).collect();
if d.len() < 14 {
return None;
}
let n = |i: usize, j: usize| -> i64 { d[i..j].iter().fold(0i64, |a, &x| a * 10 + x as i64) };
let (y, mo, day) = (n(0, 4), n(4, 6), n(6, 8));
let (h, mi, s) = (n(8, 10), n(10, 12), n(12, 14));
if !(1..=12).contains(&mo) || !(1..=31).contains(&day) || h > 23 || mi > 59 || s > 60 {
return None;
}
Some((days_from_civil(y, mo, day) * 86_400 + h * 3600 + mi * 60 + s) * 1000)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum KeychainReadError {
NotApplicable,
Locked,
Missing,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SlotCredentialSource {
File,
Keychain,
}
#[derive(Eq, PartialEq)]
pub(crate) struct SlotCredential {
bytes: Vec<u8>,
source: SlotCredentialSource,
}
impl SlotCredential {
pub(crate) fn bytes(&self) -> &[u8] {
&self.bytes
}
pub(crate) fn source(&self) -> SlotCredentialSource {
self.source
}
}
fn choose_capture_credential<SlotRead, LiveRead>(
strict_slot: bool,
slot: SlotRead,
live: LiveRead,
) -> std::result::Result<Option<Vec<u8>>, KeychainReadError>
where
SlotRead: FnOnce() -> std::result::Result<SlotCredential, KeychainReadError>,
LiveRead: FnOnce() -> Option<Vec<u8>>,
{
if strict_slot {
slot().map(|credential| Some(credential.bytes))
} else {
Ok(live())
}
}
pub(crate) fn choose_slot_credential(
file: Option<Vec<u8>>,
keychain: std::result::Result<Vec<u8>, KeychainReadError>,
) -> std::result::Result<SlotCredential, KeychainReadError> {
let selected = match keychain {
Ok(bytes) => (bytes, SlotCredentialSource::Keychain),
Err(KeychainReadError::NotApplicable) => (
file.ok_or(KeychainReadError::Missing)?,
SlotCredentialSource::File,
),
Err(error) => return Err(error),
};
if selected.0.is_empty() {
return Err(KeychainReadError::Missing);
}
Ok(SlotCredential {
bytes: selected.0,
source: selected.1,
})
}
pub(crate) fn slot_service(dir: &std::path::Path) -> String {
format!(
"{KEYCHAIN_PREFIX}-{}",
&sha256_hex(dir.to_string_lossy().as_bytes())[..8]
)
}
pub(crate) fn slot_keychain_read_detail(
dir: &std::path::Path,
) -> std::result::Result<Vec<u8>, KeychainReadError> {
if !keychain_enabled() {
return Err(KeychainReadError::NotApplicable);
}
keychain_read_service_detail(&slot_service(dir))
}
pub(crate) fn slot_credential(
dir: &std::path::Path,
) -> std::result::Result<SlotCredential, KeychainReadError> {
match slot_keychain_read_detail(dir) {
Err(KeychainReadError::NotApplicable) => choose_slot_credential(
crate::atomic::read_regular(&dir.join(".credentials.json")).ok(),
Err(KeychainReadError::NotApplicable),
),
keychain => choose_slot_credential(None, keychain),
}
}
fn keychain_read_service_detail(service: &str) -> std::result::Result<Vec<u8>, KeychainReadError> {
let out = std::process::Command::new(SECURITY)
.args([
"find-generic-password",
"-s",
service,
"-a",
&keychain_account_name(),
"-w",
])
.output()
.map_err(|_| KeychainReadError::Missing)?;
if !out.status.success() {
return Err(if out.status.code() == Some(36) {
KeychainReadError::Locked
} else {
KeychainReadError::Missing
});
}
let mut v = out.stdout;
while v.last().is_some_and(|b| *b == b'\n' || *b == b'\r') {
v.pop();
}
if v.is_empty() {
return Err(KeychainReadError::Missing);
}
Ok(v)
}
fn all_claude_services() -> Vec<String> {
let Ok(out) = std::process::Command::new(SECURITY)
.arg("dump-keychain")
.output()
else {
return Vec::new();
};
let text = String::from_utf8_lossy(&out.stdout);
let mut v: Vec<String> = Vec::new();
for line in text.lines() {
if let Some(svc) = parse_kc_attr(line, "svce") {
if svc.starts_with(KEYCHAIN_PREFIX) && !v.contains(&svc) {
v.push(svc);
}
}
}
v
}
pub(crate) struct KeychainDiag {
pub found: Vec<String>,
pub target: Option<String>,
pub computed: String,
pub config_dir: Option<String>,
}
pub(crate) fn keychain_diagnostic() -> Option<KeychainDiag> {
if !keychain_enabled() {
return None;
}
let config_dir = std::env::var("CLAUDE_SECURESTORAGE_CONFIG_DIR")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
std::env::var("CLAUDE_CONFIG_DIR")
.ok()
.filter(|s| !s.is_empty())
});
Some(KeychainDiag {
found: all_claude_services(),
target: keychain_service(),
computed: effective_computed_service(),
config_dir,
})
}
fn keychain_read() -> Option<Vec<u8>> {
let service = keychain_service()?;
let out = std::process::Command::new(SECURITY)
.args([
"find-generic-password",
"-s",
&service,
"-a",
&keychain_account_name(),
"-w",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let mut v = out.stdout;
while v.last() == Some(&b'\n') {
v.pop();
}
(!v.is_empty()).then_some(v)
}
#[derive(Debug, PartialEq)]
enum KcRead {
Present(Vec<u8>),
Absent,
Error,
}
fn classify_kc_read(success: bool, code: Option<i32>, stdout: Vec<u8>) -> KcRead {
if success {
let mut v = stdout;
while v.last() == Some(&b'\n') {
v.pop();
}
return if v.is_empty() {
KcRead::Absent
} else {
KcRead::Present(v)
};
}
match code {
Some(44) => KcRead::Absent, _ => KcRead::Error,
}
}
fn keychain_prior() -> Result<Option<Vec<u8>>> {
if !keychain_enabled() {
return Ok(None);
}
let service = effective_computed_service();
let out = std::process::Command::new(SECURITY)
.args([
"find-generic-password",
"-s",
&service,
"-a",
&keychain_account_name(),
"-w",
])
.output()
.context("read the current Keychain token")?;
match classify_kc_read(out.status.success(), out.status.code(), out.stdout) {
KcRead::Present(v) => Ok(Some(v)),
KcRead::Absent => Ok(None),
KcRead::Error => bail!("could not read the current Keychain token"),
}
}
fn keychain_write(value: &[u8]) -> Result<()> {
keychain_write_service(&effective_computed_service(), value)
}
fn keychain_write_service(service: &str, value: &[u8]) -> Result<()> {
use std::io::Write;
if !keychain_enabled() {
return Ok(());
}
let acct = keychain_account_name();
let hex: String = value.iter().map(|b| format!("{b:02x}")).collect();
let cmd = format!("add-generic-password -U -a \"{acct}\" -s \"{service}\" -X {hex}\n");
let mut child = std::process::Command::new(SECURITY)
.arg("-i")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.context("run `/usr/bin/security -i`")?;
child
.stdin
.as_mut()
.context("security stdin")?
.write_all(cmd.as_bytes())?;
let out = child.wait_with_output()?;
if !out.status.success() {
bail!(
"Keychain write failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
fn managed_item_may_remain(_computed_service: &str, computed_exists: bool) -> bool {
computed_exists
}
pub(crate) fn managed_present(paths: &Paths) -> bool {
if paths.claude_credentials().exists() {
return true;
}
let computed = effective_computed_service();
managed_item_may_remain(&computed, keychain_item_exists(&computed))
}
pub(crate) fn keychain_delete() {
keychain_delete_service(&effective_computed_service());
}
fn keychain_delete_service(service: &str) {
if !keychain_enabled() {
return;
}
let acct = keychain_account_name();
let _ = std::process::Command::new(SECURITY)
.args(["delete-generic-password", "-s", service, "-a", &acct])
.output();
}
#[derive(serde::Serialize, serde::Deserialize)]
struct ApplyWal {
cred_path: String,
cred_prior_hex: Option<String>,
cfg_path: String,
cfg_oauth_prior: Option<Value>,
kc_service: Option<String>,
kc_prior_hex: Option<String>,
}
pub(crate) fn to_hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
pub(crate) fn from_hex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
fn apply_wal_path(paths: &Paths) -> std::path::PathBuf {
paths.store_dir().join("apply-claude.wal")
}
fn write_apply_wal(paths: &Paths, wal: &ApplyWal) -> Result<()> {
let p = apply_wal_path(paths);
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).ok();
}
let bytes = serde_json::to_vec(wal).context("serialize apply WAL")?;
crate::atomic::write_secret(&p, &bytes)
}
fn remove_apply_wal(paths: &Paths) {
let _ = std::fs::remove_file(apply_wal_path(paths));
}
pub(crate) fn recover_interrupted_apply(paths: &Paths) {
let p = apply_wal_path(paths);
let Ok(bytes) = std::fs::read(&p) else {
return;
};
let Ok(wal) = serde_json::from_slice::<ApplyWal>(&bytes) else {
return; };
let mut ok = true;
let cfg_path = std::path::PathBuf::from(&wal.cfg_path);
if cfg_path.exists() {
if let Ok(cur) = crate::atomic::read_regular(&cfg_path) {
if let Ok(mut v) = serde_json::from_slice::<Value>(&cur) {
if let Some(obj) = v.as_object_mut() {
match &wal.cfg_oauth_prior {
Some(prior) => {
obj.insert("oauthAccount".into(), prior.clone());
}
None => {
obj.remove("oauthAccount");
}
}
match serde_json::to_vec(&v) {
Ok(nb) => ok &= crate::atomic::write_secret(&cfg_path, &nb).is_ok(),
Err(_) => ok = false,
}
}
}
}
}
let cred_path = std::path::PathBuf::from(&wal.cred_path);
match &wal.cred_prior_hex {
Some(hex) => match from_hex(hex) {
Some(b) => ok &= crate::atomic::write_secret(&cred_path, &b).is_ok(),
None => ok = false,
},
None => {
let _ = std::fs::remove_file(&cred_path);
}
}
if keychain_enabled_for_paths(paths) {
if let Some(service) = &wal.kc_service {
match &wal.kc_prior_hex {
Some(hex) => match from_hex(hex) {
Some(b) => ok &= keychain_write_service(service, &b).is_ok(),
None => ok = false,
},
None => keychain_delete_service(service),
}
}
}
if ok {
remove_apply_wal(paths);
}
}
fn cred_read(paths: &Paths) -> Option<Vec<u8>> {
if keychain_enabled_for_paths(paths) {
return keychain_read().or_else(|| {
let f = paths.claude_credentials();
f.exists()
.then(|| crate::atomic::read_regular(&f).ok())
.flatten()
});
}
let f = paths.claude_credentials();
f.exists()
.then(|| crate::atomic::read_regular(&f).ok())
.flatten()
}
fn capture_credential(paths: &Paths) -> Result<Option<Vec<u8>>> {
let selected = choose_capture_credential(
paths.claude_slot_context(),
|| capture_slot_credential(paths),
|| cred_read(paths),
);
selected.map_err(|error| match error {
KeychainReadError::Locked => anyhow::anyhow!(
"the selected Claude slot credential is unavailable because its Keychain is locked"
),
KeychainReadError::Missing => {
anyhow::anyhow!("the selected Claude slot has no readable credential")
}
KeychainReadError::NotApplicable => {
anyhow::anyhow!("the selected Claude slot credential is unavailable")
}
})
}
fn capture_slot_credential(
paths: &Paths,
) -> std::result::Result<SlotCredential, KeychainReadError> {
crate::claude_authority::credential(paths, paths.claude_dir())
}
pub fn plan_label(credential: &[u8]) -> Option<String> {
let v: Value = serde_json::from_slice(credential).ok()?;
let oauth = &v["claudeAiOauth"];
let plan = oauth["subscriptionType"]
.as_str()
.filter(|s| !s.is_empty())?;
let multiple = oauth["rateLimitTier"]
.as_str()
.and_then(|tier| tier.rsplit('_').next())
.filter(|x| {
x.strip_suffix('x')
.is_some_and(|n| n.parse::<u32>().is_ok())
});
Some(match multiple {
Some(x) if plan == "max" => format!("{plan} {x}"),
_ => plan.to_string(),
})
}
pub(crate) fn live_credentials(paths: &Paths) -> Option<Vec<u8>> {
cred_read(paths)
}
fn cred_present(paths: &Paths) -> bool {
paths.claude_credentials().exists() || keychain_read().is_some()
}
impl AuthTool for Claude {
fn name(&self) -> &'static str {
"claude-code"
}
fn present(&self, paths: &Paths) -> bool {
cred_present(paths)
}
fn managed_present(&self, paths: &Paths) -> bool {
managed_present(paths)
}
fn capture(&self, paths: &Paths) -> Result<Snapshot> {
recover_interrupted_apply(paths);
let Some(cred_bytes) = capture_credential(paths)? else {
bail!("not logged in to Claude Code");
};
serde_json::from_slice::<Value>(&cred_bytes)
.context("the Claude credential is not valid JSON")?;
let cfg_path = paths.claude_config_json();
let cfg: Value = if cfg_path.exists() {
serde_json::from_slice(&crate::atomic::read_regular(&cfg_path)?).context(
"the selected Claude identity metadata is corrupt - repair or remove \
it, then retry; removing loses local settings like project trust",
)?
} else {
Value::Null
};
let oauth = cfg.get("oauthAccount").cloned().unwrap_or(Value::Null);
let oauth_bytes = serde_json::to_vec(&oauth)?;
Ok(Snapshot {
tool: "claude-code",
blobs: vec![
("credentials".into(), Secret::new(cred_bytes)),
("oauth_account".into(), Secret::new(oauth_bytes)),
],
})
}
fn apply(&self, paths: &Paths, snap: &Snapshot) -> Result<()> {
recover_interrupted_apply(paths);
let cred = snap
.part("credentials")
.context("snapshot missing credentials")?;
let oauth = snap
.part("oauth_account")
.context("snapshot missing oauth_account")?;
serde_json::from_slice::<Value>(cred.expose())
.context("saved credentials are not valid JSON; refusing to apply")?;
let oauth_val: Value = serde_json::from_slice(oauth.expose())
.context("saved oauthAccount is not valid JSON; refusing to apply")?;
let cfg_path = paths.claude_config_json();
let mut cfg: Value = if cfg_path.exists() {
serde_json::from_slice(&crate::atomic::read_regular(&cfg_path)?).context(
"the selected Claude identity metadata is corrupt - repair or remove \
it, then retry; removing loses local settings like project trust",
)?
} else {
Value::Object(Default::default())
};
let cfg_oauth_prior = cfg.get("oauthAccount").cloned();
match cfg.as_object_mut() {
Some(obj) => {
obj.insert("oauthAccount".into(), oauth_val);
}
None => bail!(".claude.json is not a JSON object"),
}
let new_cfg = serde_json::to_vec(&cfg)?;
let cred_path = paths.claude_credentials();
let macos = keychain_enabled_for_paths(paths);
let prev_file = if cred_path.exists() {
crate::atomic::read_regular(&cred_path).ok()
} else {
None
};
let prev_kc = if macos {
match keychain_prior() {
Ok(v) => v,
Err(e) => {
return Err(e.context(
"apply aborted before any change - could not read the current \
Keychain token, so a failed switch could not be rolled back",
))
}
}
} else {
None
};
let wal = ApplyWal {
cred_path: cred_path.to_string_lossy().into_owned(),
cred_prior_hex: prev_file.as_deref().map(to_hex),
cfg_path: cfg_path.to_string_lossy().into_owned(),
cfg_oauth_prior,
kc_service: if macos {
Some(effective_computed_service())
} else {
None
},
kc_prior_hex: prev_kc.as_deref().map(to_hex),
};
write_apply_wal(paths, &wal)?;
let restore_file = |prev: &Option<Vec<u8>>| match prev {
Some(p) => crate::atomic::write_secret(&cred_path, p).is_ok(),
None => std::fs::remove_file(&cred_path).is_ok() || !cred_path.exists(),
};
crate::atomic::write_secret(&cred_path, cred.expose())?;
if macos {
if let Err(e) = keychain_write(cred.expose()) {
if restore_file(&prev_file) {
remove_apply_wal(paths); }
return Err(e.context("apply aborted; credential file rolled back"));
}
}
if let Err(e) = crate::atomic::write_secret(&cfg_path, &new_cfg) {
let f_ok = restore_file(&prev_file);
let k_ok = if macos {
match &prev_kc {
Some(p) => keychain_write(p).is_ok(),
None => {
keychain_delete();
true
}
}
} else {
true
};
let msg = if f_ok && k_ok {
remove_apply_wal(paths);
"apply aborted; the credential change was rolled back"
} else {
"apply aborted and the rollback FAILED - the login may be half-swapped; \
run `swapdex restore --tool claude` once the underlying problem is fixed"
};
return Err(e.context(msg));
}
remove_apply_wal(paths);
Ok(())
}
fn identity(&self, paths: &Paths) -> Result<Option<Account>> {
let Some(cred_bytes) = capture_credential(paths)? else {
return Ok(None);
};
let creds: Value = serde_json::from_slice(&cred_bytes)
.context("the Claude credential is not valid JSON")?;
let expires_at = creds["claudeAiOauth"]["expiresAt"].as_i64();
let tier = plan_label(&cred_bytes);
let cfg_path = paths.claude_config_json();
let cfg: Value = if cfg_path.exists() {
crate::atomic::read_regular(&cfg_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or(Value::Null)
} else {
Value::Null
};
let oauth = &cfg["oauthAccount"];
Ok(Some(Account {
tool: "claude-code",
account_id: oauth["accountUuid"].as_str().unwrap_or("").to_string(),
display: oauth["displayName"]
.as_str()
.unwrap_or("Claude account")
.to_string(),
email: oauth["emailAddress"].as_str().map(|s| s.to_string()),
tier,
expires_at,
}))
}
}
#[cfg(test)]
mod tests {
use super::slot_service;
#[test]
fn plan_label_names_which_max() {
let label = |sub: &str, tier: Option<&str>| {
let mut oauth = serde_json::json!({"subscriptionType": sub});
if let Some(tier) = tier {
oauth["rateLimitTier"] = tier.into();
}
plan_label(&serde_json::to_vec(&serde_json::json!({"claudeAiOauth": oauth})).unwrap())
};
assert_eq!(
label("max", Some("default_claude_max_20x")).as_deref(),
Some("max 20x")
);
assert_eq!(
label("max", Some("default_claude_max_5x")).as_deref(),
Some("max 5x")
);
assert_eq!(label("max", Some("default")).as_deref(), Some("max"));
assert_eq!(label("max", None).as_deref(), Some("max"));
assert_eq!(
label("team", Some("default_raven")).as_deref(),
Some("team")
);
assert_eq!(
label("pro", Some("default_claude_pro_2x")).as_deref(),
Some("pro")
);
assert_eq!(label("", Some("default_claude_max_20x")), None);
}
#[test]
fn a_slot_is_named_from_its_own_directory() {
let home = std::path::Path::new("/Users/me/.claude");
let got = slot_service(home);
assert!(got.starts_with("Claude Code-credentials-"), "{got}");
assert_eq!(got.len(), "Claude Code-credentials-".len() + 8);
assert_ne!(
got,
slot_service(std::path::Path::new("/Users/me/.claude-work"))
);
}
use super::*;
use crate::paths::Paths;
use serde_json::json;
#[test]
fn mdat_line_parses_to_unix_ms() {
let line = " \"mdat\"<timedate>=0x32303234303130313030303030305A00 \
\"20240101000000Z\\000\"";
assert_eq!(parse_mdat_ms(line), Some(1_704_067_200_000));
let epoch = "\"mdat\"<timedate>=0x00 \"19700101000000Z\\000\"";
assert_eq!(parse_mdat_ms(epoch), Some(0));
assert_eq!(
parse_mdat_ms("\"cdat\"<timedate>=0x00 \"20240101000000Z\""),
None
);
assert_eq!(parse_mdat_ms("\"mdat\"<timedate>=0x00 \"2024\""), None);
assert_eq!(
parse_mdat_ms("\"mdat\"<timedate>=0x00 \"20241301000000Z\""),
None,
"month 13 rejected"
);
}
#[test]
fn days_from_civil_matches_known_dates() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(days_from_civil(2024, 1, 1), 19723);
assert_eq!(days_from_civil(2024, 3, 1), 19783, "2024 is a leap year");
assert_eq!(days_from_civil(1969, 12, 31), -1);
}
#[test]
fn slot_login_distinguishes_absent_present_and_unreadable() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(slot_login(dir.path()), SlotLogin::Absent, "no login yet");
std::fs::write(
dir.path().join(".credentials.json"),
br#"{"claudeAiOauth":{"accessToken":"AT","expiresAt":1704067200000}}"#,
)
.unwrap();
assert_eq!(
slot_login(dir.path()),
SlotLogin::Present(Some(1_704_067_200_000))
);
std::fs::write(dir.path().join(".credentials.json"), b"not json").unwrap();
assert_eq!(slot_login(dir.path()), SlotLogin::Present(None));
}
#[test]
fn the_check_after_a_sign_out_asks_about_the_item_that_was_deleted() {
let gone_but_others_remain = pick_service(
"Claude Code-credentials".to_string(),
false,
vec!["Claude Code-credentials-abc123".to_string()],
);
assert_eq!(
gone_but_others_remain,
Some("Claude Code-credentials-abc123".to_string()),
"reading still falls back, which is what alias-only setups need"
);
assert!(
!managed_item_may_remain("Claude Code-credentials", false),
"the deleted item is gone, so the sign-out took"
);
assert!(
managed_item_may_remain("Claude Code-credentials", true),
"and if it is still there, it did not"
);
}
#[test]
fn pick_service_prefers_the_env_derived_item() {
let siblings = vec![
"Claude Code-credentials-5953ba74".to_string(),
"Claude Code-credentials-feeb5ea6".to_string(),
];
assert_eq!(
pick_service("Claude Code-credentials".into(), true, siblings),
Some("Claude Code-credentials".to_string())
);
}
#[test]
fn pick_service_falls_back_only_when_unambiguous() {
assert_eq!(
pick_service(
"Claude Code-credentials".into(),
false,
vec!["Claude Code-credentials-5953ba74".to_string()],
),
Some("Claude Code-credentials-5953ba74".to_string())
);
assert_eq!(
pick_service(
"Claude Code-credentials".into(),
false,
vec![
"Claude Code-credentials-5953ba74".to_string(),
"Claude Code-credentials-feeb5ea6".to_string(),
],
),
None
);
assert_eq!(
pick_service("Claude Code-credentials".into(), false, vec![]),
None
);
}
fn seed_claude(p: &Paths, acct: &str, email: &str) {
std::fs::create_dir_all(p.claude_credentials().parent().unwrap()).unwrap();
std::fs::write(
p.claude_credentials(),
serde_json::to_vec(&json!({"claudeAiOauth": {
"accessToken": "AT-SENTINEL", "refreshToken": "RT-SENTINEL",
"expiresAt": 9999999999999i64, "scopes": ["x"],
"subscriptionType": "max", "rateLimitTier": "default"}}))
.unwrap(),
)
.unwrap();
std::fs::write(
p.claude_config_json(),
serde_json::to_vec(&json!({
"projects": {"/home/x/proj": {"trust": true}},
"mcpServers": {"prodex": {"command": "prodex"}},
"theme": "dark",
"oauthAccount": {"accountUuid": acct, "emailAddress": email,
"displayName": "Work", "userRateLimitTier": "max"}
}))
.unwrap(),
)
.unwrap();
}
#[test]
fn sha256_hex_matches_known_vector() {
assert_eq!(&super::sha256_hex(b"abc")[..8], "ba7816bf");
}
#[test]
fn keychain_attr_parser_reads_svce_and_acct() {
assert_eq!(
super::parse_kc_attr(" \"acct\"<blob>=\"bsgong\"", "acct").as_deref(),
Some("bsgong")
);
assert_eq!(
super::parse_kc_attr(
" \"svce\"<blob>=\"Claude Code-credentials-5953ba74\"",
"svce"
)
.as_deref(),
Some("Claude Code-credentials-5953ba74")
);
assert_eq!(super::parse_kc_attr("no attr here", "acct"), None);
}
#[test]
fn classify_kc_read_distinguishes_absent_from_error() {
use super::{classify_kc_read, KcRead};
assert_eq!(classify_kc_read(false, Some(44), vec![]), KcRead::Absent);
assert_eq!(classify_kc_read(false, Some(1), vec![]), KcRead::Error);
assert_eq!(classify_kc_read(false, None, vec![]), KcRead::Error);
assert_eq!(
classify_kc_read(true, Some(0), b"tok\n".to_vec()),
KcRead::Present(b"tok".to_vec())
);
assert_eq!(classify_kc_read(true, Some(0), vec![]), KcRead::Absent);
}
#[test]
fn rooted_paths_disable_machine_keychain_operations() {
assert!(!keychain_policy(true, true));
assert!(keychain_policy(true, false));
}
#[test]
fn apply_rolls_back_credentials_when_config_write_fails() {
let a = tempfile::tempdir().unwrap();
let pa = Paths::rooted(a.path());
seed_claude(&pa, "uuid-A", "a@x.com");
let snap = Claude.capture(&pa).unwrap();
let b = tempfile::tempdir().unwrap();
let pb = Paths::rooted(b.path());
seed_claude(&pb, "uuid-B", "b@y.com");
let orig_creds = std::fs::read(pb.claude_credentials()).unwrap();
let cfg = pb.claude_config_json();
std::fs::remove_file(&cfg).ok();
std::fs::create_dir(&cfg).unwrap();
assert!(Claude.apply(&pb, &snap).is_err(), "config write must fail");
assert_eq!(
std::fs::read(pb.claude_credentials()).unwrap(),
orig_creds,
"credentials must roll back to B - never half-swapped"
);
}
#[test]
fn apply_leaves_no_wal_on_success() {
let a = tempfile::tempdir().unwrap();
let pa = Paths::rooted(a.path());
seed_claude(&pa, "uuid-A", "a@x.com");
let snap = Claude.capture(&pa).unwrap();
let b = tempfile::tempdir().unwrap();
let pb = Paths::rooted(b.path());
seed_claude(&pb, "uuid-B", "b@y.com");
Claude.apply(&pb, &snap).unwrap();
assert!(
!apply_wal_path(&pb).exists(),
"WAL is retired once the apply is consistent"
);
}
#[test]
fn recover_rolls_back_a_crashed_apply_to_prior() {
let b = tempfile::tempdir().unwrap();
let pb = Paths::rooted(b.path());
seed_claude(&pb, "uuid-B", "b@y.com");
let cred_path = pb.claude_credentials();
let cfg_path = pb.claude_config_json();
let prior_cred = std::fs::read(&cred_path).unwrap();
let prior_oauth: Value =
serde_json::from_slice::<Value>(&std::fs::read(&cfg_path).unwrap())
.unwrap()
.get("oauthAccount")
.cloned()
.unwrap();
let wal = ApplyWal {
cred_path: cred_path.to_string_lossy().into_owned(),
cred_prior_hex: Some(to_hex(&prior_cred)),
cfg_path: cfg_path.to_string_lossy().into_owned(),
cfg_oauth_prior: Some(prior_oauth.clone()),
kc_service: None, kc_prior_hex: None,
};
write_apply_wal(&pb, &wal).unwrap();
std::fs::write(&cred_path, br#"{"claudeAiOauth":{"accessToken":"AT-A"}}"#).unwrap();
std::fs::write(
&cfg_path,
serde_json::to_vec(&json!({"oauthAccount": {"accountUuid": "uuid-A"}})).unwrap(),
)
.unwrap();
recover_interrupted_apply(&pb);
assert_eq!(
std::fs::read(&cred_path).unwrap(),
prior_cred,
"credential file rolled back to B"
);
let after: Value = serde_json::from_slice(&std::fs::read(&cfg_path).unwrap()).unwrap();
assert_eq!(
after["oauthAccount"], prior_oauth,
"oauthAccount rolled back to B"
);
assert!(
!apply_wal_path(&pb).exists(),
"WAL removed after a successful recovery"
);
}
#[test]
fn apply_swaps_only_oauthaccount_and_preserves_siblings() {
let a = tempfile::tempdir().unwrap();
let pa = Paths::rooted(a.path());
seed_claude(&pa, "uuid-A", "a@x.com");
let snap = Claude.capture(&pa).unwrap();
let b = tempfile::tempdir().unwrap();
let pb = Paths::rooted(b.path());
seed_claude(&pb, "uuid-B", "b@y.com");
std::fs::write(
pb.claude_config_json(),
serde_json::to_vec(&json!({
"projects": {"/keep/me": {"trust": true}},
"mcpServers": {"sessionwiki": {"command": "sessionwiki"}},
"theme": "light",
"oauthAccount": {"accountUuid": "uuid-B", "emailAddress": "b@y.com"}
}))
.unwrap(),
)
.unwrap();
Claude.apply(&pb, &snap).unwrap();
let after: Value =
serde_json::from_slice(&std::fs::read(pb.claude_config_json()).unwrap()).unwrap();
assert_eq!(after["oauthAccount"]["accountUuid"], "uuid-A");
assert_eq!(after["oauthAccount"]["emailAddress"], "a@x.com");
assert_eq!(after["projects"]["/keep/me"]["trust"], true);
assert_eq!(after["mcpServers"]["sessionwiki"]["command"], "sessionwiki");
assert_eq!(after["theme"], "light");
let creds: Value =
serde_json::from_slice(&std::fs::read(pb.claude_credentials()).unwrap()).unwrap();
assert_eq!(creds["claudeAiOauth"]["subscriptionType"], "max");
let id = Claude.identity(&pb).unwrap().unwrap();
assert_eq!(id.account_id, "uuid-A");
assert_eq!(id.email.as_deref(), Some("a@x.com"));
}
}
#[cfg(test)]
mod capture_reads_slot_keychain_tests {
#[test]
fn the_service_follows_the_directory_it_was_given() {
let a = std::path::Path::new("/tmp/swapdex-kc-a");
let b = std::path::Path::new("/tmp/swapdex-kc-b");
let sa = super::slot_service(a);
let sb = super::slot_service(b);
assert_ne!(sa, sb, "different slots get different items");
assert_eq!(sa, super::slot_service(a), "and it is stable");
assert!(
sa.starts_with("Claude Code-credentials-"),
"it is a per-slot item, not the default one: {sa}"
);
}
}