Skip to main content

pitboard_core/switch/
uninstall.rs

1//! Taking Pitboard off a machine without leaving credentials behind.
2
3use super::{Result, Settled, purge};
4use crate::context::Context;
5use crate::error::Error;
6use crate::service::Permit;
7use crate::state::Account;
8use crate::{home, schedule, state};
9
10/// What was removed, for the report. It may say more in a later release, so it cannot be
11/// built outside this crate.
12#[non_exhaustive]
13pub struct Removed {
14    /// Parked logins deleted from the keychain or the vault.
15    pub parks: usize,
16    /// Parked logins that could not be deleted, which is why the home was kept.
17    pub pending: usize,
18    /// Parked logins left where they are because this Pitboard did not write them:
19    /// `repair` gave them back from a store every Pitboard on the machine shares, so each
20    /// may be another Pitboard's. Always none where the vault is inside Pitboard's own
21    /// directory.
22    pub left: usize,
23    /// Whether ~/.pitboard itself is gone.
24    pub home_removed: bool,
25    /// Whether the daily renewal schedule was taken away. `false` where there was none.
26    pub schedule_removed: bool,
27}
28
29/// Takes away the daily renewal schedule, deletes every parked login this Pitboard wrote,
30/// then removes Pitboard's own directory. Each tool's login is left exactly as it is:
31/// whoever is signed in stays signed in.
32///
33/// The schedule goes first. It runs `pitboard renew` every day, which would make a new
34/// directory once this one is gone, and if it cannot be taken away nothing else has been
35/// touched yet.
36///
37/// The directory is removed last and only when every parked login is gone, because
38/// state.json is the only index of those keychain items. Deleting it first would leave live
39/// refresh tokens on the machine with no way left to name them. A login `repair` gave back
40/// is not this Pitboard's to delete, so it is left, and does not keep the directory.
41pub fn uninstall(settled: Settled) -> Result<Removed> {
42    let Settled {
43        _exclusive,
44        mut state,
45        ctx,
46        permit,
47    } = settled;
48    let schedule_removed = remove_schedule(&ctx, permit)?;
49    let held = state.accounts.iter().filter_map(|a| a.parked.as_ref());
50    let left = held
51        .clone()
52        .filter(|park| state.is_foreign(&park.service))
53        .count();
54    let parks = held.count() - left;
55    for key in state.accounts.iter().map(Account::key).collect::<Vec<_>>() {
56        state.remove(&key);
57    }
58    state.active.clear();
59    state::save(&ctx, permit, &state)?;
60    let pending = purge(&ctx, permit, &mut state);
61    // The sweep in settle has already resolved every outstanding name, so what is left
62    // refers to nothing. The home goes next, and an index of names with no home is noise.
63    if pending == 0 {
64        crate::pending::clear(&ctx, permit);
65    }
66    let home_removed = pending == 0 && remove_home(&ctx, permit);
67    Ok(Removed {
68        parks: parks.saturating_sub(pending),
69        pending,
70        left,
71        home_removed,
72        schedule_removed,
73    })
74}
75
76/// The schedule, where it is this home's. One that renews another home is that home's to
77/// take away, and a machine with no scheduler has nothing to take.
78fn remove_schedule(ctx: &Context, permit: Permit) -> Result<bool> {
79    if !schedule::serves(ctx) {
80        return Ok(false);
81    }
82    match schedule::uninstall(ctx, permit) {
83        Err(Error::ScheduleUnsupported) => Ok(false),
84        removed => removed,
85    }
86}
87
88/// The lock file this run holds lives in here too; on macOS and Linux an open file goes on
89/// existing until the last handle closes, so removing the directory now is safe.
90fn remove_home(ctx: &Context, permit: Permit) -> bool {
91    crate::host::fs::remove_dir_all(permit, &home::dir(ctx)).is_ok()
92}
93
94#[cfg(test)]
95mod tests {
96    use super::super::harness::{codex_machine, machine};
97    use super::super::settle;
98    use super::*;
99
100    #[test]
101    fn uninstalling_takes_the_renewal_schedule_with_it() {
102        for make in [machine, codex_machine] {
103            let m = make("uninstall-schedule");
104            schedule::install(&m.ctx, Permit::for_a_test()).expect("scheduled");
105
106            let removed = uninstall(
107                settle(&m.ctx, Permit::for_a_test(), None)
108                    .expect("nothing to recover")
109                    .0,
110            )
111            .expect("uninstalled");
112
113            assert!(removed.schedule_removed);
114            assert!(removed.home_removed);
115            assert_eq!(schedule::status(&m.ctx), schedule::Installed::No);
116        }
117    }
118
119    /// A schedule that cannot be taken away stops the uninstall before anything else is
120    /// touched. Left running, it would renew logins whose index is gone.
121    #[test]
122    fn a_schedule_that_cannot_be_taken_away_leaves_every_login_where_it_was() {
123        use crate::host::fs::testing;
124        for make in [machine, codex_machine] {
125            let m = make("uninstall-stuck");
126            schedule::install(&m.ctx, Permit::for_a_test()).expect("scheduled");
127            let dir = schedule::path(&m.ctx)
128                .and_then(|p| p.parent().map(std::path::Path::to_path_buf))
129                .expect("where the scheduler keeps it");
130            testing::deny_changes(&dir).expect("read-only");
131            let refused = uninstall(
132                settle(&m.ctx, Permit::for_a_test(), None)
133                    .expect("nothing to recover")
134                    .0,
135            );
136            testing::allow_changes(&dir).expect("changeable again");
137
138            let Err(error) = refused else {
139                panic!("{:?}: uninstalled with the schedule still there", m.which);
140            };
141            assert_eq!(error.code(), "home_unwritable");
142            let state = state::load(&m.ctx).expect("the account list");
143            assert_eq!(state.accounts.len(), 2, "{:?}", m.which);
144            let there = state.get(&m.key("there")).expect("`there` is enrolled");
145            let park = there.parked.as_ref().expect("`there` is still parked");
146            crate::park::load(&m.ctx, &m.key("there"), park).expect("its parked login is kept");
147            assert!(home::dir(&m.ctx).is_dir());
148            assert!(matches!(
149                schedule::status(&m.ctx),
150                schedule::Installed::Yes { .. }
151            ));
152        }
153    }
154
155    #[test]
156    fn uninstalling_where_nothing_is_scheduled_is_not_a_failure() {
157        let m = machine("uninstall-unscheduled");
158        let removed = uninstall(
159            settle(&m.ctx, Permit::for_a_test(), None)
160                .expect("nothing to recover")
161                .0,
162        )
163        .expect("uninstalled");
164        assert!(!removed.schedule_removed);
165        assert!(removed.home_removed);
166    }
167
168    /// launchd and systemd renew the default home, so uninstalling any other one leaves
169    /// the schedule to the Pitboard it serves.
170    #[test]
171    fn uninstalling_another_home_leaves_the_schedule_alone() {
172        let m = machine("uninstall-elsewhere");
173        schedule::install(&m.ctx, Permit::for_a_test()).expect("scheduled");
174        let elsewhere = m
175            .ctx
176            .clone()
177            .with_pitboard_home(m.ctx_home().join("elsewhere"));
178
179        let removed = uninstall(
180            settle(&elsewhere, Permit::for_a_test(), None)
181                .expect("nothing to recover")
182                .0,
183        )
184        .expect("uninstalled");
185
186        assert!(!removed.schedule_removed);
187        assert!(matches!(
188            schedule::status(&m.ctx),
189            schedule::Installed::Yes { .. }
190        ));
191    }
192}