Skip to main content

pitboard_core/
schedule.rs

1//! Keeping parked logins alive without anybody running a command.
2//!
3//! A refresh token has a finite life, and the only two things that renew one are somebody
4//! typing `pitboard` and the menu bar app's poll. So the tool is safe for a macOS user who
5//! installed the app and leaves it running, and quietly unsafe for everyone else: the
6//! whole of Linux, and any macOS user on the command line alone. Go away for the refresh
7//! window, come back, and every parked login is dead and each account needs a browser
8//! sign-in, which is precisely the cost Pitboard exists to spare people.
9//!
10//! This is opt-in, and stays opt-in. A background process that talks to Anthropic on a
11//! schedule is the shape most likely to be read as automation, so it is something a person
12//! turns on knowing what it is, and what it does is written where they can read it: it
13//! renews the owner's own parked logins and nothing else. It never switches, never asks for
14//! usage, and makes no request other than the token exchange.
15//!
16//! What it installs is the system's own scheduler, never a homemade daemon: a LaunchAgent
17//! on macOS, which runs inside the login session so the keychain is unlocked, and a systemd
18//! user timer on Linux, which is that system's answer. How each is asked is
19//! [`crate::host::Scheduler`]'s; what is decided here is which Pitboard it runs, how often,
20//! and whether it is this home's to change. Where there is no scheduler, it says so rather
21//! than inventing one.
22
23use crate::context::Context;
24use crate::error::{Error, Result};
25use crate::host::Scheduler;
26use crate::service::Permit;
27use std::path::{Path, PathBuf};
28
29/// Once a day. A refresh token's life is measured in weeks and Pitboard starts renewing
30/// three days out, so a daily check has three chances to catch each one, and a machine
31/// that was asleep for one of them still has two.
32pub(crate) const EVERY_SECONDS: u32 = 86_400;
33
34/// Whether the schedule is installed, and what it is.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Installed {
37    /// Installed, at this path.
38    Yes {
39        path: PathBuf,
40        every_seconds: u32,
41    },
42    No,
43    /// This machine has no scheduler Pitboard knows how to ask.
44    Unsupported,
45}
46
47fn scheduler(ctx: &Context) -> Option<&dyn Scheduler> {
48    ctx.host().scheduler()
49}
50
51/// Where the schedule lives on this machine, or `None` where there is no scheduler.
52pub fn path(ctx: &Context) -> Option<PathBuf> {
53    scheduler(ctx).map(|s| s.location(ctx))
54}
55
56pub fn status(ctx: &Context) -> Installed {
57    match scheduler(ctx) {
58        None => Installed::Unsupported,
59        Some(s) if s.installed(ctx) => Installed::Yes {
60            path: s.location(ctx),
61            every_seconds: EVERY_SECONDS,
62        },
63        Some(_) => Installed::No,
64    }
65}
66
67/// The arguments the schedule's job runs Pitboard with where what the system's scheduler
68/// says of the job it started is not to be relied on: `pitboard renew --scheduled`. systemd
69/// passes a service a timer started `$TRIGGER_UNIT` only from version 252, and
70/// systemd.exec(5) calls it best effort, lossy and not to be relied on; a service started
71/// by hand gets none. So the unit's `ExecStart` carries the marker, and the command line,
72/// started with it, says so through [`Context::started_by_the_schedule`]. launchd names its
73/// job in `XPC_SERVICE_NAME`, so the LaunchAgent runs plain `renew`.
74pub const SCHEDULED_RUN: [&str; 2] = ["renew", "--scheduled"];
75
76/// Whether the schedule is this context's to look after.
77///
78/// The schedule always renews the default home, `~/.pitboard`
79/// ([`crate::host::default_pitboard_home`]), whatever `PITBOARD_HOME` its job is given
80/// ([`for_its_run`]). A Pitboard pointed at another home has none of its own: the one there
81/// is belongs to the default home, and [`install`] refuses to put one there from it.
82///
83/// The two are compared as paths, as written, so `~/.pitboard/` is the default home, while
84/// a link to it, or the same directory spelled in another case on a file system that
85/// ignores case, is another directory.
86pub(crate) fn serves(ctx: &Context) -> bool {
87    crate::home::dir(ctx) == crate::host::default_pitboard_home(ctx.home())
88}
89
90/// Whether this process is a run the schedule itself started: as the system's scheduler
91/// says, where it says, or as the marker its job is started with says
92/// ([`SCHEDULED_RUN`]).
93pub(crate) fn started_this_run(ctx: &Context) -> bool {
94    ctx.marked_as_scheduled()
95        || scheduler(ctx).is_some_and(|s| s.started_this_process(ctx.scheduled_job()))
96}
97
98/// The context a renewal runs in: where the schedule started this run, the default home's,
99/// whatever `PITBOARD_HOME` says, and otherwise `ctx` as it is.
100///
101/// The schedule belongs to the default home alone ([`serves`]), whichever home installed
102/// it. Its job is never written with `PITBOARD_HOME`, but the system can still hand it one:
103/// `launchctl setenv` gives a variable to every job launchd starts, and a systemd user
104/// environment, from `systemctl --user set-environment` or `environment.d`, to every unit.
105/// A run that followed it renewed another home's parked logins and left the default home's
106/// to run out.
107///
108/// In a build for tests, a run the schedule started whose home is this account's own is
109/// stopped here, before it reads anything: it would renew the real `~/.pitboard`, and a
110/// test gives a run of the schedule a home of its own.
111pub(crate) fn for_its_run(ctx: &Context) -> Context {
112    if !started_this_run(ctx) {
113        return ctx.clone();
114    }
115    #[cfg(any(test, feature = "test-support"))]
116    assert!(
117        !crate::host::user::is_the_accounts_own_home(ctx.home()),
118        "a build for tests ran as the daily renewal schedule with this account's own home, \
119         where it would renew the real Pitboard directory: give it a HOME of its own"
120    );
121    ctx.clone()
122        .with_pitboard_home(crate::host::default_pitboard_home(ctx.home()))
123}
124
125/// The Pitboard the schedule should run: the one the context names, or this one, by the
126/// path it was started by.
127fn program(ctx: &Context) -> Result<PathBuf> {
128    if let Some(program) = ctx.schedule_program() {
129        return lasting(program).map(Path::to_path_buf);
130    }
131    crate::host::current_program().map_err(|source| Error::HomeUnwritable {
132        path: PathBuf::from("the running Pitboard"),
133        source,
134    })
135}
136
137/// `program`, where it will still be there when the scheduler runs it. A scheduler starts a
138/// program that is gone without telling anyone, every day, so a path that leads nowhere is
139/// refused rather than written down.
140///
141/// A path inside the copy macOS makes of an app opened where it was downloaded leads
142/// somewhere while that app runs and nowhere once it quits, so it is refused as well.
143fn lasting(program: &Path) -> Result<&Path> {
144    if in_a_temporary_copy(program) {
145        return Err(Error::ScheduleProgramTemporary {
146            path: program.to_path_buf(),
147        });
148    }
149    if !program.is_file() {
150        return Err(Error::ScheduleProgramMissing {
151            path: program.to_path_buf(),
152        });
153    }
154    Ok(program)
155}
156
157/// Whether `program` is inside the copy macOS makes of an app opened where it was
158/// downloaded, which is there while the app runs and gone once it quits: a path through a
159/// directory named `AppTranslocation`. Neither a schedule nor a link can rely on one, so an
160/// app asks this of the command line inside it, by the rule a schedule is refused by.
161pub fn in_a_temporary_copy(program: &Path) -> bool {
162    program
163        .components()
164        .any(|part| part.as_os_str() == "AppTranslocation")
165}
166
167/// The Pitboard the installed schedule runs, read back from what `install` wrote. `None`
168/// where nothing is installed, and where what is there does not name one the way `install`
169/// writes it.
170pub fn installed_program(ctx: &Context) -> Option<PathBuf> {
171    scheduler(ctx)?.program(ctx)
172}
173
174/// What the schedule's job is given of the installing Pitboard's environment, since the
175/// system starts it with its own and not the person's shell: `PITBOARD_NO_ARGV` where this
176/// context refuses the argument line, and `proxy`, the proxy variables, each by the name and
177/// with the value it was set with, so that its runs read the proxy the way those that set it
178/// did ([`crate::proxy`]).
179fn environment(
180    ctx: &Context,
181    proxy: impl IntoIterator<Item = (String, String)>,
182) -> Vec<(String, String)> {
183    let refusing = (!ctx.argv_fallback()).then(|| ("PITBOARD_NO_ARGV".into(), "1".into()));
184    refusing.into_iter().chain(proxy).collect()
185}
186
187/// The proxy variables the installed schedule's job is given, as its file says, in the
188/// order written. Empty where nothing is installed.
189fn installed_proxy_variables(ctx: &Context) -> Vec<(String, String)> {
190    let Some(scheduler) = scheduler(ctx) else {
191        return Vec::new();
192    };
193    scheduler
194        .environment(ctx)
195        .into_iter()
196        .filter(|(name, _)| {
197            crate::proxy::NAMING
198                .iter()
199                .chain(&crate::proxy::EXEMPTING)
200                .any(|proxy| proxy == name)
201        })
202        .collect()
203}
204
205/// What the installed schedule's runs make of the proxy variables its file gives them, read
206/// as this run's are. `None` where nothing is installed.
207pub(crate) fn installed_proxies(ctx: &Context) -> Option<crate::proxy::Proxies> {
208    if !scheduler(ctx)?.installed(ctx) {
209        return None;
210    }
211    let given: crate::context::Environment = installed_proxy_variables(ctx).into_iter().collect();
212    Some(crate::proxy::Proxies::read(&given))
213}
214
215/// Install it, and ask the system to start it. Returns where it went.
216///
217/// Its job is given this context's proxy variables, which a reinstall takes again, and
218/// `PITBOARD_NO_ARGV` where this context has it ([`environment`]).
219///
220/// Refused with [`Error::ScheduleNotDefaultHome`] where this context is not the default
221/// home's ([`serves`]): the schedule renews `~/.pitboard` alone, so installing it while
222/// `PITBOARD_HOME` names another directory would leave that directory's parked logins to
223/// run out while seeming to keep them alive. `uninstall` and `repair` are not refused for
224/// it, so a schedule can always be taken away.
225pub fn install(ctx: &Context, permit: Permit) -> Result<PathBuf> {
226    if !serves(ctx) {
227        return Err(Error::ScheduleNotDefaultHome {
228            home: crate::home::dir(ctx),
229            default: crate::host::default_pitboard_home(ctx.home()),
230        });
231    }
232    let proxy = ctx
233        .proxy
234        .variables()
235        .iter()
236        .map(|(name, value)| ((*name).to_string(), value.clone()));
237    let path = put(ctx, permit, &program(ctx)?, &environment(ctx, proxy))?;
238    crate::audit::record(ctx, permit, "schedule", "install", "ok");
239    Ok(path)
240}
241
242/// Point a schedule that runs an app's own program at the command line the context names.
243/// `true` when it did.
244///
245/// An app up to 0.3.0 scheduled itself, and that app renews nothing when started with
246/// `renew`: launchd started a second menu bar app every day instead. The app calls this
247/// when it starts, so nothing changes unless the schedule is such a one and belongs to this
248/// home, and the context names a command line that will still be there when the scheduler
249/// runs it.
250///
251/// Nothing changes from inside the schedule's own run either: launchd stops a job's process
252/// when it unloads the job, which a repair does before loading it again, so nothing would be
253/// left to load it back.
254///
255/// The job is given the proxy variables its file gives, never the app's own: an app is
256/// started with the system's environment, which names no proxy a shell set, and a repair is
257/// no reason to change where the schedule's requests go. An app of 0.3.0 or earlier, whose
258/// schedules alone are repaired, wrote none.
259pub fn repair(ctx: &Context, permit: Permit) -> Result<bool> {
260    if scheduler(ctx).is_none() || started_this_run(ctx) {
261        return Ok(false);
262    }
263    let Some(named) = ctx.schedule_program() else {
264        return Ok(false);
265    };
266    if !serves(ctx)
267        || lasting(named).is_err()
268        || !installed_program(ctx).is_some_and(|program| an_apps_own_program(&program))
269    {
270        return Ok(false);
271    }
272    let kept = installed_proxy_variables(ctx);
273    let repaired = put(ctx, permit, named, &environment(ctx, kept));
274    crate::audit::record(
275        ctx,
276        permit,
277        "schedule",
278        "repair",
279        match &repaired {
280            Ok(_) => "ok",
281            Err(e) => e.code(),
282        },
283    );
284    repaired.map(|_| true)
285}
286
287/// Whether `program` is the one an app bundle starts, `Contents/MacOS/<name>`, where no
288/// command line is ever kept.
289pub(crate) fn an_apps_own_program(program: &Path) -> bool {
290    let mut dirs = program
291        .ancestors()
292        .skip(1)
293        .map(|dir| dir.file_name().and_then(|n| n.to_str()));
294    dirs.next() == Some(Some("MacOS")) && dirs.next() == Some(Some("Contents"))
295}
296
297/// Schedule `program`, given `environment`, and ask the system to start it.
298fn put(
299    ctx: &Context,
300    permit: Permit,
301    program: &Path,
302    environment: &[(String, String)],
303) -> Result<PathBuf> {
304    let Some(scheduler) = scheduler(ctx) else {
305        return Err(Error::ScheduleUnsupported);
306    };
307    scheduler.put(ctx, permit, program, environment)?;
308    Ok(scheduler.location(ctx))
309}
310
311/// Take it away. `false` when there was nothing installed.
312pub fn uninstall(ctx: &Context, permit: Permit) -> Result<bool> {
313    let Some(scheduler) = scheduler(ctx) else {
314        return Err(Error::ScheduleUnsupported);
315    };
316    if !scheduler.remove(ctx, permit)? {
317        return Ok(false);
318    }
319    crate::audit::record(ctx, permit, "schedule", "uninstall", "ok");
320    Ok(true)
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::host::memory::MemoryHost;
327    use std::sync::Arc;
328
329    /// A home of the test's own, gone when the test is, on a machine whose scheduler asks
330    /// nobody: its files are written in that home, and launchd or systemd is never told.
331    struct Scratch(PathBuf, Arc<MemoryHost>);
332
333    impl Scratch {
334        fn new(name: &str) -> Scratch {
335            let root = std::env::temp_dir().join(format!(
336                "pitboard-schedule-{name}-{}-{:?}",
337                std::process::id(),
338                std::thread::current().id()
339            ));
340            let _ = std::fs::remove_dir_all(&root);
341            std::fs::create_dir_all(&root).expect("a scratch home");
342            Scratch(root, MemoryHost::new())
343        }
344
345        fn ctx(&self) -> Context {
346            Context::new(self.0.clone()).with_memory_stores(Arc::clone(&self.1))
347        }
348
349        /// A context read from an environment whose home is this one and that sets `vars`
350        /// besides, as the command line's is read, on the same machine in memory.
351        fn read(&self, vars: &[(&str, &str)]) -> Context {
352            let home = self.0.to_string_lossy().into_owned();
353            let env: crate::context::Environment = [("HOME", home.as_str())]
354                .iter()
355                .chain(vars)
356                .copied()
357                .collect();
358            Context::for_command_line(&env).with_memory_stores(Arc::clone(&self.1))
359        }
360    }
361
362    /// The variables the installed schedule's job is given, as its file says.
363    fn given(ctx: &Context) -> Vec<(String, String)> {
364        scheduler(ctx).expect("a scheduler").environment(ctx)
365    }
366
367    fn pairs(given: &[(&str, &str)]) -> Vec<(String, String)> {
368        given
369            .iter()
370            .map(|(name, value)| ((*name).to_string(), (*value).to_string()))
371            .collect()
372    }
373
374    impl Drop for Scratch {
375        fn drop(&mut self) {
376            let _ = std::fs::remove_dir_all(&self.0);
377        }
378    }
379
380    /// Put a program at `path`, for a schedule to name.
381    fn a_program_at(path: &Path) {
382        std::fs::create_dir_all(path.parent().expect("a directory")).expect("its directory");
383        std::fs::write(path, "").expect("a program");
384    }
385
386    /// A schedule the scheduler will not start leaves nothing behind saying renewal is on.
387    #[test]
388    fn a_schedule_the_scheduler_will_not_start_is_not_left_on_disk() {
389        let home = Scratch::new("refused");
390        let program = home.0.join("bin/pitboard");
391        a_program_at(&program);
392        let ctx = home.ctx().with_schedule_program(program);
393
394        home.1.refuse_next_start();
395        let refused = install(&ctx, Permit::for_a_test()).expect_err("refused");
396
397        assert_eq!(refused.code(), "schedule_refused");
398        assert_eq!(status(&ctx), Installed::No);
399        assert_eq!(installed_program(&ctx), None);
400    }
401
402    /// A repair the scheduler will not start leaves the schedule that was there, which is
403    /// what the app and doctor then go on reporting.
404    #[test]
405    fn a_repair_the_scheduler_will_not_start_leaves_the_schedule_as_it_was() {
406        let home = Scratch::new("repair-refused");
407        std::fs::create_dir_all(home.0.join(".pitboard")).expect("a Pitboard home");
408        let app = home
409            .0
410            .join("Applications/Pitboard.app/Contents/MacOS/Pitboard");
411        let bundled = home
412            .0
413            .join("Applications/Pitboard.app/Contents/Helpers/pitboard");
414        a_program_at(&app);
415        a_program_at(&bundled);
416        let ctx = home.ctx();
417        install(
418            &ctx.clone().with_schedule_program(app.clone()),
419            Permit::for_a_test(),
420        )
421        .expect("0.3.0's schedule");
422
423        home.1.refuse_next_start();
424        let refused = repair(
425            &ctx.clone().with_schedule_program(bundled),
426            Permit::for_a_test(),
427        )
428        .expect_err("refused");
429
430        assert_eq!(refused.code(), "schedule_refused");
431        assert!(matches!(status(&ctx), Installed::Yes { .. }));
432        assert_eq!(installed_program(&ctx), Some(app));
433    }
434
435    #[test]
436    fn nothing_is_installed_on_a_machine_where_nothing_was_installed() {
437        let home = Scratch::new("none");
438        let ctx = home.ctx();
439        assert_eq!(status(&ctx), Installed::No);
440        assert_eq!(installed_program(&ctx), None);
441    }
442
443    /// A machine with no scheduler Pitboard knows says so, and writes nothing.
444    #[test]
445    fn a_machine_with_no_scheduler_says_so() {
446        let home = Scratch::new("unsupported");
447        home.1.without_a_scheduler();
448        let program = home.0.join("bin/pitboard");
449        a_program_at(&program);
450        let ctx = home.ctx().with_schedule_program(program);
451        assert_eq!(status(&ctx), Installed::Unsupported);
452        assert_eq!(path(&ctx), None);
453        assert_eq!(
454            install(&ctx, Permit::for_a_test())
455                .expect_err("nowhere")
456                .code(),
457            "schedule_unsupported"
458        );
459        assert_eq!(
460            uninstall(&ctx, Permit::for_a_test())
461                .expect_err("nowhere")
462                .code(),
463            "schedule_unsupported"
464        );
465        assert!(!repair(&ctx, Permit::for_a_test()).expect("nothing to do"));
466    }
467
468    #[test]
469    fn the_schedule_runs_the_pitboard_the_context_names_and_otherwise_this_one() {
470        let home = Scratch::new("program");
471        let ctx = home.ctx();
472        let scheduled = program(&ctx).expect("this program");
473        let running = std::env::current_exe().expect("this test's own program");
474        assert_eq!(
475            std::fs::canonicalize(&scheduled).expect("there"),
476            std::fs::canonicalize(&running).expect("there"),
477            "the command line schedules itself"
478        );
479        let bundled = home
480            .0
481            .join("Applications/Pitboard.app/Contents/Helpers/pitboard");
482        a_program_at(&bundled);
483        assert_eq!(
484            program(&ctx.with_schedule_program(bundled.clone())).expect("the named one"),
485            bundled,
486            "an app schedules the command line it comes with"
487        );
488    }
489
490    /// A Pitboard that is named is written down only where it will still be there when the
491    /// scheduler runs it. macOS runs an app opened where it was downloaded from a temporary
492    /// copy, which is there while the app runs and gone once it quits, so being there now
493    /// is not enough.
494    #[test]
495    fn a_named_pitboard_that_will_not_be_there_is_refused() {
496        let home = Scratch::new("refused");
497        let ctx = home.ctx();
498
499        let missing = home.0.join("Pitboard.app/Contents/Helpers/pitboard");
500        let refused = install(
501            &ctx.clone().with_schedule_program(missing.clone()),
502            Permit::for_a_test(),
503        )
504        .expect_err("nothing there to run");
505        assert_eq!(refused.code(), "schedule_program_missing");
506        assert!(
507            refused.to_string().contains(&missing.display().to_string()),
508            "{refused}"
509        );
510
511        let temporary = home
512            .0
513            .join("AppTranslocation/6A1C/d/Pitboard.app/Contents/Helpers/pitboard");
514        a_program_at(&temporary);
515        let refused = install(
516            &ctx.clone().with_schedule_program(temporary.clone()),
517            Permit::for_a_test(),
518        )
519        .expect_err("a copy that goes away");
520        assert_eq!(refused.code(), "schedule_program_temporary");
521        assert!(
522            refused
523                .to_string()
524                .contains(&temporary.display().to_string())
525                && refused.to_string().contains("Applications folder"),
526            "{refused}"
527        );
528
529        assert_eq!(status(&ctx), Installed::No, "nothing was written");
530    }
531
532    /// A copy macOS runs from a temporary place is told by a directory of the path, as the
533    /// Swift app told it by "/AppTranslocation/" in its command line's path, and by nothing
534    /// that only looks like one.
535    #[test]
536    fn a_temporary_copy_is_told_by_its_path() {
537        for (path, temporary) in [
538            (
539                "/private/var/folders/xy/abc/T/AppTranslocation/0A1B2C/d/Pitboard.app/Contents/\
540                 Helpers/pitboard",
541                true,
542            ),
543            (
544                "/Applications/Pitboard.app/Contents/Helpers/pitboard",
545                false,
546            ),
547            (
548                "/Users/x/MyAppTranslocation/Pitboard.app/Contents/Helpers/pitboard",
549                false,
550            ),
551            (
552                "/Users/x/AppTranslocations/Pitboard.app/Contents/Helpers/pitboard",
553                false,
554            ),
555        ] {
556            assert_eq!(in_a_temporary_copy(Path::new(path)), temporary, "{path}");
557        }
558    }
559
560    /// What `pitboard doctor` reads back is what was written, including a path the
561    /// scheduler's format has to escape.
562    #[test]
563    fn an_installed_schedule_says_which_pitboard_it_runs() {
564        let home = Scratch::new("installed");
565        let bundled = home
566            .0
567            .join("Tools&Apps/Pitboard.app/Contents/Helpers/pitboard");
568        a_program_at(&bundled);
569        let ctx = home.ctx().with_schedule_program(bundled.clone());
570
571        install(&ctx, Permit::for_a_test()).expect("installed");
572        assert!(matches!(status(&ctx), Installed::Yes { .. }));
573        assert_eq!(installed_program(&ctx), Some(bundled));
574
575        assert!(uninstall(&ctx, Permit::for_a_test()).expect("taken away"));
576        assert_eq!(status(&ctx), Installed::No);
577        assert_eq!(installed_program(&ctx), None);
578        assert!(!uninstall(&ctx, Permit::for_a_test()).expect("nothing there"));
579    }
580
581    /// An app up to 0.3.0 scheduled itself, and launchd has started a second menu bar app
582    /// every day since. An app that names the command line it comes with puts that in its
583    /// place, and every other schedule is left as it is.
584    #[test]
585    fn a_schedule_that_runs_an_app_is_pointed_at_its_command_line() {
586        let home = Scratch::new("repair");
587        std::fs::create_dir_all(home.0.join(".pitboard")).expect("a Pitboard home");
588        let app = home
589            .0
590            .join("Applications/Pitboard.app/Contents/MacOS/Pitboard");
591        let bundled = home
592            .0
593            .join("Applications/Pitboard.app/Contents/Helpers/pitboard");
594        a_program_at(&app);
595        a_program_at(&bundled);
596        let ctx = home.ctx();
597        let the_app = ctx.clone().with_schedule_program(bundled.clone());
598
599        assert!(
600            !repair(&the_app, Permit::for_a_test()).expect("nothing to do"),
601            "nothing installed"
602        );
603
604        install(
605            &ctx.clone().with_schedule_program(app.clone()),
606            Permit::for_a_test(),
607        )
608        .expect("0.3.0's schedule");
609        assert!(
610            !repair(&ctx, Permit::for_a_test()).expect("nothing to do"),
611            "no command line named"
612        );
613        let gone = home.0.join("Old.app/Contents/Helpers/pitboard");
614        assert!(
615            !repair(
616                &ctx.clone().with_schedule_program(gone),
617                Permit::for_a_test()
618            )
619            .expect("nothing to do"),
620            "a command line that is not there"
621        );
622        assert!(
623            !repair(
624                &the_app.clone().with_pitboard_home(home.0.join("elsewhere")),
625                Permit::for_a_test()
626            )
627            .expect("nothing to do"),
628            "a schedule another home's Pitboard looks after"
629        );
630        assert_eq!(installed_program(&ctx), Some(app.clone()));
631
632        assert!(repair(&the_app, Permit::for_a_test()).expect("repaired"));
633        assert_eq!(installed_program(&ctx), Some(bundled));
634        let logged = crate::audit::read(&ctx, 1);
635        assert_eq!(
636            logged
637                .iter()
638                .map(|e| (e.verb.as_str(), e.subject.as_str(), e.outcome.as_str()))
639                .collect::<Vec<_>>(),
640            [("schedule", "repair", "ok")]
641        );
642
643        assert!(
644            !repair(&the_app, Permit::for_a_test()).expect("nothing to do"),
645            "a schedule that runs a command line already"
646        );
647        install(
648            &ctx.clone().with_schedule_program(app.clone()),
649            Permit::for_a_test(),
650        )
651        .expect("the app again");
652        let temporary = home
653            .0
654            .join("AppTranslocation/6A1C/d/Pitboard.app/Contents/Helpers/pitboard");
655        a_program_at(&temporary);
656        assert!(
657            !repair(
658                &ctx.clone().with_schedule_program(temporary),
659                Permit::for_a_test()
660            )
661            .expect("nothing to do"),
662            "a command line that is gone once the app quits"
663        );
664        assert_eq!(installed_program(&ctx), Some(app));
665    }
666
667    /// A process some other job started is not the schedule's own run, and repairs the way
668    /// one opened by hand does.
669    #[test]
670    fn a_process_another_job_started_still_repairs() {
671        let home = Scratch::new("another-job");
672        std::fs::create_dir_all(home.0.join(".pitboard")).expect("a Pitboard home");
673        let app = home
674            .0
675            .join("Applications/Pitboard.app/Contents/MacOS/Pitboard");
676        let bundled = home
677            .0
678            .join("Applications/Pitboard.app/Contents/Helpers/pitboard");
679        a_program_at(&app);
680        a_program_at(&bundled);
681        let ctx = home.ctx();
682        install(
683            &ctx.clone().with_schedule_program(app),
684            Permit::for_a_test(),
685        )
686        .expect("0.3.0's schedule");
687
688        let opened = ctx
689            .clone()
690            .with_schedule_program(bundled.clone())
691            .with_scheduled_job("application.com.datlechin.pitboard.1.2".into());
692        assert!(repair(&opened, Permit::for_a_test()).expect("repaired"));
693        assert_eq!(installed_program(&ctx), Some(bundled));
694    }
695
696    /// Only what `install` writes is read, and only the way it writes it.
697    #[test]
698    fn a_schedule_pitboard_did_not_write_names_no_program() {
699        let home = Scratch::new("foreign");
700        let ctx = home.ctx();
701        let installed = path(&ctx).expect("a scheduler here");
702        std::fs::create_dir_all(installed.parent().expect("its directory")).expect("made");
703        std::fs::write(&installed, "not what install writes\n").expect("written");
704        if let Some(beside) = installed.parent() {
705            // What systemd's timer runs is its service, beside it.
706            std::fs::write(
707                beside.join("pitboard-renew.service"),
708                "[Service]\nExecStart=/bin/true\n",
709            )
710            .expect("written");
711        }
712
713        assert!(matches!(status(&ctx), Installed::Yes { .. }));
714        assert_eq!(installed_program(&ctx), None);
715    }
716
717    /// The scheduler starts `renew` with the default home, so a Pitboard pointed anywhere
718    /// else leaves the schedule alone.
719    #[test]
720    fn the_schedule_belongs_to_the_default_home_alone() {
721        let ctx = Context::new(PathBuf::from("/home/x"));
722        assert!(serves(&ctx));
723        assert!(serves(
724            &ctx.clone()
725                .with_pitboard_home(PathBuf::from("/home/x/.pitboard/"))
726        ));
727        assert!(!serves(
728            &ctx.with_pitboard_home(PathBuf::from("/tmp/elsewhere"))
729        ));
730    }
731
732    /// The schedule renews the default home alone, so it is installed only from there. With
733    /// `PITBOARD_HOME` naming any other directory, `install` is refused with
734    /// `schedule_not_default_home`, which says what the schedule renews and how to install
735    /// it, and nothing is written or started. `PITBOARD_HOME` naming the default home, however
736    /// it spells it, installs as before. Neither `uninstall` nor `repair` is refused for it:
737    /// a schedule can always be taken away, from whichever home.
738    #[test]
739    fn a_schedule_is_installed_only_where_pitboard_home_is_the_default_home() {
740        let home = Scratch::new("default-home");
741        std::fs::create_dir_all(home.0.join(".pitboard")).expect("a Pitboard home");
742        let program = home.0.join("bin/pitboard");
743        a_program_at(&program);
744        let ctx = home.ctx().with_schedule_program(program);
745        let default = home.0.join(".pitboard");
746        let elsewhere = home.0.join("elsewhere");
747        let another = ctx.clone().with_pitboard_home(elsewhere.clone());
748
749        home.1.refuse_next_start();
750        let refused = install(&another, Permit::for_a_test()).expect_err("another home");
751        assert_eq!(refused.code(), "schedule_not_default_home");
752        assert_eq!(refused.exit_code(), 1);
753        assert_eq!(
754            refused.to_string(),
755            format!(
756                "the renewal schedule renews only the parked logins in {}, not those in {}, \
757                 which PITBOARD_HOME names. Nothing was scheduled. To schedule it for {}, \
758                 unset PITBOARD_HOME and install it again.",
759                default.display(),
760                elsewhere.display(),
761                default.display()
762            )
763        );
764        assert_eq!(status(&ctx), Installed::No, "nothing was written");
765        assert!(crate::audit::read(&another, 10).is_empty(), "nor logged");
766
767        let spelled = PathBuf::from(format!("{}/", default.display()));
768        let refused = install(
769            &ctx.clone().with_pitboard_home(spelled),
770            Permit::for_a_test(),
771        )
772        .expect_err("the default home reaches the scheduler, which refuses this once");
773        assert_eq!(refused.code(), "schedule_refused");
774        install(&ctx, Permit::for_a_test()).expect("installed from the default home");
775        assert!(matches!(status(&ctx), Installed::Yes { .. }));
776
777        assert!(!repair(&another, Permit::for_a_test()).expect("not refused"));
778        assert!(uninstall(&another, Permit::for_a_test()).expect("taken away from another home"));
779        assert_eq!(status(&ctx), Installed::No);
780    }
781
782    /// The system starts the schedule's runs with its own environment, so `install` gives its
783    /// job the proxy variables it was started with, each under the name it was set by, even
784    /// empty, in the order Pitboard reads them, after `PITBOARD_NO_ARGV` where that is set.
785    /// The runs then read the same proxy. A reinstall takes the variables of the Pitboard
786    /// that runs it, none included. What is written is only the person's to read.
787    #[test]
788    fn a_schedule_is_given_the_proxy_variables_of_whoever_installs_it() {
789        let home = Scratch::new("proxy-install");
790        std::fs::create_dir_all(home.0.join(".pitboard")).expect("a Pitboard home");
791        let program = home.0.join("bin/pitboard");
792        a_program_at(&program);
793        let proxied = home
794            .read(&[
795                ("PITBOARD_NO_ARGV", "1"),
796                ("no_proxy", "localhost"),
797                ("HTTPS_PROXY", "http://proxy.example.com:3128"),
798                ("ALL_PROXY", "socks5h://alice:s3cret@127.0.0.1:1080"),
799                ("NO_PROXY", ""),
800                ("PATH", "/usr/bin"),
801            ])
802            .with_schedule_program(program.clone());
803
804        install(&proxied, Permit::for_a_test()).expect("installed");
805        assert_eq!(
806            given(&proxied),
807            pairs(&[
808                ("PITBOARD_NO_ARGV", "1"),
809                ("ALL_PROXY", "socks5h://alice:s3cret@127.0.0.1:1080"),
810                ("HTTPS_PROXY", "http://proxy.example.com:3128"),
811                ("NO_PROXY", ""),
812                ("no_proxy", "localhost"),
813            ])
814        );
815        let its = installed_proxies(&proxied).expect("installed");
816        assert!(its.same_way(&proxied.proxy), "{its:?}");
817        assert_eq!(its.variables(), proxied.proxy.variables());
818        let written = path(&proxied).expect("a scheduler here");
819        assert_eq!(
820            crate::host::fs::access(&written).map(|access| access.described),
821            Some("mode 600".to_string()),
822            "a proxy's password is the person's alone to read"
823        );
824
825        let direct = home.read(&[]).with_schedule_program(program);
826        install(&direct, Permit::for_a_test()).expect("installed again");
827        assert_eq!(given(&direct), Vec::new(), "a reinstall takes this run's");
828        assert!(
829            installed_proxies(&direct)
830                .expect("installed")
831                .same_way(&direct.proxy)
832        );
833    }
834
835    /// A repair, which an app makes when it starts, writes the proxy variables the schedule's
836    /// file gives, and never the app's own. The schedules it repairs are those an app of
837    /// 0.3.0 or earlier wrote, which gave no proxy, so a repair gives none, even where the
838    /// app's environment names one. Where such a file gives a proxy all the same, as one
839    /// edited by hand can, the repair keeps it.
840    #[test]
841    fn a_repair_writes_the_proxy_the_schedules_file_gives_and_never_the_apps() {
842        let home = Scratch::new("proxy-repair");
843        std::fs::create_dir_all(home.0.join(".pitboard")).expect("a Pitboard home");
844        let app = home
845            .0
846            .join("Applications/Pitboard.app/Contents/MacOS/Pitboard");
847        let bundled = home
848            .0
849            .join("Applications/Pitboard.app/Contents/Helpers/pitboard");
850        a_program_at(&app);
851        a_program_at(&bundled);
852        let apps_own = [("HTTPS_PROXY", "http://app.example.com:3128")];
853
854        install(
855            &home.read(&[]).with_schedule_program(app.clone()),
856            Permit::for_a_test(),
857        )
858        .expect("0.3.0's schedule, which gives no proxy");
859        let the_app = home.read(&apps_own).with_schedule_program(bundled.clone());
860        assert!(repair(&the_app, Permit::for_a_test()).expect("repaired"));
861        assert_eq!(installed_program(&the_app), Some(bundled.clone()));
862        assert_eq!(given(&the_app), Vec::new(), "no proxy, not the app's");
863
864        let edited = pairs(&[
865            ("https_proxy", "http://alice:s3cret@proxy.example.com:3128"),
866            ("NO_PROXY", ".anthropic.com"),
867        ]);
868        install(
869            &home
870                .read(&[
871                    ("https_proxy", "http://alice:s3cret@proxy.example.com:3128"),
872                    ("NO_PROXY", ".anthropic.com"),
873                ])
874                .with_schedule_program(app),
875            Permit::for_a_test(),
876        )
877        .expect("an app's schedule that gives a proxy, as one edited by hand");
878        assert!(repair(&the_app, Permit::for_a_test()).expect("repaired"));
879        assert_eq!(installed_program(&the_app), Some(bundled));
880        assert_eq!(given(&the_app), edited, "the proxy its file gave");
881    }
882
883    /// A real context's scheduler is never asked from a test: the system's own service
884    /// manager would reach the person's real schedule.
885    #[test]
886    fn a_test_never_reaches_the_systems_own_scheduler() {
887        let root = std::env::temp_dir().join(format!(
888            "pitboard-schedule-real-{}-{:?}",
889            std::process::id(),
890            std::thread::current().id()
891        ));
892        let _ = std::fs::remove_dir_all(&root);
893        let program = root.join("bin/pitboard");
894        a_program_at(&program);
895        let ctx = Context::new(root.clone()).with_schedule_program(program);
896        let refused =
897            install(&ctx, Permit::for_a_test()).expect_err("refused before it reaches the system");
898        assert_eq!(refused.code(), "schedule_refused");
899        assert_eq!(status(&ctx), Installed::No, "and nothing is left written");
900        let _ = std::fs::remove_dir_all(&root);
901    }
902}