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