1use crate::context::Context;
15use crate::error::{Error, Result};
16use crate::service::Permit;
17use crate::state::State;
18use crate::{atomic, home, park, store};
19use std::path::PathBuf;
20
21fn path(ctx: &Context) -> PathBuf {
22 home::dir(ctx).join("pending")
23}
24
25fn read(ctx: &Context) -> Vec<String> {
26 std::fs::read_to_string(path(ctx))
27 .unwrap_or_default()
28 .lines()
29 .map(str::trim)
30 .filter(|line| !line.is_empty())
31 .map(str::to_owned)
32 .collect()
33}
34
35fn store_list(ctx: &Context, permit: Permit, names: &[String]) -> Result<()> {
36 let path = path(ctx);
37 if names.is_empty() {
38 match crate::host::fs::remove_file(permit, &path) {
41 Ok(()) => return Ok(()),
42 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
43 Err(source) => return Err(Error::HomeUnwritable { path, source }),
44 }
45 }
46 let mut body = names.join("\n");
47 body.push('\n');
48 atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret)
49 .map_err(|source| Error::HomeUnwritable { path, source })
50}
51
52pub fn reserve(ctx: &Context, permit: Permit, service: &str) -> Result<()> {
55 home::ensure(ctx, permit).map_err(|source| Error::HomeUnwritable {
56 path: home::dir(ctx),
57 source,
58 })?;
59 let mut names = read(ctx);
60 if !names.iter().any(|n| n == service) {
61 names.push(service.to_string());
62 }
63 store_list(ctx, permit, &names)
64}
65
66#[derive(Debug, Default, PartialEq, Eq)]
68pub struct Reclaimed {
69 pub given_back: Vec<(String, String)>,
71 pub deleted: Vec<String>,
74 pub strangers: Vec<String>,
78 pub unreadable: Vec<String>,
80}
81
82impl Reclaimed {
83 pub fn found(&self) -> usize {
84 self.given_back.len() + self.deleted.len()
85 }
86
87 pub fn is_empty(&self) -> bool {
88 self.given_back.is_empty()
89 && self.deleted.is_empty()
90 && self.strangers.is_empty()
91 && self.unreadable.is_empty()
92 }
93}
94
95pub fn sweep(ctx: &Context, permit: Permit, state: &mut State) -> Result<Reclaimed> {
101 let ours = read(ctx);
102 resolve(ctx, permit, state, ours.clone(), &ours)
103}
104
105pub fn reclaim(ctx: &Context, permit: Permit, state: &mut State) -> Result<Reclaimed> {
110 let ours = read(ctx);
111 let Some(stored) = store::vault_list(ctx)? else {
112 return resolve(ctx, permit, state, ours.clone(), &ours);
114 };
115 let mut names = stored;
116 for listed in &ours {
117 if !names.contains(listed) {
118 names.push(listed.clone());
119 }
120 }
121 resolve(ctx, permit, state, names, &ours)
122}
123
124fn resolve(
136 ctx: &Context,
137 permit: Permit,
138 state: &mut State,
139 names: Vec<String>,
140 ours: &[String],
141) -> Result<Reclaimed> {
142 let mut keep = Vec::new();
143 let mut out = Reclaimed::default();
144 for service in names {
145 if state.names(&service) {
146 continue;
147 }
148 let written_here = ours.contains(&service);
149 match store::vault_read(ctx, &service) {
150 Ok(None) => {}
152 Err(_) => {
154 out.unreadable.push(service.clone());
155 if written_here {
156 keep.push(service);
157 }
158 }
159 Ok(Some(raw)) => match adopt(ctx, permit, state, &service, &raw, written_here) {
160 Some(label) => out.given_back.push((label, service)),
161 None if written_here => {
162 release(ctx, permit, state, &service);
165 out.deleted.push(service);
166 }
167 None => out.strangers.push(service),
168 },
169 }
170 }
171 if keep != ours {
172 store_list(ctx, permit, &keep)?;
173 }
174 if !out.given_back.is_empty() || !out.deleted.is_empty() {
175 crate::state::save(ctx, permit, state)?;
176 }
177 Ok(out)
178}
179
180fn adopt(
196 ctx: &Context,
197 permit: Permit,
198 state: &mut State,
199 service: &str,
200 raw: &str,
201 written_here: bool,
202) -> Option<String> {
203 let (id, at_millis) = park::parts_of(service)?;
204 let oauth = serde_json::from_str::<serde_json::Value>(raw).ok()?;
205 let account = state.owner_of_park(&id)?;
206 let key = account.key();
207 let park = park::describe(key.provider, service, at_millis / 1000, &oauth);
208 if park.refresh_fingerprint.is_empty() || !park.restorable_at(ctx.now()) {
209 return None;
210 }
211 let replaces_an_older_copy = |held: &crate::state::Park| {
212 written_here
213 && held.refresh_fingerprint != park.refresh_fingerprint
214 && park.parked_at >= held.parked_at
215 };
216 if account
217 .parked
218 .as_ref()
219 .is_some_and(|held| !replaces_an_older_copy(held))
220 {
221 return None;
222 }
223 if park::is_live_twin(ctx, key.provider, &oauth) {
226 return None;
227 }
228 if written_here || !store::vault_is_shared(ctx) {
231 state.park(&key, park);
232 } else {
233 state.park_foreign(&key, park);
234 }
235 let label = key.typed();
236 crate::audit::record(ctx, permit, "reclaim", &label, "ok");
237 Some(label)
238}
239
240fn release(ctx: &Context, permit: Permit, state: &mut State, service: &str) {
243 debug_assert!(park::is_park_name(service), "only Pitboard's own names");
244 state.release(service);
245 crate::audit::record(ctx, permit, "reclaim", service, "discarded");
246}
247
248pub fn clear(ctx: &Context, permit: Permit) {
251 let _ = crate::host::fs::remove_file(permit, &path(ctx));
252}
253
254pub fn outstanding(ctx: &Context, state: Option<&State>) -> Vec<String> {
259 read(ctx)
260 .into_iter()
261 .filter(|service| !state.is_some_and(|state| state.names(service)))
262 .collect()
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::host::memory::MemoryHost;
269 use crate::provider::ProviderId;
270 use crate::time::{Clock, FixedClock};
271 use serde_json::json;
272 use std::sync::Arc;
273
274 const NOW: i64 = 1_760_000_000;
275
276 fn work() -> crate::state::Key {
277 crate::state::Key::new(ProviderId::Claude, "work")
278 }
279
280 fn machine(name: &str) -> (Context, Arc<MemoryHost>, PathBuf) {
281 let root = std::env::temp_dir().join(format!(
282 "pitboard-pending-{name}-{}-{:?}",
283 std::process::id(),
284 std::thread::current().id()
285 ));
286 let _ = std::fs::remove_dir_all(&root);
287 let mem = MemoryHost::new();
288 let ctx = Context::new(root.clone())
289 .with_pitboard_home(root.clone())
290 .with_memory_stores(Arc::clone(&mem))
291 .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn Clock>);
292 home::ensure(&ctx, Permit::for_a_test()).expect("a home");
293 (ctx, mem, root)
294 }
295
296 fn oauth(refresh: &str) -> serde_json::Value {
297 json!({
298 "refreshToken": refresh,
299 "accessToken": "a",
300 "expiresAt": (NOW + 3600) * 1000,
301 "refreshTokenExpiresAt": (NOW + 30 * 86_400) * 1000
302 })
303 }
304
305 fn account(label: &str, uuid: &str) -> crate::state::Account {
306 crate::state::Account {
307 last_used_at: None,
308 label: label.into(),
309 id: uuid.into(),
310 account_uuid: uuid.into(),
311 email: format!("{uuid}@example.com"),
312 detail: crate::state::Detail::Claude {
313 organization_uuid: "org".into(),
314 oauth_account: json!({}),
315 },
316 parked: None,
317 }
318 }
319
320 #[test]
321 fn a_name_claimed_but_never_written_to_is_simply_dropped() {
322 let (ctx, _mem, root) = machine("never-written");
323 reserve(
324 &ctx,
325 Permit::for_a_test(),
326 "pitboard-park-acc-1760000000000",
327 )
328 .expect("reserved");
329 let mut state = State::default();
330
331 assert_eq!(
332 sweep(&ctx, Permit::for_a_test(), &mut state)
333 .expect("swept")
334 .found(),
335 0
336 );
337 assert!(outstanding(&ctx, None).is_empty());
338 let _ = std::fs::remove_dir_all(root);
339 }
340
341 #[test]
342 fn a_name_the_state_records_is_not_outstanding() {
343 let (ctx, _mem, root) = machine("recorded");
344 let service = "pitboard-park-acc-1760000000000";
345 reserve(&ctx, Permit::for_a_test(), service).expect("reserved");
346 let mut state = State::default();
347 state.accounts.push(account("work", "acc"));
348 state.park(
349 &work(),
350 park::describe(ProviderId::Claude, service, NOW, &oauth("r")),
351 );
352
353 assert!(outstanding(&ctx, Some(&state)).is_empty());
354 assert_eq!(outstanding(&ctx, None), [service]);
355 assert_eq!(outstanding(&ctx, Some(&State::default())), [service]);
356 let _ = std::fs::remove_dir_all(root);
357 }
358
359 #[test]
360 fn an_orphan_goes_back_to_the_account_whose_name_it_carries() {
361 let (ctx, mem, root) = machine("adopt");
362 let service = "pitboard-park-acc-1760000000000";
363 reserve(&ctx, Permit::for_a_test(), service).expect("reserved");
364 mem.vault().plant(service, &oauth("r").to_string());
365
366 let mut state = State::default();
367 state.accounts.push(account("work", "acc"));
368 assert_eq!(
369 sweep(&ctx, Permit::for_a_test(), &mut state)
370 .expect("swept")
371 .found(),
372 1
373 );
374
375 let park = state
376 .get(&crate::state::Key::new(
377 crate::provider::ProviderId::Claude,
378 "work",
379 ))
380 .expect("account")
381 .parked
382 .clone()
383 .expect("the orphan was taken back");
384 assert_eq!(park.service, service);
385 assert_eq!(
386 park.parked_at, 1_760_000_000,
387 "when it was parked is in its own name"
388 );
389 assert!(state.foreign.is_empty(), "this Pitboard wrote it");
390 assert!(outstanding(&ctx, None).is_empty());
391 let _ = std::fs::remove_dir_all(root);
392 }
393
394 fn holding(
397 name: &str,
398 held: (&str, &str, i64),
399 orphan: (&str, &str),
400 ) -> (Context, State, PathBuf) {
401 let (ctx, mem, root) = machine(name);
402 mem.vault().plant(held.0, &oauth(held.1).to_string());
403 mem.vault().plant(orphan.0, &oauth(orphan.1).to_string());
404 reserve(&ctx, Permit::for_a_test(), orphan.0).expect("reserved");
405 let mut state = State::default();
406 state.accounts.push(account("work", "acc"));
407 state.park(
408 &work(),
409 park::describe(ProviderId::Claude, held.0, held.2, &oauth(held.1)),
410 );
411 (ctx, state, root)
412 }
413
414 fn parked(state: &State) -> Option<String> {
415 state
416 .get(&work())
417 .and_then(|a| a.parked.as_ref())
418 .map(|p| p.service.clone())
419 }
420
421 #[test]
424 fn a_second_copy_of_the_chain_an_account_holds_is_deleted() {
425 let held = "pitboard-park-acc-1750000000000";
426 let orphan = "pitboard-park-acc-1760000000000";
427 let (ctx, mut state, root) = holding("same-chain", (held, "r", NOW), (orphan, "r"));
428
429 assert_eq!(
430 sweep(&ctx, Permit::for_a_test(), &mut state)
431 .expect("swept")
432 .found(),
433 1
434 );
435 assert_eq!(parked(&state).as_deref(), Some(held));
436 assert!(state.discarded.iter().any(|s| s == orphan));
437 assert!(outstanding(&ctx, None).is_empty());
438 let _ = std::fs::remove_dir_all(root);
439 }
440
441 #[test]
444 fn an_older_copy_than_the_one_an_account_holds_is_deleted() {
445 let held = "pitboard-park-acc-1760000000000";
446 let orphan = "pitboard-park-acc-1750000000000";
447 let (ctx, mut state, root) = holding("older", (held, "held", NOW), (orphan, "orphan"));
448
449 assert_eq!(
450 sweep(&ctx, Permit::for_a_test(), &mut state)
451 .expect("swept")
452 .found(),
453 1
454 );
455 assert_eq!(parked(&state).as_deref(), Some(held));
456 assert!(state.discarded.iter().any(|s| s == orphan));
457 let _ = std::fs::remove_dir_all(root);
458 }
459
460 #[test]
464 fn a_newer_copy_written_here_replaces_the_one_an_account_holds() {
465 let held = "pitboard-park-acc-1750000000000";
466 let orphan = "pitboard-park-acc-1760000000000";
467 let (ctx, mut state, root) =
468 holding("newer", (held, "spent", 1_750_000_000), (orphan, "fresh"));
469
470 let reclaimed = sweep(&ctx, Permit::for_a_test(), &mut state).expect("swept");
471 assert_eq!(reclaimed.given_back.len(), 1);
472 assert_eq!(parked(&state).as_deref(), Some(orphan));
473 assert!(
474 state.discarded.iter().any(|s| s == held),
475 "the spent copy goes"
476 );
477 let _ = std::fs::remove_dir_all(root);
478 }
479
480 #[test]
482 fn an_item_that_cannot_be_read_stays_listed_rather_than_being_guessed_at() {
483 let (ctx, mem, root) = machine("unreadable");
484 let service = "pitboard-park-acc-1760000000000";
485 reserve(&ctx, Permit::for_a_test(), service).expect("reserved");
486 mem.vault().plant(service, &oauth("r").to_string());
487 mem.vault().fault(
488 service,
489 crate::store::memory::Fault::Unreadable("the keychain is locked".into()),
490 );
491
492 let mut state = State::default();
493 assert_eq!(
494 sweep(&ctx, Permit::for_a_test(), &mut state)
495 .expect("swept")
496 .found(),
497 0
498 );
499 assert_eq!(outstanding(&ctx, None), vec![service.to_string()]);
500 assert!(state.discarded.is_empty(), "nothing is deleted on a guess");
501 let _ = std::fs::remove_dir_all(root);
502 }
503
504 #[test]
508 fn asking_the_store_finds_a_login_the_list_never_knew_about() {
509 let (ctx, mem, root) = machine("reclaim");
510 let service = "pitboard-park-acc-1760000000000";
511 mem.vault().plant(service, &oauth("r").to_string());
512 assert!(
513 outstanding(&ctx, None).is_empty(),
514 "nothing was ever written down"
515 );
516
517 let mut state = State::default();
518 state.accounts.push(account("work", "acc"));
519
520 assert_eq!(
522 sweep(&ctx, Permit::for_a_test(), &mut state)
523 .expect("swept")
524 .found(),
525 0
526 );
527 assert!(state.get(&work()).expect("account").parked.is_none());
528
529 let reclaimed = reclaim(&ctx, Permit::for_a_test(), &mut state).expect("reclaimed");
530 assert_eq!(reclaimed.given_back.len(), 1);
531 assert_eq!(reclaimed.given_back[0].0, "work");
532 assert_eq!(
533 state
534 .get(&crate::state::Key::new(
535 crate::provider::ProviderId::Claude,
536 "work"
537 ))
538 .expect("account")
539 .parked
540 .as_ref()
541 .map(|p| p.service.as_str()),
542 Some(service)
543 );
544 assert_eq!(
545 state.foreign,
546 vec![service.to_string()],
547 "nothing says this Pitboard wrote it, so it may be another's"
548 );
549 let _ = std::fs::remove_dir_all(root);
550 }
551
552 #[test]
559 fn a_login_this_pitboard_never_wrote_down_is_reported_and_not_touched() {
560 let (ctx, mem, root) = machine("stranger");
561 let service = "pitboard-park-stranger-1760000000000";
562 mem.vault().plant(service, &oauth("r").to_string());
563
564 let mut state = State::default();
565 let reclaimed = reclaim(&ctx, Permit::for_a_test(), &mut state).expect("reclaimed");
566
567 assert!(reclaimed.given_back.is_empty());
568 assert!(
569 reclaimed.deleted.is_empty(),
570 "nothing may be deleted on a guess"
571 );
572 assert_eq!(reclaimed.strangers, vec![service.to_string()]);
573 assert!(state.discarded.is_empty());
574 assert_eq!(
575 mem.vault().peek(service).as_deref(),
576 Some(oauth("r").to_string().as_str()),
577 "it is still there"
578 );
579 let _ = std::fs::remove_dir_all(root);
580 }
581
582 #[test]
585 fn a_login_this_pitboard_wrote_down_and_nothing_wants_is_deleted() {
586 let (ctx, mem, root) = machine("our-orphan");
587 let service = "pitboard-park-stranger-1760000000000";
588 mem.vault().plant(service, &oauth("r").to_string());
589 reserve(&ctx, Permit::for_a_test(), service).expect("written down first");
590
591 let mut state = State::default();
592 let reclaimed = reclaim(&ctx, Permit::for_a_test(), &mut state).expect("reclaimed");
593
594 assert_eq!(reclaimed.deleted, vec![service.to_string()]);
595 assert!(reclaimed.strangers.is_empty());
596 assert!(state.discarded.iter().any(|s| s == service));
597 let _ = std::fs::remove_dir_all(root);
598 }
599
600 #[test]
601 fn a_name_the_state_already_carries_is_not_swept_at_all() {
602 let (ctx, mem, root) = machine("already-named");
603 let service = "pitboard-park-acc-1760000000000";
604 reserve(&ctx, Permit::for_a_test(), service).expect("reserved");
605 mem.vault().plant(service, &oauth("r").to_string());
606
607 let mut state = State::default();
608 state.accounts.push(account("work", "acc"));
609 state.park(
610 &work(),
611 park::describe(ProviderId::Claude, service, NOW, &oauth("r")),
612 );
613
614 assert_eq!(
615 sweep(&ctx, Permit::for_a_test(), &mut state)
616 .expect("swept")
617 .found(),
618 0
619 );
620 assert!(outstanding(&ctx, None).is_empty());
621 assert!(state.discarded.is_empty());
622 let _ = std::fs::remove_dir_all(root);
623 }
624}