use crate::context::Context;
use crate::error::{Error, Result};
use crate::state::State;
use crate::{atomic, home, park, store};
use std::path::PathBuf;
fn path(ctx: &Context) -> PathBuf {
home::dir(ctx).join("pending")
}
fn read(ctx: &Context) -> Vec<String> {
std::fs::read_to_string(path(ctx))
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect()
}
fn store_list(ctx: &Context, names: &[String]) -> Result<()> {
let path = path(ctx);
if names.is_empty() {
match std::fs::remove_file(&path) {
Ok(()) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(source) => return Err(Error::HomeUnwritable { path, source }),
}
}
let mut body = names.join("\n");
body.push('\n');
atomic::write(&path, body.as_bytes(), atomic::Perms::Secret)
.map_err(|source| Error::HomeUnwritable { path, source })
}
pub fn reserve(ctx: &Context, service: &str) -> Result<()> {
home::ensure(ctx).map_err(|source| Error::HomeUnwritable {
path: home::dir(ctx),
source,
})?;
let mut names = read(ctx);
if !names.iter().any(|n| n == service) {
names.push(service.to_string());
}
store_list(ctx, &names)
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Reclaimed {
pub given_back: Vec<(String, String)>,
pub deleted: Vec<String>,
pub strangers: Vec<String>,
pub unreadable: Vec<String>,
}
impl Reclaimed {
pub fn found(&self) -> usize {
self.given_back.len() + self.deleted.len()
}
pub fn is_empty(&self) -> bool {
self.given_back.is_empty()
&& self.deleted.is_empty()
&& self.strangers.is_empty()
&& self.unreadable.is_empty()
}
}
pub fn sweep(ctx: &Context, state: &mut State) -> Result<Reclaimed> {
let ours = read(ctx);
resolve(ctx, state, ours.clone(), &ours)
}
pub fn reclaim(ctx: &Context, state: &mut State) -> Result<Reclaimed> {
let ours = read(ctx);
let Some(stored) = store::vault_list(ctx)? else {
return resolve(ctx, state, ours.clone(), &ours);
};
let mut names = stored;
for listed in &ours {
if !names.contains(listed) {
names.push(listed.clone());
}
}
resolve(ctx, state, names, &ours)
}
fn resolve(
ctx: &Context,
state: &mut State,
names: Vec<String>,
ours: &[String],
) -> Result<Reclaimed> {
let mut keep = Vec::new();
let mut out = Reclaimed::default();
for service in names {
if state.names(&service) {
continue;
}
let written_here = ours.contains(&service);
match store::vault_read(ctx, &service) {
Ok(None) => {}
Err(_) => {
out.unreadable.push(service.clone());
if written_here {
keep.push(service);
}
}
Ok(Some(raw)) => match adopt(ctx, state, &service, &raw, written_here) {
Some(label) => out.given_back.push((label, service)),
None if written_here => {
release(ctx, state, &service);
out.deleted.push(service);
}
None => out.strangers.push(service),
},
}
}
if keep != ours {
store_list(ctx, &keep)?;
}
if !out.given_back.is_empty() || !out.deleted.is_empty() {
crate::state::save(ctx, state)?;
}
Ok(out)
}
fn adopt(
ctx: &Context,
state: &mut State,
service: &str,
raw: &str,
written_here: bool,
) -> Option<String> {
let (uuid, at_millis) = park::parts_of(service)?;
let oauth = serde_json::from_str::<serde_json::Value>(raw).ok()?;
let account = state.owner_of_park(&uuid)?;
let key = account.key();
let park = park::describe(key.provider, service, at_millis / 1000, &oauth);
if park.refresh_fingerprint.is_empty() || !park.restorable_at(ctx.now()) {
return None;
}
let replaces_an_older_copy = |held: &crate::state::Park| {
written_here
&& held.refresh_fingerprint != park.refresh_fingerprint
&& park.parked_at >= held.parked_at
};
if account
.parked
.as_ref()
.is_some_and(|held| !replaces_an_older_copy(held))
{
return None;
}
if park::is_live_twin(ctx, key.provider, &oauth) {
return None;
}
if written_here || !store::vault_is_shared(ctx) {
state.park(&key, park);
} else {
state.park_foreign(&key, park);
}
let label = key.typed();
crate::audit::record(ctx, "reclaim", &label, "ok");
Some(label)
}
fn release(ctx: &Context, state: &mut State, service: &str) {
debug_assert!(park::is_park_name(service), "only pitboard's own names");
state.release(service);
crate::audit::record(ctx, "reclaim", service, "discarded");
}
pub fn clear(ctx: &Context) {
let _ = std::fs::remove_file(path(ctx));
}
pub fn outstanding(ctx: &Context) -> Vec<String> {
read(ctx)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ProviderId;
use crate::store::memory::MemoryHost;
use crate::time::{Clock, FixedClock};
use serde_json::json;
use std::sync::Arc;
const NOW: i64 = 1_760_000_000;
fn work() -> crate::state::Key {
crate::state::Key::new(ProviderId::Claude, "work")
}
fn machine(name: &str) -> (Context, Arc<MemoryHost>, PathBuf) {
let root = std::env::temp_dir().join(format!(
"pitboard-pending-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
let mem = MemoryHost::new();
let ctx = Context::new(root.clone())
.with_pitboard_home(root.clone())
.with_memory_stores(Arc::clone(&mem))
.with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn Clock>);
home::ensure(&ctx).expect("a home");
(ctx, mem, root)
}
fn oauth(refresh: &str) -> serde_json::Value {
json!({
"refreshToken": refresh,
"accessToken": "a",
"expiresAt": (NOW + 3600) * 1000,
"refreshTokenExpiresAt": (NOW + 30 * 86_400) * 1000
})
}
fn account(label: &str, uuid: &str) -> crate::state::Account {
crate::state::Account {
last_used_at: None,
label: label.into(),
account_uuid: uuid.into(),
email: format!("{uuid}@example.com"),
detail: crate::state::Detail::Claude {
organization_uuid: "org".into(),
oauth_account: json!({}),
},
parked: None,
}
}
#[test]
fn a_name_claimed_but_never_written_to_is_simply_dropped() {
let (ctx, _mem, root) = machine("never-written");
reserve(&ctx, "pitboard-park-acc-1760000000000").expect("reserved");
let mut state = State::default();
assert_eq!(sweep(&ctx, &mut state).expect("swept").found(), 0);
assert!(outstanding(&ctx).is_empty());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn an_orphan_goes_back_to_the_account_whose_name_it_carries() {
let (ctx, mem, root) = machine("adopt");
let service = "pitboard-park-acc-1760000000000";
reserve(&ctx, service).expect("reserved");
mem.vault().plant(service, &oauth("r").to_string());
let mut state = State::default();
state.accounts.push(account("work", "acc"));
assert_eq!(sweep(&ctx, &mut state).expect("swept").found(), 1);
let park = state
.get(&crate::state::Key::new(
crate::provider::ProviderId::Claude,
"work",
))
.expect("account")
.parked
.clone()
.expect("the orphan was taken back");
assert_eq!(park.service, service);
assert_eq!(
park.parked_at, 1_760_000_000,
"when it was parked is in its own name"
);
assert!(state.foreign.is_empty(), "this pitboard wrote it");
assert!(outstanding(&ctx).is_empty());
let _ = std::fs::remove_dir_all(root);
}
fn holding(
name: &str,
held: (&str, &str, i64),
orphan: (&str, &str),
) -> (Context, State, PathBuf) {
let (ctx, mem, root) = machine(name);
mem.vault().plant(held.0, &oauth(held.1).to_string());
mem.vault().plant(orphan.0, &oauth(orphan.1).to_string());
reserve(&ctx, orphan.0).expect("reserved");
let mut state = State::default();
state.accounts.push(account("work", "acc"));
state.park(
&work(),
park::describe(ProviderId::Claude, held.0, held.2, &oauth(held.1)),
);
(ctx, state, root)
}
fn parked(state: &State) -> Option<String> {
state
.get(&work())
.and_then(|a| a.parked.as_ref())
.map(|p| p.service.clone())
}
#[test]
fn a_second_copy_of_the_chain_an_account_holds_is_deleted() {
let held = "pitboard-park-acc-1750000000000";
let orphan = "pitboard-park-acc-1760000000000";
let (ctx, mut state, root) = holding("same-chain", (held, "r", NOW), (orphan, "r"));
assert_eq!(sweep(&ctx, &mut state).expect("swept").found(), 1);
assert_eq!(parked(&state).as_deref(), Some(held));
assert!(state.discarded.iter().any(|s| s == orphan));
assert!(outstanding(&ctx).is_empty());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn an_older_copy_than_the_one_an_account_holds_is_deleted() {
let held = "pitboard-park-acc-1760000000000";
let orphan = "pitboard-park-acc-1750000000000";
let (ctx, mut state, root) = holding("older", (held, "held", NOW), (orphan, "orphan"));
assert_eq!(sweep(&ctx, &mut state).expect("swept").found(), 1);
assert_eq!(parked(&state).as_deref(), Some(held));
assert!(state.discarded.iter().any(|s| s == orphan));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn a_newer_copy_written_here_replaces_the_one_an_account_holds() {
let held = "pitboard-park-acc-1750000000000";
let orphan = "pitboard-park-acc-1760000000000";
let (ctx, mut state, root) =
holding("newer", (held, "spent", 1_750_000_000), (orphan, "fresh"));
let reclaimed = sweep(&ctx, &mut state).expect("swept");
assert_eq!(reclaimed.given_back.len(), 1);
assert_eq!(parked(&state).as_deref(), Some(orphan));
assert!(
state.discarded.iter().any(|s| s == held),
"the spent copy goes"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn an_item_that_cannot_be_read_stays_listed_rather_than_being_guessed_at() {
let (ctx, mem, root) = machine("unreadable");
let service = "pitboard-park-acc-1760000000000";
reserve(&ctx, service).expect("reserved");
mem.vault().plant(service, &oauth("r").to_string());
mem.vault().fault(
service,
crate::store::memory::Fault::Unreadable("the keychain is locked".into()),
);
let mut state = State::default();
assert_eq!(sweep(&ctx, &mut state).expect("swept").found(), 0);
assert_eq!(outstanding(&ctx), vec![service.to_string()]);
assert!(state.discarded.is_empty(), "nothing is deleted on a guess");
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn asking_the_store_finds_a_login_the_list_never_knew_about() {
let (ctx, mem, root) = machine("reclaim");
let service = "pitboard-park-acc-1760000000000";
mem.vault().plant(service, &oauth("r").to_string());
assert!(
outstanding(&ctx).is_empty(),
"nothing was ever written down"
);
let mut state = State::default();
state.accounts.push(account("work", "acc"));
assert_eq!(sweep(&ctx, &mut state).expect("swept").found(), 0);
assert!(state.get(&work()).expect("account").parked.is_none());
let reclaimed = reclaim(&ctx, &mut state).expect("reclaimed");
assert_eq!(reclaimed.given_back.len(), 1);
assert_eq!(reclaimed.given_back[0].0, "work");
assert_eq!(
state
.get(&crate::state::Key::new(
crate::provider::ProviderId::Claude,
"work"
))
.expect("account")
.parked
.as_ref()
.map(|p| p.service.as_str()),
Some(service)
);
assert_eq!(
state.foreign,
vec![service.to_string()],
"nothing says this pitboard wrote it, so it may be another's"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn a_login_this_pitboard_never_wrote_down_is_reported_and_not_touched() {
let (ctx, mem, root) = machine("stranger");
let service = "pitboard-park-stranger-1760000000000";
mem.vault().plant(service, &oauth("r").to_string());
let mut state = State::default();
let reclaimed = reclaim(&ctx, &mut state).expect("reclaimed");
assert!(reclaimed.given_back.is_empty());
assert!(
reclaimed.deleted.is_empty(),
"nothing may be deleted on a guess"
);
assert_eq!(reclaimed.strangers, vec![service.to_string()]);
assert!(state.discarded.is_empty());
assert_eq!(
mem.vault().peek(service).as_deref(),
Some(oauth("r").to_string().as_str()),
"it is still there"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn a_login_this_pitboard_wrote_down_and_nothing_wants_is_deleted() {
let (ctx, mem, root) = machine("our-orphan");
let service = "pitboard-park-stranger-1760000000000";
mem.vault().plant(service, &oauth("r").to_string());
reserve(&ctx, service).expect("written down first");
let mut state = State::default();
let reclaimed = reclaim(&ctx, &mut state).expect("reclaimed");
assert_eq!(reclaimed.deleted, vec![service.to_string()]);
assert!(reclaimed.strangers.is_empty());
assert!(state.discarded.iter().any(|s| s == service));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn a_name_the_state_already_carries_is_not_swept_at_all() {
let (ctx, mem, root) = machine("already-named");
let service = "pitboard-park-acc-1760000000000";
reserve(&ctx, service).expect("reserved");
mem.vault().plant(service, &oauth("r").to_string());
let mut state = State::default();
state.accounts.push(account("work", "acc"));
state.park(
&work(),
park::describe(ProviderId::Claude, service, NOW, &oauth("r")),
);
assert_eq!(sweep(&ctx, &mut state).expect("swept").found(), 0);
assert!(outstanding(&ctx).is_empty());
assert!(state.discarded.is_empty());
let _ = std::fs::remove_dir_all(root);
}
}