use crate::provider;
use crate::provider::{LoginKind, Provider, ProviderId};
mod adopt;
mod auto;
#[cfg(test)]
mod crash;
pub(crate) mod enroll;
pub(crate) mod folder;
#[cfg(test)]
mod foreign;
mod forget;
#[cfg(test)]
pub(crate) mod harness;
pub(crate) mod identify;
mod journal;
#[cfg(test)]
mod pinned;
mod refresh;
#[cfg(test)]
mod refusals;
mod rename;
pub(crate) mod renew;
mod stow;
#[cfg(test)]
mod two_tools;
mod uninstall;
pub mod warm;
pub use crate::pending::Reclaimed;
pub use adopt::Adopted;
pub(crate) use adopt::adopt;
pub(crate) use auto::automatically;
pub(crate) use enroll::enroll;
#[cfg(any(test, feature = "test-support"))]
pub(crate) use enroll::planted;
pub use enroll::{Enrolled, Said, SignIn, Steps, WatchedSignIn};
#[cfg(any(test, feature = "test-support"))]
pub use enroll::{ScriptedSignIn, SignInScript};
pub(crate) use enroll::{one_at_a_time, sign_in, sign_in_watched_as};
pub use folder::awaited::WaitStep;
pub use folder::{Ended, GivenUp, MoveSettled};
pub(crate) use forget::forget;
pub use journal::{Abandoned, Recovered};
pub(crate) use journal::{Asking, interrupted_tool, pending as interrupted};
pub(crate) use rename::rename;
pub use renew::Renewal;
pub(crate) use renew::{Due, renew_due, renew_parked};
pub(crate) use stow::find as left_login;
pub(crate) use stow::stow;
pub use stow::{Foreseen, Kept, Left, Stowed};
pub use uninstall::Removed;
pub(crate) use uninstall::uninstall;
use crate::context::Context;
use crate::error::{Error, Result};
use crate::in_use::{self, InUse};
use crate::service::{Permit, Warning};
use crate::state::{Account, Key, Park, ParkKind, State};
use crate::{fault, holder, home, lock, park, pending, state, store};
use journal::{Journal, clear_journal, reconcile, write_journal};
use refresh::Refreshing;
use serde_json::Value;
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,
},
Installed {
provider: ProviderId,
to: String,
adoption: provider::Adoption,
},
AlreadyActive {
label: String,
config_updated: bool,
},
}
#[derive(Debug)]
pub(crate) struct Settled {
_exclusive: std::fs::File,
state: State,
ctx: Context,
permit: Permit,
}
#[derive(Debug, Default)]
pub struct GaveUp {
pub switch: Option<Abandoned>,
pub moves: Vec<GivenUp>,
pub warnings: Vec<Warning>,
}
pub fn abandon(ctx: &Context, permit: Permit) -> Result<GaveUp> {
if let Some(tool) = interrupted_tool(ctx) {
refuse_custom_oauth(ctx, Some(tool))?;
}
let _exclusive = exclusive(ctx, permit)?;
let mut state = state::load(ctx)?;
let mut gave_up = GaveUp {
switch: journal::abandon(ctx, permit, &mut state)?,
..GaveUp::default()
};
for &tool in ProviderId::ALL {
let LoginKind::Folder(app) = provider::of(tool) else {
continue;
};
let Some(label) = state
.moving(tool)
.map(|moving| state.typed(&Key::new(tool, moving.label.clone())))
else {
continue;
};
let done =
gave_up.switch.is_some() || !gave_up.moves.is_empty() || !gave_up.warnings.is_empty();
match folder::abandon(ctx, permit, &mut state, app) {
Ok((given_up, said)) => {
gave_up.moves.extend(given_up);
gave_up.warnings.extend(said);
}
Err(why) if done => {
gave_up
.warnings
.push(Warning::MoveNotGivenUp { tool, label, why });
state = state::load(ctx)?;
}
Err(refused) => return Err(refused),
}
}
Ok(gave_up)
}
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 || interrupted_tool(ctx) == claude {
return Err(Error::CustomOauthEndpoint);
}
Ok(())
}
pub(crate) fn settle(
ctx: &Context,
permit: Permit,
tool: Option<ProviderId>,
) -> Result<(Settled, Option<Recovered>)> {
refuse_custom_oauth(ctx, tool)?;
let exclusive = exclusive(ctx, permit)?;
let mut state = state::load(ctx)?;
let recovered = reconcile(ctx, permit, &mut state)?;
let _ = refresh::finish(ctx, permit, &mut state);
pending::sweep(ctx, permit, &mut state)?;
drop_live_twins(ctx, permit, &mut state)?;
purge(ctx, permit, &mut state);
Ok((
Settled {
_exclusive: exclusive,
state,
ctx: ctx.clone(),
permit,
},
recovered,
))
}
pub(crate) fn stuck(ctx: &Context, state: &State, asking: Asking) -> Option<Error> {
if !journal::pending(ctx) {
return None;
}
refuse_custom_oauth(ctx, interrupted_tool(ctx)).ok()?;
journal::refusal(ctx, state, asking)
}
pub(crate) fn repair(settled: Settled) -> Result<(Reclaimed, Vec<Warning>)> {
let Settled {
_exclusive,
mut state,
ctx,
permit,
} = settled;
let mut warnings = Vec::new();
for &tool in ProviderId::ALL {
match provider::of(tool) {
LoginKind::Document(_) => {}
LoginKind::Folder(app) => {
warnings.extend(folder::settle_for_repair(&ctx, permit, &mut state, app));
}
}
}
let mut reclaimed = pending::reclaim(&ctx, permit, &mut state)?;
give_back_asides(&ctx, permit, &mut state, &mut reclaimed)?;
purge(&ctx, permit, &mut state);
Ok((reclaimed, warnings))
}
fn give_back_asides(
ctx: &Context,
permit: Permit,
state: &mut State,
reclaimed: &mut Reclaimed,
) -> Result<()> {
let folders = park::parks(ParkKind::Folder);
let names: Vec<String> = state.aside.iter().map(|aside| aside.name.clone()).collect();
let mut given = false;
for name in names {
let park::Orphan::Login { id, park, .. } = folders.orphan(ctx, state, &name) else {
continue;
};
let Some(key) = pending::takes(ctx, state, &id) else {
continue;
};
state.drop_aside(&name);
pending::give_back(ctx, permit, state, &key, *park, reclaimed);
given = true;
}
if given {
state::save(ctx, permit, state)?;
}
Ok(())
}
fn drop_live_twins(ctx: &Context, permit: Permit, state: &mut State) -> Result<()> {
let twins = park::live_twins(ctx, state);
if twins.is_empty() {
return Ok(());
}
for service in &twins {
state.discard(service, ParkKind::Vault);
}
state::save(ctx, permit, state)
}
fn purge(ctx: &Context, permit: Permit, state: &mut State) -> usize {
let listed = state.discarded.len();
let remaining = park::purge(ctx, permit, state);
if remaining != listed {
let _ = state::save(ctx, permit, state);
}
remaining
}
fn exclusive(ctx: &Context, permit: Permit) -> Result<std::fs::File> {
let (file, path) = lock_file(ctx, permit)?;
file.lock()
.map_err(|source| Error::HomeUnwritable { path, source })?;
Ok(file)
}
fn try_exclusive(ctx: &Context, permit: Permit) -> Option<std::fs::File> {
let (file, _) = lock_file(ctx, permit).ok()?;
file.try_lock().ok()?;
Some(file)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Unfinished {
UnderWay,
Interrupted,
}
pub(crate) fn unfinished(ctx: &Context) -> Option<Unfinished> {
if !journal::pending(ctx) {
return None;
}
let Ok(lock) = std::fs::File::open(home::dir(ctx).join("state.lock")) else {
return Some(Unfinished::Interrupted);
};
match lock.try_lock_shared() {
Err(std::fs::TryLockError::WouldBlock) => Some(Unfinished::UnderWay),
_ => journal::pending(ctx).then_some(Unfinished::Interrupted),
}
}
fn lock_file(ctx: &Context, permit: Permit) -> 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, permit).map_err(fail)?;
let file = crate::host::fs::open_private_lock(permit, &path).map_err(fail)?;
Ok((file, path))
}
pub(super) fn identify_document(
ctx: &Context,
tool: &'static dyn Provider,
document: &Value,
) -> Result<provider::Owner> {
let credential = provider::Credential::new(tool.id(), document.clone());
tool.identify(ctx, &credential)
.map_err(|e| unidentified(tool.id(), e))
}
fn unidentified(which: ProviderId, error: provider::ProviderError) -> Error {
match error {
provider::ProviderError::Unauthorized => Error::SessionExpired {
tool: which,
refused: false,
},
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, tool: &'static dyn Provider) -> Error {
match tool.own_record(ctx) {
Some(found) => Error::LiveCredentialElsewhere { email: found.email },
None => Error::LiveCredentialAbsent {
tool: tool.id(),
label: None,
},
}
}
pub(super) fn live_store(
ctx: &Context,
tool: &'static dyn Provider,
) -> Result<provider::LiveStore> {
tool.live(ctx).map_err(|e| shape(tool.id(), e))
}
fn read_live(
ctx: &Context,
tool: &'static dyn Provider,
live: &provider::LiveStore,
) -> Result<(String, Value)> {
read_stored(tool, live)?.ok_or_else(|| nothing_signed_in(ctx, tool))
}
fn read_stored(
tool: &'static dyn Provider,
live: &provider::LiveStore,
) -> Result<Option<(String, Value)>> {
let Some(raw) = store::read_raw(&live.chain, &live.service)? else {
return Ok(None);
};
let document = serde_json::from_str(&raw)
.map_err(|e| Error::Store(store::Error::Malformed(e.to_string())))?;
match tool.slice(&document) {
Err(provider::ProviderError::NoLogin { .. }) => Ok(None),
Err(other) => Err(shape(tool.id(), other)),
Ok(_) => Ok(Some((raw, document))),
}
}
pub(crate) fn switch(settled: Settled, key: &Key) -> Result<(Outcome, Vec<Warning>)> {
let tool = match provider::of(key.provider) {
LoginKind::Document(tool) => tool,
LoginKind::Folder(app) => return folder::switch(settled, key, app),
};
let Settled {
_exclusive,
mut state,
ctx,
permit,
} = settled;
let target = enrolled(&state, key)?;
let identify::Live::Login(outgoing) = identify::now(&ctx, permit, &mut state, tool)? else {
return Err(Error::LiveCredentialAbsent {
tool: key.provider,
label: None,
});
};
if target.owned_by(&outgoing.owner) {
let lost = outgoing.lock_warning(key.provider);
let (outcome, warnings) = already_active(&ctx, permit, &mut state, tool, key, &target);
return Ok((outcome, lost.into_iter().chain(warnings).collect()));
}
let (switched, warnings) = switch_from(state, &ctx, permit, tool, key, outgoing)?;
Ok((switched.into(), warnings))
}
pub(crate) fn update_config(settled: Settled, key: &Key) -> Result<(Outcome, Vec<Warning>)> {
let tool = match provider::of(key.provider) {
LoginKind::Document(tool) => tool,
LoginKind::Folder(app) => return folder::confirm(settled, key, app),
};
let Settled {
_exclusive,
mut state,
ctx,
permit,
} = settled;
let target = enrolled(&state, key)?;
match identify::now(&ctx, permit, &mut state, tool)? {
identify::Live::Login(stored) if target.owned_by(&stored.owner) => {
let lost = stored.lock_warning(key.provider);
let (outcome, warnings) = already_active(&ctx, permit, &mut state, tool, key, &target);
Ok((outcome, lost.into_iter().chain(warnings).collect()))
}
_ => Err(Error::AccountNotInUse {
tool: key.provider,
label: state.typed(key),
}),
}
}
fn enrolled(state: &State, key: &Key) -> Result<Account> {
state
.get(key)
.cloned()
.ok_or_else(|| Error::AccountUnknown {
label: key.typed(),
enrolled: state.labels(key.provider),
})
}
struct Switched {
provider: ProviderId,
from: String,
to: String,
parked: Park,
adoption: provider::Adoption,
}
impl From<Switched> for Outcome {
fn from(switched: Switched) -> Outcome {
let Switched {
provider,
from,
to,
parked,
adoption,
} = switched;
Outcome::Switched {
provider,
from,
to,
parked,
adoption,
}
}
}
fn switch_from(
mut state: State,
ctx: &Context,
permit: Permit,
tool: &'static dyn Provider,
key: &Key,
outgoing: identify::Login,
) -> Result<(Switched, Vec<Warning>)> {
let label = &key.label;
let target = enrolled(&state, key)?;
let identify::Login {
store: live,
document: first,
owner: outgoing,
lock_lost: renewal_lock_lost,
} = outgoing;
let (outgoing_key, outgoing_id) = state
.account_of(key.provider, &outgoing)
.map(|account| (account.key(), account.id.clone()))
.ok_or_else(|| Error::LiveAccountNotEnrolled {
tool: key.provider,
who: crate::words::login(
key.provider,
&outgoing.email,
state
.accounts
.iter()
.any(|a| a.provider() == key.provider && a.email == outgoing.email),
),
})?;
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 });
}
let incoming = park::load(ctx, tool, key, &held)?;
let (held, incoming) =
prove_incoming(ctx, permit, &mut state, tool, key, &target, held, incoming)?;
let refreshing = Refreshing::take(ctx, permit, tool)?;
let guard = write_lock(ctx, permit, tool)?;
let Readied {
before_raw,
before,
next,
on_the_command_line,
} = ready(ctx, tool, &live, &first, &outgoing, &incoming, &to)?;
let parking = park::price(
ctx,
key.provider,
&from,
&park::service_name(&outgoing_id, ctx.now_millis()),
&tool.slice(&before).map_err(|e| shape(key.provider, e))?,
)?;
let park_service = park::reserve(ctx, permit, &outgoing_id)?;
write_journal(
ctx,
permit,
&Journal {
provider: key.provider,
started_at: ctx.now(),
from_label: outgoing_key.label.clone(),
from_id: outgoing_id,
to_label: label.to_string(),
to_id: target.id.clone(),
park_service: park_service.clone(),
incoming_service: held.service.clone(),
from_fingerprint: tool.fingerprint(&before),
to_fingerprint: held.login.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, permit, tool, &park_service, &slice) {
Ok(parked) => parked,
Err(e) => {
clear_journal(ctx, permit);
return Err(e);
}
};
fault::point("switch.park_stored");
state.park(&outgoing_key, parked.clone());
if let Err(e) = state::save(ctx, permit, &state) {
let _ = store::vault_delete(ctx, permit, &parked.service);
clear_journal(ctx, permit);
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, permit, &state)?;
clear_journal(ctx, permit);
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, tool, &document).ok())
.is_some_and(|found| found.same_login(&outgoing));
if still_outgoing {
state.discard(&parked.service, ParkKind::Vault);
}
state::save(ctx, permit, &state)?;
clear_journal(ctx, permit);
purge(ctx, permit, &mut state);
return Err(Error::SignedInAccountChanged);
}
if let Err(e) = install_with(
key.provider,
|body| store::write_raw(&live.chain, permit, &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, ParkKind::Vault);
}
state::save(ctx, permit, &state)?;
clear_journal(ctx, permit);
purge(ctx, permit, &mut state);
return Err(e);
}
fault::point("switch.installed");
let lock_lost = guard.as_ref().is_some_and(lock::Guard::compromised);
match holds(tool, &live) {
Ok(true) => {}
Ok(false) => {
clear_journal(ctx, permit);
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, ParkKind::Vault);
let installed = InUse {
owner: Some(target.owner()),
login: tool.fingerprint(&incoming),
known_at: ctx.now(),
named: state
.in_use(key.provider)
.and_then(|known| known.named.clone()),
};
state.identified(key.provider, installed, ctx.now());
state::save(ctx, permit, &state)?;
fault::point("switch.recorded");
drop(guard);
let lock_lost = lock_lost | Refreshing::let_go(refreshing) | renewal_lock_lost;
let cache_warning = write_config(ctx, permit, &mut state, tool, &target, &outgoing);
fault::point("switch.config_updated");
let parks_pending = purge(ctx, permit, &mut state);
clear_journal(ctx, permit);
let still_running = match still_holding(ctx, key.provider) {
StillHolding::Nothing => None,
StillHolding::These(holding) => Some(Warning::SessionsStillRunning {
from: from.clone(),
holding,
}),
StillHolding::Unknown => Some(Warning::SessionsUnknown {
tool: key.provider,
from: from.clone(),
}),
};
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((
Switched {
provider: key.provider,
adoption: tool.adoption(tool.behind(ctx).as_ref()),
from,
to,
parked,
},
warnings,
))
}
fn already_active(
ctx: &Context,
permit: Permit,
state: &mut State,
tool: &'static dyn Provider,
key: &Key,
target: &Account,
) -> (Outcome, Vec<Warning>) {
let which = key.provider;
let naming = in_use::naming(which, &target.owner());
let named = tool
.own_record(ctx)
.filter(|named| Some(state::new_id(which, named)) != naming);
let (config_updated, warnings) = match named {
None => (false, Vec::new()),
Some(named) => match write_config(ctx, permit, state, tool, target, &named) {
None => (true, Vec::new()),
Some(warning) => (false, vec![warning]),
},
};
(
Outcome::AlreadyActive {
label: state.typed(key),
config_updated,
},
warnings,
)
}
fn write_config(
ctx: &Context,
permit: Permit,
state: &mut State,
tool: &'static dyn Provider,
target: &Account,
named: &provider::Owner,
) -> Option<Warning> {
let which = tool.id();
if let Err(error) = tool.after_switch(ctx, permit, target, named) {
return Some(Warning::ConfigNotUpdated(error));
}
if let Some(record) = state.in_use(which).cloned() {
let naming = InUse {
named: in_use::naming(which, &target.owner()),
..record
};
if state.identified(which, naming, ctx.now()).changed {
let _ = state::save(ctx, permit, state);
}
}
None
}
struct Readied {
before_raw: String,
before: Value,
next: String,
on_the_command_line: Option<Warning>,
}
fn write_lock(
ctx: &Context,
permit: Permit,
tool: &'static dyn Provider,
) -> Result<Option<lock::Guard>> {
Ok(tool
.write_lock(ctx)
.map(|dir| lock::acquire(permit, &dir, ctx.locks.write))
.transpose()?)
}
fn ready(
ctx: &Context,
tool: &'static dyn Provider,
live: &provider::LiveStore,
first: &Value,
signed_in: &provider::Owner,
incoming: &Value,
label: &str,
) -> Result<Readied> {
let which = tool.id();
let (before_raw, before) = read_live(ctx, tool, live)?;
if tool.slice(&before).ok() != tool.slice(first).ok()
&& !identify_document(ctx, tool, &before)?.same_login(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 {
before_raw,
before,
next,
on_the_command_line,
})
}
fn holds(
tool: &'static dyn Provider,
live: &provider::LiveStore,
) -> std::result::Result<bool, store::Error> {
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())
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum StillHolding {
Nothing,
These(Vec<holder::Holding>),
Unknown,
}
pub(crate) fn still_holding(ctx: &Context, which: ProviderId) -> StillHolding {
let tool = match provider::of(which) {
LoginKind::Document(tool) => tool,
LoginKind::Folder(app) => {
return match folder::open(ctx, app) {
folder::Open::No => StillHolding::Nothing,
folder::Open::Yes(listed) => StillHolding::These(vec![holder::Holding {
holder: *app.holder(),
pids: listed.iter().map(|one| one.pid).collect(),
}]),
folder::Open::Unknown(_) => StillHolding::Unknown,
};
}
};
let provider::Adoption::RestartRequired { program, holders } = tool.adoption(None) else {
return StillHolding::Nothing;
};
match holder::find(ctx, program, holders) {
None => StillHolding::Unknown,
Some(holding) if holding.is_empty() => StillHolding::Nothing,
Some(holding) => StillHolding::These(holding),
}
}
#[allow(clippy::too_many_arguments)]
fn prove_incoming(
ctx: &Context,
permit: Permit,
state: &mut State,
tool: &'static dyn Provider,
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 tool.verify(ctx, &credential) {
Ok(found) if target.owned_by(&found) => return Ok((held, incoming)),
Ok(other) => {
return Err(Error::ParkedLoginBelongsElsewhere {
label,
who: crate::words::login(
key.provider,
&other.email,
other.email == target.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, permit, state, tool, 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, tool, 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]
#[cfg_attr(
windows,
ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
)]
fn a_switch_after_a_sign_in_outside_puts_the_account_it_goes_to_in_use() {
let m = harness::machine("records-both-logins");
let mut state = state::load(&m.ctx).expect("state");
let before = InUse::of(
state.get(&m.key("there")).expect("enrolled"),
"signed-in-over",
harness::NOW - 3600,
);
state
.in_use
.insert(ProviderId::Claude.code().into(), before);
state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
let settled = settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
switch(settled, &m.key("there")).expect("switched");
let state = state::load(&m.ctx).expect("state");
let there = state.get(&m.key("there")).expect("enrolled");
assert_eq!(
there.last_used_at,
Some(harness::NOW),
"in use from this switch"
);
assert_eq!(
state.in_use(ProviderId::Claude).map(|r| r.login.as_str()),
Some(
provider::document(ProviderId::Claude)
.fingerprint(&harness::oauth("there-refresh", 30))
.as_str()
)
);
assert!(
state
.get(&m.key("here"))
.expect("enrolled")
.parked
.is_some()
);
}
#[test]
#[cfg_attr(
windows,
ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
)]
fn a_session_renewing_the_outgoing_login_during_a_switch_waits_for_it() {
use harness::{Session, lock_dir, renews_meanwhile, saves};
let refreshing =
|m: &harness::Machine| lock_dir(&provider::claude::paths::refresh_lock(&m.ctx));
for point in ["switch.journal_written", "switch.park_recorded"] {
let m = harness::machine(&format!("refresh-lock-{point}"));
let keychain = std::sync::Arc::clone(m.mem.live());
let session = Session::new();
let settled = settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
let done = fault::meanwhile(
point,
renews_meanwhile(&m, std::sync::Arc::clone(&keychain), &session),
|| switch(settled, &m.key("there")),
);
saves(&m, &keychain, &session);
assert!(
session.waited.get(),
"{point}: a session spent the refresh token Pitboard parked"
);
done.expect("switched");
let state = state::load(&m.ctx).expect("state");
let parked = state
.get(&m.key("here"))
.and_then(|account| account.parked.clone())
.expect("the outgoing login is parked");
assert_eq!(parked.login, store::fingerprint("here-refresh"), "{point}");
assert!(
!refreshing(&m).exists(),
"{point}: the refresh lock is let go of"
);
}
}
#[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
}));
}
}