use crate::provider;
use crate::provider::ProviderId;
mod adopt;
#[cfg(test)]
mod crash;
mod enroll;
#[cfg(test)]
mod foreign;
mod forget;
#[cfg(test)]
pub(crate) mod harness;
mod journal;
#[cfg(test)]
mod refusals;
mod rename;
pub(crate) mod renew;
#[cfg(test)]
mod two_tools;
mod uninstall;
pub use crate::pending::Reclaimed;
pub use adopt::{Adopted, adopt};
pub use enroll::{Enrolled, Said, SignIn, WatchedSignIn, enroll, sign_in, sign_in_watched};
pub use forget::forget;
pub(crate) use journal::interrupted_tool;
pub use journal::{Abandoned, Recovered, pending as interrupted};
pub use rename::rename;
pub use renew::{Due, Renewal, renew_due, renew_parked};
pub use uninstall::{Removed, uninstall};
use crate::context::Context;
use crate::error::{Error, Result};
use crate::service::Warning;
use crate::state::{Account, Key, Park, State};
use crate::{api, fault, holder, home, lock, park, pending, state, store};
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;
#[derive(Debug)]
pub enum Outcome {
Switched {
provider: ProviderId,
from: String,
to: String,
parked: Park,
adoption: provider::Adoption,
},
AlreadyActive { label: String },
}
pub struct Settled {
_exclusive: std::fs::File,
state: State,
ctx: Context,
}
pub fn abandon(ctx: &Context) -> Result<Option<Abandoned>> {
refuse_custom_oauth(ctx, None)?;
let _exclusive = exclusive(ctx)?;
let mut state = state::load(ctx)?;
journal::abandon(ctx, &mut state)
}
fn refuse_custom_oauth(ctx: &Context, tool: Option<ProviderId>) -> Result<()> {
if !crate::settings::custom_oauth(ctx) {
return Ok(());
}
let claude = Some(ProviderId::Claude);
if tool.is_none() || tool == claude || journal::interrupted_tool(ctx) == claude {
return Err(Error::CustomOauthEndpoint);
}
Ok(())
}
pub fn settle(ctx: &Context, tool: Option<ProviderId>) -> Result<(Settled, Option<Recovered>)> {
refuse_custom_oauth(ctx, tool)?;
let exclusive = exclusive(ctx)?;
let mut state = state::load(ctx)?;
let recovered = reconcile(ctx, &mut state)?;
pending::sweep(ctx, &mut state)?;
drop_live_twins(ctx, &mut state)?;
purge(ctx, &mut state);
Ok((
Settled {
_exclusive: exclusive,
state,
ctx: ctx.clone(),
},
recovered,
))
}
pub fn repair(settled: Settled) -> Result<pending::Reclaimed> {
let Settled {
_exclusive,
mut state,
ctx,
} = settled;
let reclaimed = pending::reclaim(&ctx, &mut state)?;
purge(&ctx, &mut state);
Ok(reclaimed)
}
fn drop_live_twins(ctx: &Context, state: &mut State) -> Result<()> {
let twins = park::live_twins(ctx, state);
if twins.is_empty() {
return Ok(());
}
for service in &twins {
state.discard(service);
}
state::save(ctx, state)
}
fn purge(ctx: &Context, state: &mut State) -> usize {
let listed = state.discarded.len();
let remaining = park::purge(ctx, state);
if remaining != listed {
let _ = state::save(ctx, state);
}
remaining
}
fn exclusive(ctx: &Context) -> Result<std::fs::File> {
let (file, path) = lock_file(ctx)?;
file.lock()
.map_err(|source| Error::HomeUnwritable { path, source })?;
Ok(file)
}
fn try_exclusive(ctx: &Context) -> Option<std::fs::File> {
let (file, _) = lock_file(ctx).ok()?;
file.try_lock().ok()?;
Some(file)
}
fn lock_file(ctx: &Context) -> Result<(std::fs::File, PathBuf)> {
let path = home::dir(ctx).join("state.lock");
let fail = |source| Error::HomeUnwritable {
path: path.clone(),
source,
};
home::ensure(ctx).map_err(fail)?;
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.mode(0o600)
.open(&path)
.map_err(fail)?;
Ok((file, path))
}
pub(super) fn identify_document(
ctx: &Context,
which: ProviderId,
document: &Value,
) -> Result<api::Owner> {
let credential = provider::Credential::new(which, document.clone());
provider::of(which)
.identify(ctx, &credential)
.map(|found| api::Owner {
account_uuid: found.account_id,
email: found.email,
organization_uuid: found.group.unwrap_or_default(),
})
.map_err(|e| match e {
provider::ProviderError::Unauthorized => Error::SessionExpired { tool: which },
other @ (provider::ProviderError::ShapeUnexpected { .. }
| provider::ProviderError::Unsupported { .. }) => shape(which, other),
other => Error::IdentityUnverifiable {
tool: which,
cause: crate::error::Cause::of_provider(&other),
detail: other.to_string(),
},
})
}
pub(super) fn nothing_signed_in(ctx: &Context, which: ProviderId) -> Error {
match provider::of(which).recorded_identity(ctx) {
Some(found) => Error::LiveCredentialElsewhere { email: found.email },
None => Error::LiveCredentialAbsent { tool: which },
}
}
pub(super) fn live_store(ctx: &Context, which: ProviderId) -> Result<provider::LiveStore> {
provider::of(which).live(ctx).map_err(|e| shape(which, e))
}
fn read_live(
ctx: &Context,
which: ProviderId,
live: &provider::LiveStore,
) -> Result<(String, Value)> {
let raw = store::read_raw(&live.chain, &live.service)?
.ok_or_else(|| nothing_signed_in(ctx, which))?;
let document = serde_json::from_str(&raw)
.map_err(|e| Error::Store(store::Error::Malformed(e.to_string())))?;
match provider::of(which).slice(&document) {
Err(provider::ProviderError::NoLogin { .. }) => Err(nothing_signed_in(ctx, which)),
Err(other) => Err(shape(which, other)),
Ok(_) => Ok((raw, document)),
}
}
pub fn switch(settled: Settled, key: &Key) -> Result<(Outcome, Vec<Warning>)> {
let Settled {
_exclusive,
mut state,
ctx,
} = settled;
let ctx = &ctx;
let label = &key.label;
let tool = provider::of(key.provider);
let target = state
.get(key)
.cloned()
.ok_or_else(|| Error::AccountUnknown {
label: key.typed(),
enrolled: state.labels(key.provider),
})?;
let live = live_store(ctx, key.provider)?;
let (_, first) = read_live(ctx, key.provider, &live)?;
let outgoing = identify_document(ctx, key.provider, &first)?;
if outgoing.account_uuid == target.account_uuid {
if state.active_for(key.provider) != Some(label.as_str()) {
state.set_active(key.provider, Some(label.to_string()));
state.used(key, ctx.now());
state::save(ctx, &state)?;
}
return Ok((
Outcome::AlreadyActive {
label: state.typed(key),
},
Vec::new(),
));
}
let outgoing_key = state
.by_uuid(key.provider, &outgoing.account_uuid)
.map(Account::key)
.ok_or_else(|| Error::LiveAccountNotEnrolled {
tool: key.provider,
email: outgoing.email.clone(),
})?;
let (from, to) = (state.typed(&outgoing_key), state.typed(key));
let held = target.parked.clone().ok_or_else(|| Error::NothingParked {
tool: key.provider,
label: to.clone(),
})?;
if !held.restorable_at(ctx.now()) {
return Err(Error::ParkedLoginExpired { label: to.clone() });
}
let incoming = park::load(ctx, key, &held)?;
let (held, incoming) = prove_incoming(ctx, &mut state, key, &target, held, incoming)?;
let Readied {
guard,
before_raw,
before,
next,
on_the_command_line,
} = ready(
ctx,
key.provider,
&live,
&first,
&outgoing.account_uuid,
&incoming,
&to,
)?;
let parking = park::price(
ctx,
key.provider,
&from,
&park::service_name(&outgoing.account_uuid, ctx.now_millis()),
&tool.slice(&before).map_err(|e| shape(key.provider, e))?,
)?;
let park_service = park::reserve(ctx, &outgoing.account_uuid)?;
write_journal(
ctx,
&Journal {
provider: key.provider,
started_at: ctx.now(),
from_label: outgoing_key.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(),
from_fingerprint: tool.fingerprint(&before),
to_fingerprint: held.refresh_fingerprint.clone(),
slot: Some(tool.slot(ctx)),
},
)?;
fault::point("switch.journal_written");
let slice = tool.slice(&before).map_err(|e| shape(key.provider, e))?;
let parked = match park::store_at(ctx, key.provider, &park_service, &slice) {
Ok(parked) => parked,
Err(e) => {
clear_journal(ctx);
return Err(e);
}
};
fault::point("switch.park_stored");
state.park(&outgoing_key, parked.clone());
if let Err(e) = state::save(ctx, &state) {
let _ = store::vault_delete(ctx, &parked.service);
clear_journal(ctx);
return Err(e);
}
fault::point("switch.park_recorded");
if tool.park_semantics() == provider::ParkSemantics::MoveOnly
&& store::vault_read(ctx, &parked.service)?.is_none()
{
state.release(&parked.service);
state::save(ctx, &state)?;
clear_journal(ctx);
return Err(Error::ParkedCredentialMissing { label: from });
}
let now = store::read_raw(&live.chain, &live.service);
if !matches!(&now, Ok(Some(now)) if *now == before_raw) {
let still_outgoing = now
.ok()
.flatten()
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.and_then(|document| identify_document(ctx, key.provider, &document).ok())
.is_some_and(|found| found.account_uuid == outgoing.account_uuid);
if still_outgoing {
state.discard(&parked.service);
}
state::save(ctx, &state)?;
clear_journal(ctx);
purge(ctx, &mut state);
return Err(Error::SignedInAccountChanged);
}
if let Err(e) = install_with(
key.provider,
|body| store::write_raw(&live.chain, &live.service, body),
|| store::read_raw(&live.chain, &live.service),
&next,
&before_raw,
&from,
&to,
) {
if matches!(e, Error::SwitchUnverified { .. }) {
return Err(e);
}
if !only_copy_left(&e) {
state.discard(&parked.service);
}
state::save(ctx, &state)?;
clear_journal(ctx);
purge(ctx, &mut state);
return Err(e);
}
fault::point("switch.installed");
let lock_lost = guard.as_ref().is_some_and(lock::Guard::compromised);
match holds(key.provider, &live) {
Ok(true) => {}
Ok(false) => {
clear_journal(ctx);
return Err(Error::SwitchDidNotHold {
tool: key.provider,
from,
to,
});
}
Err(unreadable) => {
return Err(Error::SwitchUnverified {
tool: key.provider,
from,
to,
detail: unreadable.to_string(),
});
}
}
state.discard(&held.service);
state.set_active(key.provider, Some(label.to_string()));
state.used(key, ctx.now());
state::save(ctx, &state)?;
fault::point("switch.recorded");
drop(guard);
let outgoing_identity = provider::Identity {
account_id: outgoing.account_uuid.clone(),
email: outgoing.email.clone(),
group: Some(outgoing.organization_uuid.clone()).filter(|g| !g.is_empty()),
};
let cache_warning = tool
.after_switch(ctx, &target, &outgoing_identity)
.err()
.map(Warning::ConfigNotUpdated);
fault::point("switch.config_updated");
let parks_pending = purge(ctx, &mut state);
clear_journal(ctx);
let still_running =
still_holding(ctx, key.provider).map(|holding| Warning::SessionsStillRunning {
from: from.clone(),
holding,
});
let warnings = on_the_command_line
.into_iter()
.chain(parking)
.chain(still_running)
.chain(lock_lost.then_some(Warning::LockCompromised { tool: key.provider }))
.chain(cache_warning)
.chain((parks_pending > 0).then_some(Warning::ParksPendingRemoval(parks_pending)))
.collect();
Ok((
Outcome::Switched {
provider: key.provider,
adoption: tool.adoption(),
from,
to,
parked,
},
warnings,
))
}
struct Readied {
guard: Option<lock::Guard>,
before_raw: String,
before: Value,
next: String,
on_the_command_line: Option<Warning>,
}
fn ready(
ctx: &Context,
which: ProviderId,
live: &provider::LiveStore,
first: &Value,
signed_in: &str,
incoming: &Value,
label: &str,
) -> Result<Readied> {
let tool = provider::of(which);
let guard = tool
.write_lock(ctx)
.map(|dir| lock::acquire(&dir))
.transpose()?;
let (before_raw, before) = read_live(ctx, which, live)?;
if tool.slice(&before).ok() != tool.slice(first).ok()
&& identify_document(ctx, which, &before)?.account_uuid != signed_in
{
return Err(Error::SignedInAccountChanged);
}
let next = to_body(
tool.splice(&before, incoming)
.map_err(|e| shape(which, e))?,
);
let price = store::cost(&live.chain, &live.service, &next);
if price.is_some_and(store::Cost::refused) {
let price = price.expect("refused implies a ceiling");
return Err(Error::CredentialTooLarge {
tool: which,
label: label.to_string(),
bytes: price.needs,
limit: price.limit,
});
}
let on_the_command_line =
price
.filter(|p| p.on_the_second_route())
.map(|p| Warning::WrittenOnTheCommandLine {
tool: which,
bytes: p.needs,
limit: p.limit,
});
Ok(Readied {
guard,
before_raw,
before,
next,
on_the_command_line,
})
}
fn holds(which: ProviderId, live: &provider::LiveStore) -> std::result::Result<bool, store::Error> {
let tool = provider::of(which);
store::read_raw(&live.chain, &live.service).map(|now| {
now.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.is_some_and(|document| tool.slice(&document).is_ok())
})
}
pub(crate) fn still_holding(ctx: &Context, which: ProviderId) -> Option<Vec<holder::Holding>> {
match provider::of(which).adoption() {
provider::Adoption::RestartRequired { program, holders } => {
holder::find(ctx, program, holders).filter(|holding| !holding.is_empty())
}
provider::Adoption::PollingWithin(_) => None,
}
}
fn prove_incoming(
ctx: &Context,
state: &mut State,
key: &Key,
target: &Account,
held: Park,
incoming: Value,
) -> Result<(Park, Value)> {
let label = state.typed(key);
if held.askable_at(ctx.now()) {
let credential = provider::Credential::new(key.provider, incoming.clone());
match provider::of(key.provider).verify(ctx, &credential) {
Ok(found) if found.account_id == target.account_uuid => return Ok((held, incoming)),
Ok(other) => {
return Err(Error::ParkedLoginBelongsElsewhere {
label,
email: other.email,
});
}
Err(provider::ProviderError::Unauthorized) => {}
Err(provider::ProviderError::ShapeUnexpected { detail, .. }) => {
return Err(Error::ParkedCredentialCorrupt { label, detail });
}
Err(e) => {
return Err(Error::IdentityUnverifiable {
tool: key.provider,
cause: crate::error::Cause::of_provider(&e),
detail: e.to_string(),
});
}
}
}
let Some(fresh) = renew::renew_one(ctx, state, key, &held)? else {
return Err(Error::IdentityUnverifiable {
tool: key.provider,
cause: crate::error::Cause::Unreachable,
detail: format!(
"`{label}`'s parked login needs renewing and {} did not answer",
key.provider.service()
),
});
};
let document = park::load(ctx, key, &fresh)?;
Ok((fresh, document))
}
fn only_copy_left(failure: &Error) -> bool {
matches!(failure, Error::SwitchCorrupted { .. })
}
pub(super) fn shape(tool: ProviderId, error: provider::ProviderError) -> Error {
match error {
provider::ProviderError::ShapeUnexpected { detail, .. } => {
Error::LiveCredentialShapeUnexpected { tool, detail }
}
provider::ProviderError::Unsupported { reason, .. } => {
Error::LiveStoreUnsupported { tool, reason }
}
other => Error::LiveCredentialShapeUnexpected {
tool,
detail: other.to_string(),
},
}
}
fn to_body(document: Value) -> String {
serde_json::to_string(&document).expect("a credential document stays serialisable")
}
fn install_with(
tool: ProviderId,
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,
};
match read() {
Ok(Some(now)) if now == before_raw => return Err(rolled_back(failure.to_string())),
Err(unreadable) => {
return Err(Error::SwitchUnverified {
tool,
from: from.to_string(),
to: to.to_string(),
detail: format!("{failure}; {unreadable}"),
});
}
_ => {}
}
match write(before_raw) {
Ok(()) => Err(rolled_back(failure.to_string())),
Err(rollback) => Err(Error::SwitchCorrupted {
tool,
from: from.to_string(),
to: to.to_string(),
detail: format!("{failure}; {rollback}"),
}),
}
}
#[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(
ProviderId::Claude,
|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(
ProviderId::Claude,
|_| 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(
ProviderId::Claude,
|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 a_store_that_cannot_be_read_back_is_not_a_lost_login() {
let writes = RefCell::new(0);
let result = install_with(
ProviderId::Claude,
|_| {
*writes.borrow_mut() += 1;
Err(failing("the keychain is locked"))
},
|| Err(store::Error::Unreadable("the keychain is locked".into())),
"new",
"old",
"a",
"b",
);
assert!(
matches!(result, Err(Error::SwitchUnverified { .. })),
"not knowing is its own answer, and must not read as a lost login"
);
assert_eq!(
*writes.borrow(),
1,
"and nothing further is written into a store that cannot be read"
);
}
#[test]
fn only_a_failed_rollback_after_a_change_is_reported_as_corruption() {
let result = install_with(
ProviderId::Claude,
|_| 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 {
tool: ProviderId::Claude,
from,
to,
detail
}));
}
}