mod enroll;
mod forget;
mod journal;
mod rename;
pub use enroll::{Enrolled, enroll};
pub use forget::forget;
pub use journal::{Recovered, pending as interrupted};
pub use rename::rename;
use crate::error::{Error, Result};
use crate::state::{Account, Park, State};
use crate::{api, claude, configfile, home, lock, park, state, store, time};
use journal::{Journal, clear_journal, reconcile, write_journal};
use serde_json::Value;
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
pub const ADOPTION_CEILING_SECONDS: u32 = 33;
pub enum Outcome {
Switched {
from: String,
to: String,
parked: Park,
config_warning: Option<Error>,
parks_pending: usize,
},
AlreadyActive { label: String },
}
pub(super) fn oauth_of(document: &Value) -> Result<Value> {
document
.get("claudeAiOauth")
.cloned()
.ok_or_else(|| Error::LiveCredentialShapeUnexpected {
detail: "it has no claudeAiOauth block".into(),
})
}
pub struct Settled {
_exclusive: std::fs::File,
state: State,
}
impl Settled {
pub fn account(&self, label: &str) -> Option<&Account> {
self.state.get(label)
}
}
pub fn settle() -> Result<(Settled, Option<Recovered>)> {
let exclusive = exclusive()?;
let mut state = state::load()?;
let recovered = reconcile(&mut state)?;
purge(&mut state);
Ok((
Settled {
_exclusive: exclusive,
state,
},
recovered,
))
}
fn purge(state: &mut State) -> usize {
let listed = state.discarded.len();
let remaining = park::purge(state);
if remaining != listed {
let _ = state::save(state);
}
remaining
}
fn exclusive() -> Result<std::fs::File> {
let path = home::dir().join("state.lock");
let fail = |source| Error::HomeUnwritable {
path: path.clone(),
source,
};
home::ensure().map_err(fail)?;
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.mode(0o600)
.open(&path)
.map_err(fail)?;
file.lock().map_err(fail)?;
Ok(file)
}
pub(super) fn identify(access_token: &str) -> Result<api::Owner> {
api::owner(access_token).map_err(|e| match e {
api::ApiError::Unauthorized => Error::SessionExpired,
other => Error::IdentityUnverifiable {
detail: other.to_string(),
},
})
}
pub(super) fn access_token(document: &Value) -> Result<String> {
document["claudeAiOauth"]["accessToken"]
.as_str()
.map(str::to_owned)
.ok_or_else(|| Error::LiveCredentialShapeUnexpected {
detail: "it has no access token".into(),
})
}
pub fn switch(settled: Settled, label: &str) -> Result<Outcome> {
let Settled {
_exclusive,
mut state,
} = settled;
let target = state
.get(label)
.cloned()
.ok_or_else(|| Error::AccountUnknown {
label: label.to_string(),
})?;
let service = claude::live_service();
let live = store::read(&service)?.ok_or(Error::LiveCredentialAbsent)?;
let identified_with = access_token(&live)?;
let outgoing = identify(&identified_with)?;
if outgoing.account_uuid == target.account_uuid {
if state.active.as_deref() != Some(label) {
state.active = Some(label.to_string());
state::save(&state)?;
}
return Ok(Outcome::AlreadyActive {
label: label.to_string(),
});
}
let outgoing_label = state
.by_uuid(&outgoing.account_uuid)
.map(|a| a.label.clone())
.ok_or_else(|| Error::LiveAccountNotEnrolled {
email: outgoing.email.clone(),
})?;
let held = target.parked.clone().ok_or_else(|| Error::NothingParked {
label: label.to_string(),
})?;
if !held.restorable_at(time::now()) {
return Err(Error::ParkedLoginExpired {
label: label.to_string(),
});
}
let incoming = park::load(label, &held)?;
let storage = PathBuf::from(claude::storage_dir()).join(".storage-write");
let guard = lock::acquire(&storage)?;
let before_raw = store::read_raw(&service)?.ok_or(Error::LiveCredentialAbsent)?;
let before: Value =
serde_json::from_str(&before_raw).map_err(|e| Error::LiveCredentialShapeUnexpected {
detail: e.to_string(),
})?;
let now_token = access_token(&before)?;
if now_token != identified_with && identify(&now_token)?.account_uuid != outgoing.account_uuid {
return Err(Error::SignedInAccountChanged);
}
let next = splice(&before, &incoming)?;
if store::too_large(&service, &next) {
return Err(Error::LiveCredentialShapeUnexpected {
detail: "the login to install is past the keychain's size limit".into(),
});
}
let park_service = park::reserve(&outgoing.account_uuid)?;
write_journal(&Journal {
started_at: time::now(),
from_label: outgoing_label.clone(),
from_uuid: outgoing.account_uuid.clone(),
to_label: label.to_string(),
to_uuid: target.account_uuid.clone(),
park_service: park_service.clone(),
incoming_service: held.service.clone(),
})?;
let parked = park::store_at(&park_service, &oauth_of(&before)?)?;
state.park(&outgoing_label, parked.clone());
state::save(&state)?;
if let Err(e) = install(&service, &next, &before_raw, &outgoing_label, label) {
if !only_copy_left(&e) {
state.discard(&parked.service);
}
state::save(&state)?;
clear_journal();
purge(&mut state);
return Err(e);
}
state.discard(&held.service);
state.active = Some(label.to_string());
state::save(&state)?;
drop(guard);
let config_warning =
update_config(&target, &outgoing.account_uuid, &outgoing.organization_uuid).err();
let parks_pending = purge(&mut state);
clear_journal();
Ok(Outcome::Switched {
from: outgoing_label,
to: label.to_string(),
parked,
config_warning,
parks_pending,
})
}
fn only_copy_left(failure: &Error) -> bool {
matches!(failure, Error::SwitchCorrupted { .. })
}
fn splice(before: &Value, incoming: &Value) -> Result<String> {
let mut next = before.clone();
next.as_object_mut()
.ok_or_else(|| Error::LiveCredentialShapeUnexpected {
detail: "it is not a JSON object".into(),
})?
.insert("claudeAiOauth".into(), incoming.clone());
Ok(serde_json::to_string(&next).expect("a credential document stays serialisable"))
}
fn install(service: &str, next: &str, before_raw: &str, from: &str, to: &str) -> Result<()> {
install_with(
|body| store::write_raw(service, body),
|| store::read_raw(service),
next,
before_raw,
from,
to,
)
}
fn install_with(
write: impl Fn(&str) -> std::result::Result<(), store::Error>,
read: impl Fn() -> std::result::Result<Option<String>, store::Error>,
next: &str,
before_raw: &str,
from: &str,
to: &str,
) -> Result<()> {
let Err(failure) = write(next) else {
return Ok(());
};
let rolled_back = |detail: String| Error::SwitchRolledBack {
from: from.to_string(),
to: to.to_string(),
detail,
};
if matches!(read(), Ok(Some(now)) if now == before_raw) {
return Err(rolled_back(failure.to_string()));
}
match write(before_raw) {
Ok(()) => Err(rolled_back(failure.to_string())),
Err(rollback) => Err(Error::SwitchCorrupted {
from: from.to_string(),
to: to.to_string(),
detail: format!("{failure}; {rollback}"),
}),
}
}
fn update_config(target: &Account, outgoing_account: &str, outgoing_org: &str) -> Result<()> {
let path = configfile::path();
configfile::backup(&path)?;
let mut config = claude::load_config()?;
configfile::splice_identity(
&mut config,
&target.oauth_account,
&[outgoing_account, outgoing_org],
);
configfile::write(&path, &config)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn failing(message: &str) -> store::Error {
store::Error::Write(message.into())
}
#[test]
fn a_successful_write_needs_no_rollback() {
let written = RefCell::new(Vec::new());
let result = install_with(
|b| {
written.borrow_mut().push(b.to_string());
Ok(())
},
|| unreachable!(),
"new",
"old",
"a",
"b",
);
assert!(result.is_ok());
assert_eq!(*written.borrow(), vec!["new"]);
}
#[test]
fn a_failed_write_that_changed_nothing_is_not_reported_as_a_lost_login() {
let result = install_with(
|_| Err(failing("keychain locked")),
|| Ok(Some("old".into())),
"new",
"old",
"a",
"b",
);
assert!(
matches!(result, Err(Error::SwitchRolledBack { .. })),
"the old login never left, so the user must not be told to sign in again"
);
}
#[test]
fn a_half_write_is_rolled_back() {
let slot = RefCell::new("old".to_string());
let result = install_with(
|b| {
if b == "new" {
*slot.borrow_mut() = "garbled".into();
Err(failing("interrupted"))
} else {
*slot.borrow_mut() = b.to_string();
Ok(())
}
},
|| Ok(Some(slot.borrow().clone())),
"new",
"old",
"a",
"b",
);
assert!(matches!(result, Err(Error::SwitchRolledBack { .. })));
assert_eq!(
*slot.borrow(),
"old",
"the previous login must be back in place"
);
}
#[test]
fn only_a_failed_rollback_after_a_change_is_reported_as_corruption() {
let result = install_with(
|_| Err(failing("disk full")),
|| Ok(Some("garbled".into())),
"new",
"old",
"a",
"b",
);
assert!(matches!(result, Err(Error::SwitchCorrupted { .. })));
}
#[test]
fn a_copy_is_kept_after_a_failed_install_only_when_it_is_all_that_is_left() {
let (from, to, detail) = ("a".to_string(), "b".to_string(), String::new());
assert!(!only_copy_left(&Error::SwitchRolledBack {
from: from.clone(),
to: to.clone(),
detail: detail.clone(),
}));
assert!(only_copy_left(&Error::SwitchCorrupted { from, to, detail }));
}
#[test]
fn a_credential_without_claude_ai_oauth_is_refused() {
assert!(oauth_of(&serde_json::json!({"slackTag": {}})).is_err());
assert!(oauth_of(&serde_json::json!({"claudeAiOauth": {"accessToken": "a"}})).is_ok());
}
}