Skip to main content

pitboard_core/
pending.rs

1//! Names Pitboard is about to write a login into, written down before the login is.
2//!
3//! The switch got this right and nothing else did. `enroll --sign-in` and the renewal that
4//! runs inside every `pitboard status` both claimed a name, wrote a login into it, and only
5//! then recorded it, with cleanup that runs when a step returns an error and never when a
6//! run is killed. Killed in that window, the machine keeps a live refresh token that no
7//! entry in `state.json` names: never renewed, never offered, not removed by
8//! `pitboard uninstall`, and on macOS not listable by any tool the user has, because
9//! `security find-generic-password` takes no wildcard.
10//!
11//! So every name is written here first and resolved by the next command. This is an index,
12//! not a vault: it holds names, never a token.
13
14use 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        // Nothing outstanding: leave no file rather than an empty one, so the ordinary
39        // state of a machine is the absence of this.
40        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
52/// Write a name down before anything is written into it. A name here that never receives a
53/// login costs one line and is dropped by the next sweep.
54pub 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/// What resolving unnamed logins did.
67#[derive(Debug, Default, PartialEq, Eq)]
68pub struct Reclaimed {
69    /// Given back to the account whose name they carry: (label, service).
70    pub given_back: Vec<(String, String)>,
71    /// Listed for deletion. Only ever a name this Pitboard wrote down itself and nothing
72    /// recorded, which is the one case where being sure is possible.
73    pub deleted: Vec<String>,
74    /// Found in the store, belonging to no account here and never written down here.
75    /// Reported and not touched: the store is shared by the whole machine and Pitboard's
76    /// records are not, so an item Pitboard cannot account for may be another Pitboard's.
77    pub strangers: Vec<String>,
78    /// Left exactly as they are, because the store could not be read.
79    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
95/// Resolve every name Pitboard wrote down and nothing refers to any more.
96///
97/// Runs in every `settle`, after the journal has had its say, so a switch's own park is
98/// already accounted for by then. It reads Pitboard's own list rather than asking the
99/// store, because it is on the path of every change and a keychain dump is not free.
100pub 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
105/// The same, asking the store what is actually there rather than trusting Pitboard's list.
106/// This is what finds a login whose name was lost with the list, or written by a version
107/// that had no list. `pitboard repair` runs it; nothing else does, because on macOS it
108/// dumps the keychain.
109pub 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        // A store that cannot be enumerated: Pitboard's own list is all there is.
113        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
124/// `ours` is the list of names this Pitboard wrote down before creating them. It is what
125/// separates an item that may be deleted from one that may only be reported.
126///
127/// The asymmetry matters more than anything else here. On macOS the keychain belongs to the
128/// whole login session while Pitboard's records belong to one `PITBOARD_HOME`, so an item
129/// this Pitboard cannot account for is not evidence of an orphan: it may be another
130/// Pitboard's parked login, and deleting it would end that account's session for someone
131/// who never ran this command. Giving a login back is additive and safe to do on a guess;
132/// deleting one is not, and is done only where being sure is possible. Where the vault is
133/// shared that way, a login given back that this Pitboard did not write down is recorded
134/// as such, so letting it go later never deletes it either: only using it does.
135fn 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            // Claimed but never written to. Nothing was ever there.
151            Ok(None) => {}
152            // Cannot tell. Try again next time rather than guess.
153            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                    // This Pitboard wrote this name down, wrote a login into it, and
163                    // nothing here recorded it. That is an orphan and nothing else can be.
164                    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
180/// Give an orphan back to the account whose name it carries, where that account is enrolled
181/// and holds nothing, or holds an older copy this one replaces.
182///
183/// The second case is what a renewal leaves when it is killed after writing the fresh login
184/// and before recording it: the service has already spent the older copy's refresh token,
185/// so the account holds a dead login and the orphan is its only live one. Keeping the older
186/// and deleting the newer, which is what happened, lost the login. Only a name this Pitboard
187/// wrote down itself is trusted that far; a stranger replaces nothing. The older copy is
188/// released rather than discarded, because an enrolment killed at the same point leaves the
189/// same shape with an older copy nothing spent, and an older copy `repair` gave back may be
190/// another Pitboard's.
191///
192/// Anything else is refused: an account that already holds a newer park has a login
193/// Pitboard renews, and a second copy of one refresh chain is the state that ends a login
194/// for both holders.
195fn 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    // A copy of the login signed in now is not something to give back, for a tool whose
224    // park may never be a copy.
225    if park::is_live_twin(ctx, key.provider, &oauth) {
226        return None;
227    }
228    // A vault of files lives inside this home, so whatever is in it is this Pitboard's
229    // whether or not it was written down, and is deleted like any other once let go.
230    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
240/// List it for deletion the way every other unwanted park is listed, so a delete that fails
241/// is retried rather than forgotten.
242fn 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
248/// Forget everything listed, without touching the vault. Only `uninstall` does this, once
249/// it has deleted what the names refer to.
250pub fn clear(ctx: &Context, permit: Permit) {
251    let _ = crate::host::fs::remove_file(permit, &path(ctx));
252}
253
254/// Every name still outstanding, for `doctor` to report: written down, and named by nothing
255/// in `state`. A change records its park in `state.json` once the login is written, and the
256/// name stays on the list until the next change's sweep drops it; until then it is
257/// accounted for, not outstanding. Where the state could not be read, every name is.
258pub 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    /// The account a name was written for, holding `held`, with `orphan` in the vault under
395    /// a name this Pitboard wrote down and nothing recorded.
396    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    /// Two copies of one refresh chain is the state that ends a login for both holders, so
422    /// the copy nothing names loses.
423    #[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    /// An orphan older than what the account holds is left over from before it, and the
442    /// recorded copy is the one Pitboard renews.
443    #[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    /// A newer copy this Pitboard wrote and never recorded is what a renewal killed between
461    /// writing and recording leaves. The service spent the older copy's chain when it
462    /// answered, so the newer one is the account's only working login and replaces it.
463    #[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    /// A store that could not answer says nothing about what is in it.
481    #[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    /// The case the list alone cannot answer: a login in the vault that Pitboard never
505    /// wrote a name down for, because the home was lost, or because it was parked by a
506    /// version that kept no list. Only asking the store finds it.
507    #[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        // The cheap sweep cannot see it, because it only reads Pitboard's own list.
521        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    /// A login for an account this machine has never heard of. Keeping it would be keeping
553    /// a credential nothing can ever use, renew or name.
554    /// The rule that matters most here. A keychain belongs to a login session; Pitboard's
555    /// records belong to one PITBOARD_HOME. So a parked login this Pitboard cannot account
556    /// for is not evidence of an orphan, it is evidence of another Pitboard, and deleting
557    /// it would end that account's session for somebody who never ran this command.
558    #[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    /// The one case where being sure is possible: this Pitboard wrote the name down, wrote
583    /// a login into it, and nothing here recorded it.
584    #[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}