Skip to main content

pitboard_core/
state.rs

1//! Which accounts pitboard knows and where each one is parked. No secrets: the logins stay
2//! in the keychain or vault.
3//!
4//! Stamped with the machine that wrote it, because a parked login belongs to exactly one
5//! machine: presenting a refresh token another machine has since rotated ends the login on
6//! both.
7
8use crate::context::Context;
9use crate::error::{Error, Result};
10use crate::{atomic, home};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::path::PathBuf;
14
15const SCHEMA: u32 = 3;
16
17/// A login held for an account while another is signed in. There is at most one per
18/// account: once installed it is Claude Code's again, and Claude Code rotates it from then
19/// on, so a copy kept back would only ever present a token it has moved past.
20#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
21pub struct Park {
22    pub service: String,
23    pub parked_at: i64,
24    pub refresh_fingerprint: String,
25    /// Until then its usage can be asked without renewing it first.
26    pub access_expires_at: Option<i64>,
27    /// Until then it can be restored.
28    pub refresh_expires_at: Option<i64>,
29}
30
31impl Park {
32    pub fn restorable_at(&self, now: i64) -> bool {
33        self.refresh_expires_at.is_none_or(|at| at > now)
34    }
35
36    pub fn askable_at(&self, now: i64) -> bool {
37        self.access_expires_at.is_none_or(|at| at > now)
38    }
39}
40
41#[derive(Serialize, Deserialize, Debug, Clone)]
42pub struct Account {
43    pub label: String,
44    pub account_uuid: String,
45    pub email: String,
46    pub organization_uuid: String,
47    /// Written into Claude Code's config on switching here. Only what Anthropic confirmed,
48    /// so Claude Code fetches the rest of its profile itself.
49    pub oauth_account: Value,
50    pub parked: Option<Park>,
51}
52
53#[derive(Serialize, Deserialize, Debug, Clone)]
54pub struct State {
55    pub schema: u32,
56    pub machine: String,
57    pub accounts: Vec<Account>,
58    pub active: Option<String>,
59    /// The credential slot `active` was recorded for. One state file serves every slot a
60    /// machine uses, and CLAUDE_CONFIG_DIR changes which keychain item is the live one, so
61    /// a record made in one slot says nothing about another.
62    #[serde(default)]
63    pub slot: Option<String>,
64    /// Parked items no account refers to any more. Listed in the same save that drops them
65    /// and removed once deleted, so a delete that fails or is interrupted is retried.
66    #[serde(default)]
67    pub discarded: Vec<String>,
68}
69
70impl Default for State {
71    fn default() -> Self {
72        State {
73            schema: SCHEMA,
74            machine: machine_id(),
75            accounts: Vec::new(),
76            active: None,
77            slot: None,
78            discarded: Vec::new(),
79        }
80    }
81}
82
83impl State {
84    /// Every label enrolled, for a message that would otherwise send someone to another
85    /// command to find out.
86    pub fn labels(&self) -> crate::error::Enrolled {
87        crate::error::Enrolled(self.accounts.iter().map(|a| a.label.clone()).collect())
88    }
89
90    pub fn get(&self, label: &str) -> Option<&Account> {
91        self.accounts.iter().find(|a| a.label == label)
92    }
93
94    pub fn by_uuid(&self, uuid: &str) -> Option<&Account> {
95        self.accounts.iter().find(|a| a.account_uuid == uuid)
96    }
97
98    fn get_mut(&mut self, label: &str) -> Option<&mut Account> {
99        self.accounts.iter_mut().find(|a| a.label == label)
100    }
101
102    /// Hold `park` for the account, discarding whatever it replaces.
103    pub fn park(&mut self, label: &str, park: Park) {
104        let service = park.service.clone();
105        if let Some(previous) = self
106            .get_mut(label)
107            .and_then(|account| account.parked.replace(park))
108            && previous.service != service
109        {
110            self.discard(&previous.service);
111        }
112    }
113
114    /// Stop holding `service` and list it for deletion: it has been installed, or it copies a
115    /// login that is still signed in.
116    pub fn discard(&mut self, service: &str) {
117        for account in &mut self.accounts {
118            account.parked.take_if(|p| p.service == service);
119        }
120        if !self.discarded.iter().any(|listed| listed == service) {
121            self.discarded.push(service.to_string());
122        }
123    }
124
125    pub fn references(&self, service: &str) -> bool {
126        self.accounts
127            .iter()
128            .any(|a| a.parked.as_ref().is_some_and(|p| p.service == service))
129    }
130
131    pub fn upsert(&mut self, account: Account) {
132        match self.get_mut(&account.label) {
133            Some(existing) => *existing = account,
134            None => self.accounts.push(account),
135        }
136    }
137
138    /// Enroll the account under `from` as `to` instead. Only the label changes: parked
139    /// logins are named by account, not by label.
140    pub fn relabel(&mut self, from: &str, to: &str) -> Result<&Account> {
141        if from != to
142            && let Some(taken) = self.get(to)
143        {
144            return Err(Error::LabelTaken {
145                label: to.to_string(),
146                email: taken.email.clone(),
147            });
148        }
149        if self.active.as_deref() == Some(from) {
150            self.active = Some(to.to_string());
151        }
152        let enrolled = self.labels();
153        let account = self.get_mut(from).ok_or_else(|| Error::AccountUnknown {
154            label: from.to_string(),
155            enrolled,
156        })?;
157        account.label = to.to_string();
158        Ok(account)
159    }
160
161    /// Drop the account, listing its park for deletion.
162    pub fn remove(&mut self, label: &str) -> Option<Account> {
163        let index = self.accounts.iter().position(|a| a.label == label)?;
164        let account = self.accounts.remove(index);
165        if let Some(park) = &account.parked {
166            self.discard(&park.service);
167        }
168        Some(account)
169    }
170}
171
172/// Hashed, so the raw platform identifier never lands in a file pitboard writes.
173pub fn machine_id() -> String {
174    use sha2::{Digest, Sha256};
175    match machine_uid::get() {
176        Ok(raw) => hex::encode(Sha256::digest(raw.as_bytes())),
177        Err(_) => String::from("unknown"),
178    }
179}
180
181fn file(ctx: &Context) -> PathBuf {
182    home::dir(ctx).join("state.json")
183}
184
185pub fn load(ctx: &Context) -> Result<State> {
186    let path = file(ctx);
187    home::check_location(&home::dir(ctx))?;
188    let raw = match std::fs::read_to_string(&path) {
189        Ok(s) => s,
190        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(State::default()),
191        Err(source) => return Err(Error::StateUnreadable { path, source }),
192    };
193    let mut document: serde_json::Value =
194        serde_json::from_str(&raw).map_err(|source| Error::StateCorrupt {
195            path: path.clone(),
196            source,
197        })?;
198    migrate(&mut document, &path)?;
199    let state: State = serde_json::from_value(document).map_err(|source| Error::StateCorrupt {
200        path: path.clone(),
201        source,
202    })?;
203    if state.machine != machine_id() {
204        return Err(Error::StateWrongMachine { path });
205    }
206    let mut state = state;
207    // Which account is in use is a fact about one slot. Read from another, the record says
208    // nothing, and pitboard asks Anthropic who is signed in anyway.
209    let slot = crate::claude::live_service(ctx);
210    if state.slot.is_some() && state.slot.as_deref() != Some(slot.as_str()) {
211        state.active = None;
212    }
213    Ok(state)
214}
215
216/// Brings an older file up to the current format in place.
217///
218/// The command line and the app carry their own copy of this crate and update by different
219/// routes, so on one machine an older pitboard will meet a file a newer one wrote. Reading
220/// forwards is what this is for; reading backwards is not possible, and says so.
221fn migrate(document: &mut serde_json::Value, path: &std::path::Path) -> Result<()> {
222    // Each future bump adds an arm that rewrites the document and falls through to the
223    // next, so a file two versions behind is brought all the way forward in one read.
224    let found = document
225        .get("schema")
226        .and_then(serde_json::Value::as_u64)
227        .unwrap_or_default() as u32;
228    match found {
229        SCHEMA => Ok(()),
230        // Nothing released wrote 1 or 2: the schema reached 3 before the first release.
231        0..SCHEMA => Err(Error::StateVersionUnknown {
232            path: path.to_path_buf(),
233            found,
234        }),
235        _ => Err(Error::StateFromNewerVersion {
236            path: path.to_path_buf(),
237            found,
238            expected: SCHEMA,
239        }),
240    }
241}
242
243pub fn save(ctx: &Context, state: &State) -> Result<()> {
244    home::check_location(&home::dir(ctx))?;
245    let mut state = state.clone();
246    state.slot = Some(crate::claude::live_service(ctx));
247    let state = &state;
248    let path = file(ctx);
249    let write = |source| Error::StateWriteFailed {
250        path: path.clone(),
251        source,
252    };
253    home::ensure(ctx).map_err(write)?;
254    let body = serde_json::to_string_pretty(state).expect("State is always serialisable");
255    atomic::write(&path, body.as_bytes(), atomic::Perms::Secret).map_err(write)
256}
257
258#[cfg(test)]
259mod tests {
260    /// CLAUDE_CONFIG_DIR picks which keychain item is the live one, and one state file
261    /// serves every slot on a machine. A record of what was switched to in one slot says
262    /// nothing about another, so it is not carried over.
263    #[test]
264    fn what_was_active_in_another_slot_is_not_claimed_here() {
265        let home = std::env::temp_dir().join(format!("pitboard-slots-{}", std::process::id()));
266        let _ = std::fs::remove_dir_all(&home);
267        let here = Context::new(home.clone()).with_pitboard_home(home.clone());
268        let elsewhere = here
269            .clone()
270            .with_claude_config_dir("/somewhere/else".into());
271
272        let mut state = State::default();
273        state.accounts.push(Account {
274            label: "work".into(),
275            account_uuid: "acc".into(),
276            email: "a@b.c".into(),
277            organization_uuid: "org".into(),
278            oauth_account: serde_json::json!({}),
279            parked: None,
280        });
281        state.active = Some("work".into());
282        save(&here, &state).expect("saved");
283
284        assert_eq!(load(&here).unwrap().active.as_deref(), Some("work"));
285        assert_eq!(
286            load(&elsewhere).unwrap().active,
287            None,
288            "another slot's record of what is in use is not this slot's"
289        );
290        let _ = std::fs::remove_dir_all(&home);
291    }
292
293    /// A file written by the version in people's hands today. The command line and the app
294    /// update separately, so a file one of them wrote has to keep loading in the other.
295    #[test]
296    fn the_format_shipped_in_0_1_x_still_loads() {
297        let written = serde_json::json!({
298            "schema": 3,
299            "machine": machine_id(),
300            "accounts": [{
301                "label": "work",
302                "account_uuid": "acc-1",
303                "email": "a@b.c",
304                "organization_uuid": "org-1",
305                "oauth_account": {"emailAddress": "a@b.c"},
306                "parked": {
307                    "service": "pitboard-park-acc-1-1789935600123",
308                    "parked_at": 1_789_935_600,
309                    "refresh_fingerprint": "abcd",
310                    "access_expires_at": 1_789_999_999,
311                    "refresh_expires_at": 1_792_000_000
312                }
313            }],
314            "active": "work",
315            "discarded": []
316        });
317        let mut document = written.clone();
318        migrate(&mut document, std::path::Path::new("/tmp/state.json")).expect("still current");
319        let state: State = serde_json::from_value(document).expect("still parses");
320        assert_eq!(state.get("work").unwrap().email, "a@b.c");
321        assert_eq!(state.active.as_deref(), Some("work"));
322    }
323
324    /// The other direction cannot work, and the message has to say which half to upgrade.
325    #[test]
326    fn a_file_from_a_newer_pitboard_says_so() {
327        let mut document = serde_json::json!({"schema": SCHEMA + 1});
328        let err = migrate(&mut document, std::path::Path::new("/tmp/state.json")).unwrap_err();
329        assert_eq!(err.code(), "state_from_newer_version");
330        assert!(err.to_string().contains("upgrade whichever"), "{err}");
331    }
332
333    use super::*;
334
335    #[test]
336    fn machine_id_is_stable_and_real() {
337        let a = machine_id();
338        assert_eq!(a, machine_id());
339        assert_eq!(
340            a.len(),
341            64,
342            "expected a sha256 of the platform id, got {a:?}"
343        );
344        assert_ne!(
345            a, "unknown",
346            "this platform should report a stable machine id"
347        );
348    }
349
350    fn park(service: &str) -> Park {
351        Park {
352            service: service.into(),
353            parked_at: 100,
354            refresh_fingerprint: "f".into(),
355            access_expires_at: Some(200),
356            refresh_expires_at: Some(300),
357        }
358    }
359
360    fn account(label: &str, parked: Option<Park>) -> Account {
361        Account {
362            label: label.into(),
363            account_uuid: format!("{label}-uuid"),
364            email: format!("{label}@example.com"),
365            organization_uuid: "o".into(),
366            oauth_account: serde_json::json!({}),
367            parked,
368        }
369    }
370
371    #[test]
372    fn a_new_park_discards_the_one_it_replaces() {
373        let mut s = State::default();
374        s.upsert(account("work", Some(park("old"))));
375        s.park("work", park("new"));
376        assert_eq!(
377            s.get("work").unwrap().parked.as_ref().unwrap().service,
378            "new"
379        );
380        assert_eq!(s.discarded, ["old"]);
381        assert!(!s.references("old"));
382    }
383
384    #[test]
385    fn discarding_releases_whichever_account_held_it_and_lists_it_once() {
386        let mut s = State::default();
387        s.upsert(account("work", Some(park("current"))));
388        s.discard("something-else");
389        assert!(s.get("work").unwrap().parked.is_some());
390        s.discard("current");
391        s.discard("current");
392        assert!(s.get("work").unwrap().parked.is_none());
393        assert_eq!(s.discarded, ["something-else", "current"]);
394    }
395
396    #[test]
397    fn relabelling_keeps_the_account_its_park_and_whether_it_is_active() {
398        let mut s = State::default();
399        s.upsert(account("wrong", Some(park("p"))));
400        s.upsert(account("other", None));
401        s.active = Some("wrong".into());
402
403        assert_eq!(
404            s.relabel("wrong", "right").unwrap().email,
405            "wrong@example.com"
406        );
407        assert!(s.get("wrong").is_none());
408        let renamed = s.get("right").unwrap();
409        assert_eq!(renamed.account_uuid, "wrong-uuid");
410        assert_eq!(renamed.parked.as_ref().unwrap().service, "p");
411        assert_eq!(s.active.as_deref(), Some("right"));
412        assert!(s.discarded.is_empty(), "nothing is deleted by a rename");
413    }
414
415    #[test]
416    fn relabelling_refuses_a_taken_label_and_an_unknown_one() {
417        let mut s = State::default();
418        s.upsert(account("a", None));
419        s.upsert(account("b", None));
420        s.active = Some("a".into());
421        assert!(matches!(s.relabel("a", "b"), Err(Error::LabelTaken { .. })));
422        assert!(matches!(
423            s.relabel("nobody", "c"),
424            Err(Error::AccountUnknown { .. })
425        ));
426        assert_eq!(
427            s.active.as_deref(),
428            Some("a"),
429            "a refused rename changes nothing"
430        );
431        assert!(s.relabel("a", "a").is_ok());
432    }
433
434    #[test]
435    fn removing_an_account_lists_its_park_for_deletion() {
436        let mut s = State::default();
437        s.upsert(account("work", Some(park("p"))));
438        assert_eq!(s.remove("work").unwrap().label, "work");
439        assert!(s.accounts.is_empty());
440        assert_eq!(s.discarded, ["p"]);
441    }
442
443    #[test]
444    fn a_park_is_restorable_until_its_login_expires() {
445        let p = park("p");
446        assert!(p.askable_at(199) && !p.askable_at(200));
447        assert!(p.restorable_at(299) && !p.restorable_at(300));
448        let unknown = Park {
449            access_expires_at: None,
450            refresh_expires_at: None,
451            ..park("p")
452        };
453        assert!(
454            unknown.restorable_at(i64::MAX),
455            "no expiry recorded is not expired"
456        );
457    }
458
459    #[test]
460    fn accounts_are_replaced_by_label_not_duplicated() {
461        let mut s = State::default();
462        s.upsert(account("work", None));
463        s.upsert(Account {
464            email: "d@e.f".into(),
465            ..account("work", None)
466        });
467        assert_eq!(s.accounts.len(), 1);
468        assert_eq!(s.get("work").unwrap().email, "d@e.f");
469    }
470}