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::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        // Nothing outstanding: leave no file rather than an empty one, so the ordinary
38        // state of a machine is the absence of this.
39        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
51/// Write a name down before anything is written into it. A name here that never receives a
52/// login costs one line and is dropped by the next sweep.
53pub 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/// What resolving unnamed logins did.
66#[derive(Debug, Default, PartialEq, Eq)]
67pub struct Reclaimed {
68    /// Given back to the account whose name they carry: (label, service).
69    pub given_back: Vec<(String, String)>,
70    /// Listed for deletion. Only ever a name this pitboard wrote down itself and nothing
71    /// recorded, which is the one case where being sure is possible.
72    pub deleted: Vec<String>,
73    /// Found in the store, belonging to no account here and never written down here.
74    /// Reported and not touched: the store is shared by the whole machine and pitboard's
75    /// records are not, so an item pitboard cannot account for may be another pitboard's.
76    pub strangers: Vec<String>,
77    /// Left exactly as they are, because the store could not be read.
78    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
94/// Resolve every name pitboard wrote down and nothing refers to any more.
95///
96/// Runs in every `settle`, after the journal has had its say, so a switch's own park is
97/// already accounted for by then. It reads pitboard's own list rather than asking the
98/// store, because it is on the path of every change and a keychain dump is not free.
99pub fn sweep(ctx: &Context, state: &mut State) -> Result<Reclaimed> {
100    let ours = read(ctx);
101    resolve(ctx, state, ours.clone(), &ours)
102}
103
104/// The same, asking the store what is actually there rather than trusting pitboard's list.
105/// This is what finds a login whose name was lost with the list, or written by a version
106/// that had no list. `pitboard repair` runs it; nothing else does, because on macOS it
107/// dumps the keychain.
108pub fn reclaim(ctx: &Context, state: &mut State) -> Result<Reclaimed> {
109    let ours = read(ctx);
110    let Some(stored) = store::vault_list(ctx)? else {
111        // A store that cannot be enumerated: pitboard's own list is all there is.
112        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
123/// `ours` is the list of names this pitboard wrote down before creating them. It is what
124/// separates an item that may be deleted from one that may only be reported.
125///
126/// The asymmetry matters more than anything else here. On macOS the keychain belongs to the
127/// whole login session while pitboard's records belong to one `PITBOARD_HOME`, so an item
128/// this pitboard cannot account for is not evidence of an orphan: it may be another
129/// pitboard's parked login, and deleting it would end that account's session for someone
130/// who never ran this command. Giving a login back is additive and safe to do on a guess;
131/// deleting one is not, and is done only where being sure is possible. Where the vault is
132/// shared that way, a login given back that this pitboard did not write down is recorded
133/// as such, so letting it go later never deletes it either: only using it does.
134fn 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            // Claimed but never written to. Nothing was ever there.
149            Ok(None) => {}
150            // Cannot tell. Try again next time rather than guess.
151            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                    // This pitboard wrote this name down, wrote a login into it, and
161                    // nothing here recorded it. That is an orphan and nothing else can be.
162                    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
178/// Give an orphan back to the account whose name it carries, where that account is enrolled
179/// and holds nothing, or holds an older copy this one replaces.
180///
181/// The second case is what a renewal leaves when it is killed after writing the fresh login
182/// and before recording it: the service has already spent the older copy's refresh token,
183/// so the account holds a dead login and the orphan is its only live one. Keeping the older
184/// and deleting the newer, which is what happened, lost the login. Only a name this pitboard
185/// wrote down itself is trusted that far; a stranger replaces nothing. The older copy is
186/// released rather than discarded, because an enrolment killed at the same point leaves the
187/// same shape with an older copy nothing spent, and an older copy `repair` gave back may be
188/// another pitboard's.
189///
190/// Anything else is refused: an account that already holds a newer park has a login
191/// pitboard renews, and a second copy of one refresh chain is the state that ends a login
192/// for both holders.
193fn 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    // A copy of the login signed in now is not something to give back, for a tool whose
221    // park may never be a copy.
222    if park::is_live_twin(ctx, key.provider, &oauth) {
223        return None;
224    }
225    // A vault of files lives inside this home, so whatever is in it is this pitboard's
226    // whether or not it was written down, and is deleted like any other once let go.
227    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
237/// List it for deletion the way every other unwanted park is listed, so a delete that fails
238/// is retried rather than forgotten.
239fn 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
245/// Forget everything listed, without touching the vault. Only `uninstall` does this, once
246/// it has deleted what the names refer to.
247pub fn clear(ctx: &Context) {
248    let _ = std::fs::remove_file(path(ctx));
249}
250
251/// Every name still outstanding, for `doctor` to report.
252pub 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    /// The account a name was written for, holding `held`, with `orphan` in the vault under
352    /// a name this pitboard wrote down and nothing recorded.
353    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    /// Two copies of one refresh chain is the state that ends a login for both holders, so
379    /// the copy nothing names loses.
380    #[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    /// An orphan older than what the account holds is left over from before it, and the
394    /// recorded copy is the one pitboard renews.
395    #[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    /// A newer copy this pitboard wrote and never recorded is what a renewal killed between
408    /// writing and recording leaves. The service spent the older copy's chain when it
409    /// answered, so the newer one is the account's only working login and replaces it.
410    #[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    /// A store that could not answer says nothing about what is in it.
428    #[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    /// The case the list alone cannot answer: a login in the vault that pitboard never
447    /// wrote a name down for, because the home was lost, or because it was parked by a
448    /// version that kept no list. Only asking the store finds it.
449    #[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        // The cheap sweep cannot see it, because it only reads pitboard's own list.
463        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    /// A login for an account this machine has never heard of. Keeping it would be keeping
490    /// a credential nothing can ever use, renew or name.
491    /// The rule that matters most here. A keychain belongs to a login session; pitboard's
492    /// records belong to one PITBOARD_HOME. So a parked login this pitboard cannot account
493    /// for is not evidence of an orphan, it is evidence of another pitboard, and deleting
494    /// it would end that account's session for somebody who never ran this command.
495    #[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    /// The one case where being sure is possible: this pitboard wrote the name down, wrote
520    /// a login into it, and nothing here recorded it.
521    #[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}