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