use super::{Result, journal};
use crate::api::Owner;
use crate::context::Context;
use crate::error::Error;
use crate::in_use::{self, InUse};
use crate::provider::{self, ProviderError, ProviderId};
use crate::service::Permit;
use crate::state::{self, Account, Key, State};
use crate::{audit, fault};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Found {
pub(crate) owner: Owner,
pub(crate) login: String,
pub(crate) known_at: i64,
}
impl Found {
pub(crate) fn recorded(self, named: Option<String>) -> InUse {
InUse {
owner: Some(self.owner),
login: self.login,
known_at: self.known_at,
named,
}
}
}
pub(crate) fn nobody(named: Option<&str>, at: i64) -> Option<InUse> {
named.is_none().then(|| InUse {
owner: None,
login: String::new(),
known_at: at,
named: None,
})
}
pub(crate) fn whose(
ctx: &Context,
state: &State,
which: ProviderId,
document: &Value,
) -> std::result::Result<Found, ProviderError> {
let tool = provider::of(which);
let login = tool.fingerprint(document);
if let Some(known) = state.in_use(which)
&& let Some(owner) = &known.owner
&& !login.is_empty()
&& known.login == login
{
return Ok(Found {
owner: owner.clone(),
login,
known_at: known.known_at,
});
}
let owner = tool
.identify(ctx, &provider::Credential::new(which, document.clone()))
.map(Owner::from)?;
Ok(Found {
owner,
login,
known_at: ctx.now(),
})
}
pub(crate) enum Live {
Nothing,
Login(Login),
}
pub(crate) struct Login {
pub(crate) store: provider::LiveStore,
pub(crate) document: Value,
pub(crate) owner: Owner,
}
pub(crate) fn look(ctx: &Context, state: &State, which: ProviderId) -> Result<(Live, InUse)> {
let store = super::live_store(ctx, which)?;
let named = in_use::named(ctx, which);
match super::read_stored(which, &store)? {
None => {
let nobody = nobody(named.as_deref(), ctx.now())
.ok_or_else(|| super::nothing_signed_in(ctx, which))?;
Ok((Live::Nothing, nobody))
}
Some((_, document)) => {
let found = whose(ctx, state, which, &document)
.map_err(|error| super::unidentified(which, error))?;
let owner = found.owner.clone();
let live = Live::Login(Login {
store,
document,
owner,
});
Ok((live, found.recorded(named)))
}
}
}
pub(crate) fn now(
ctx: &Context,
permit: Permit,
state: &mut State,
which: ProviderId,
) -> Result<Live> {
let (live, found) = look(ctx, state, which)?;
keep(ctx, permit, state, which, found)?;
Ok(live)
}
pub(crate) fn keep(
ctx: &Context,
permit: Permit,
state: &mut State,
which: ProviderId,
found: InUse,
) -> Result<()> {
let (changed, noticed) = record(state, which, found, ctx.now());
if changed {
state::save(ctx, permit, state)?;
}
write_down(ctx, permit, ¬iced);
Ok(())
}
pub(crate) fn untold(error: &Error) -> String {
match error {
Error::SessionExpired { tool } => format!("{} refused its access token", tool.service()),
Error::IdentityUnverifiable { detail, .. }
| Error::LiveCredentialShapeUnexpected { detail, .. } => detail.clone(),
Error::LiveStoreUnsupported { reason, .. } => reason.clone(),
Error::LiveCredentialElsewhere { email } => {
format!("its config names {email}, and Pitboard cannot find that login")
}
Error::Store(e) => e.to_string(),
other => other.code().replace('_', " "),
}
}
pub(crate) fn record_read(ctx: &Context, permit: Permit, found: &[(ProviderId, InUse)]) {
let news = |state: &State| -> Vec<(ProviderId, InUse)> {
found
.iter()
.filter(|(which, found)| {
!state
.in_use(*which)
.is_some_and(|known| known.agrees(*which, found))
})
.cloned()
.collect()
};
let Ok(state) = state::load(ctx) else {
return;
};
if news(&state).is_empty() {
return;
}
let Some(_exclusive) = super::try_exclusive(ctx, permit) else {
return;
};
if journal::pending(ctx) {
return;
}
let Ok(mut state) = state::load(ctx) else {
return;
};
fault::point("identify.recording");
let mut changed = false;
let mut noticed = Vec::new();
for (which, found) in news(&state) {
let expected = found.owner.as_ref().map(|_| found.login.clone());
if in_use::named(ctx, which) != found.named || holding(ctx, which) != Some(expected) {
continue;
}
let (recorded, said) = record(&mut state, which, found, ctx.now());
changed |= recorded;
noticed.extend(said);
}
if changed && state::save(ctx, permit, &state).is_ok() {
write_down(ctx, permit, ¬iced);
}
}
fn holding(ctx: &Context, which: ProviderId) -> Option<Option<String>> {
let live = super::live_store(ctx, which).ok()?;
let stored = super::read_stored(which, &live).ok()?;
Some(stored.map(|(_, document)| provider::of(which).fingerprint(&document)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Noticed {
SignedIn(Option<Key>),
SignedOut,
Replaced(Key),
}
pub(crate) fn record(
state: &mut State,
which: ProviderId,
found: InUse,
at: i64,
) -> (bool, Vec<Noticed>) {
let before = state.in_use(which).map(|known| known.account(which));
let now = found.account(which);
let now_enrolled = found
.owner
.as_ref()
.and_then(|owner| state.account_of(which, owner))
.map(Account::key);
let identified = state.identified(which, found, at);
let mut noticed = Vec::new();
if before.is_some_and(|before| before != now) {
noticed.push(match now {
Some(_) => Noticed::SignedIn(now_enrolled),
None => Noticed::SignedOut,
});
}
noticed.extend(
identified
.replaced
.map(|replaced| Noticed::Replaced(replaced.key)),
);
(identified.changed, noticed)
}
pub(crate) fn write_down(ctx: &Context, permit: Permit, noticed: &[Noticed]) {
for noticed in noticed {
let (subject, outcome) = match noticed {
Noticed::SignedIn(now) => (
now.as_ref().map(Key::typed).unwrap_or_default(),
"signed_in_outside",
),
Noticed::SignedOut => (String::new(), "signed_out_outside"),
Noticed::Replaced(key) => (key.typed(), "login_replaced"),
};
audit::record(ctx, permit, "in-use", &subject, outcome);
}
}
#[cfg(test)]
mod tests {
use super::super::harness::{
audit_lines, config_names, document, machine, owner, signed_in_outside, state_file,
};
use super::*;
use crate::service::Pitboard;
#[test]
#[cfg_attr(
windows,
ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
)]
fn an_answer_about_a_login_replaced_meanwhile_is_not_recorded() {
let m = signed_in_outside("identify-meanwhile");
m.api.owned_by("access-third-refresh", owner("third"));
let third = document("third-refresh");
let before = state_file(&m);
crate::fault::meanwhile(
"identify.recording",
{
let (store, service) = m.live_store();
let third = third.to_string();
move || store.plant(&service, &third)
},
|| Pitboard::new(m.ctx.clone()).status(false),
)
.expect("a read");
assert_eq!(
m.live(),
Some(third),
"the login moved while it was asked about"
);
assert_eq!(state_file(&m), before);
assert!(audit_lines(&m, "in-use").is_empty());
}
#[test]
#[cfg_attr(
windows,
ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
)]
fn what_a_read_saw_the_config_name_before_it_was_written_is_not_recorded() {
let m = machine("identify-config-written-meanwhile");
config_names(&m, "there");
let state = state::load(&m.ctx).expect("state");
let (_, found) = look(&m.ctx, &state, ProviderId::Claude).expect("a look");
let pitboard = Pitboard::new(m.ctx.clone());
pitboard.switch_to("here").expect("already in use");
let before = state_file(&m);
record_read(&m.ctx, Permit::for_a_test(), &[(ProviderId::Claude, found)]);
assert_eq!(state_file(&m), before);
let read = pitboard.status_offline().expect("a read of what is known");
let codes: Vec<&str> = read
.warnings
.iter()
.map(crate::service::Warning::code)
.collect();
assert!(codes.is_empty(), "{codes:?}");
}
#[test]
#[cfg_attr(
windows,
ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
)]
fn a_read_while_a_switch_is_interrupted_records_nothing() {
let m = machine("identify-interrupted");
m.api
.owned_by("access-elsewhere-refresh", owner("elsewhere"));
let settled = super::super::settle(&m.ctx, Permit::for_a_test(), None)
.expect("nothing to recover")
.0;
let died = crate::fault::killing("switch.park_recorded", || {
super::super::switch(settled, &m.key("there"))
});
assert_eq!(died.unwrap_err(), "switch.park_recorded");
m.sign_in(&document("elsewhere-refresh"));
let before = state_file(&m);
Pitboard::new(m.ctx.clone()).status(false).expect("a read");
assert!(super::super::interrupted(&m.ctx), "the switch still waits");
assert_eq!(state_file(&m), before);
assert!(audit_lines(&m, "in-use").is_empty());
}
#[test]
#[cfg_attr(
windows,
ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
)]
fn a_login_with_no_fingerprint_is_always_asked_about() {
let m = machine("identify-no-fingerprint");
let mut state = state::load(&m.ctx).expect("state");
let brought_forward = InUse::of(state.get(&m.key("here")).expect("enrolled"), "", 0);
state
.in_use
.insert(ProviderId::Claude.code().into(), brought_forward);
let no_refresh =
serde_json::json!({"claudeAiOauth": {"accessToken": "access-here-refresh"}});
let found = whose(&m.ctx, &state, ProviderId::Claude, &no_refresh).expect("named");
assert_eq!(found.owner, owner("here"));
assert_eq!(
m.api.asked(),
[crate::api::scripted::Asked::Owner(
"access-here-refresh".into()
)]
);
}
}