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;
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_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()
}
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
}
pub(crate) enum KeychainReadError {
NotApplicable,
Locked,
Missing,
}
pub fn slot_keychain_write(dir: &std::path::Path, value: &[u8]) -> Result<()> {
if !keychain_enabled() {
anyhow::bail!("no Keychain in this environment");
}
keychain_write_service(&slot_service(dir), value)
}
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);
}
let service = slot_service(dir);
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>>> {
let Some(service) = keychain_service() else {
return Ok(None);
};
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(())
}
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 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() {
let by_dir = slot_keychain_read_detail(paths.claude_dir()).ok();
return by_dir.or_else(keychain_read).or_else(|| {
let f = paths.claude_credentials();
f.exists()
.then(|| crate::atomic::read_regular(&f).ok())
.flatten()
});
}
let f = paths.claude_credentials();
if f.exists() {
crate::atomic::read_regular(&f).ok()
} else {
keychain_read()
}
}
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 capture(&self, paths: &Paths) -> Result<Snapshot> {
recover_interrupted_apply(paths);
let Some(cred_bytes) = cred_read(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(
"your LIVE ~/.claude.json is corrupt (not the profile snapshot) - \
repair or remove that file, 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(
"your LIVE ~/.claude.json is corrupt (not the profile snapshot) - \
repair or remove that file, 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();
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) = cred_read(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 = creds["claudeAiOauth"]["subscriptionType"]
.as_str()
.map(|s| s.to_string());
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 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 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 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}"
);
}
}