use crate::context::Context;
use crate::error::{Error, Result};
use crate::host::Scheduler;
use crate::service::Permit;
use std::path::{Path, PathBuf};
pub(crate) const EVERY_SECONDS: u32 = 86_400;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Installed {
Yes {
path: PathBuf,
every_seconds: u32,
},
No,
Unsupported,
}
fn scheduler(ctx: &Context) -> Option<&dyn Scheduler> {
ctx.host().scheduler()
}
#[cfg(test)]
pub(crate) fn path(ctx: &Context) -> Option<PathBuf> {
scheduler(ctx).map(|s| s.location(ctx))
}
pub(crate) fn status(ctx: &Context) -> Installed {
match scheduler(ctx) {
None => Installed::Unsupported,
Some(s) if s.installed(ctx) => Installed::Yes {
path: s.location(ctx),
every_seconds: EVERY_SECONDS,
},
Some(_) => Installed::No,
}
}
pub const SCHEDULED_RUN: [&str; 2] = ["renew", "--scheduled"];
pub(crate) fn serves(ctx: &Context) -> bool {
crate::home::dir(ctx) == crate::host::default_pitboard_home(ctx.home())
}
pub(crate) fn started_this_run(ctx: &Context) -> bool {
ctx.marked_as_scheduled()
|| scheduler(ctx).is_some_and(|s| s.started_this_process(ctx.scheduled_job()))
}
pub(crate) fn for_its_run(ctx: &Context) -> Context {
if !started_this_run(ctx) {
return ctx.clone();
}
#[cfg(any(test, feature = "test-support"))]
assert!(
!crate::host::user::is_the_accounts_own_home(ctx.home()),
"a build for tests ran as the daily renewal schedule with this account's own home, \
where it would renew the real Pitboard directory: give it a HOME of its own"
);
ctx.clone()
.with_pitboard_home(crate::host::default_pitboard_home(ctx.home()))
}
fn program(ctx: &Context) -> Result<PathBuf> {
if let Some(program) = ctx.schedule_program() {
return lasting(program).map(Path::to_path_buf);
}
crate::host::current_program().map_err(|source| Error::HomeUnwritable {
path: PathBuf::from("the running Pitboard"),
source,
})
}
fn lasting(program: &Path) -> Result<&Path> {
if in_a_temporary_copy(program) {
return Err(Error::ScheduleProgramTemporary {
path: program.to_path_buf(),
});
}
if !program.is_file() {
return Err(Error::ScheduleProgramMissing {
path: program.to_path_buf(),
});
}
Ok(program)
}
pub fn in_a_temporary_copy(program: &Path) -> bool {
program
.components()
.any(|part| part.as_os_str() == "AppTranslocation")
}
pub(crate) fn installed_program(ctx: &Context) -> Option<PathBuf> {
scheduler(ctx)?.program(ctx)
}
fn environment(
ctx: &Context,
proxy: impl IntoIterator<Item = (String, String)>,
) -> Vec<(String, String)> {
let refusing = (!ctx.argv_fallback()).then(|| ("PITBOARD_NO_ARGV".into(), "1".into()));
refusing.into_iter().chain(proxy).collect()
}
fn installed_proxy_variables(ctx: &Context) -> Vec<(String, String)> {
let Some(scheduler) = scheduler(ctx) else {
return Vec::new();
};
scheduler
.environment(ctx)
.into_iter()
.filter(|(name, _)| {
crate::proxy::NAMING
.iter()
.chain(&crate::proxy::EXEMPTING)
.any(|proxy| proxy == name)
})
.collect()
}
pub(crate) fn installed_proxies(ctx: &Context) -> Option<crate::proxy::Proxies> {
if !scheduler(ctx)?.installed(ctx) {
return None;
}
let given: crate::context::Environment = installed_proxy_variables(ctx).into_iter().collect();
Some(crate::proxy::Proxies::read(&given))
}
pub(crate) fn install(ctx: &Context, permit: Permit) -> Result<PathBuf> {
if !serves(ctx) {
return Err(Error::ScheduleNotDefaultHome {
home: crate::home::dir(ctx),
default: crate::host::default_pitboard_home(ctx.home()),
});
}
let proxy = ctx
.proxy
.variables()
.iter()
.map(|(name, value)| ((*name).to_string(), value.clone()));
let path = put(ctx, permit, &program(ctx)?, &environment(ctx, proxy))?;
crate::audit::record(ctx, permit, "schedule", "install", "ok");
Ok(path)
}
pub(crate) fn repair(ctx: &Context, permit: Permit) -> Result<bool> {
if scheduler(ctx).is_none() || started_this_run(ctx) {
return Ok(false);
}
let Some(named) = ctx.schedule_program() else {
return Ok(false);
};
if !serves(ctx)
|| lasting(named).is_err()
|| !installed_program(ctx).is_some_and(|program| an_apps_own_program(&program))
{
return Ok(false);
}
let kept = installed_proxy_variables(ctx);
let repaired = put(ctx, permit, named, &environment(ctx, kept));
crate::audit::record(
ctx,
permit,
"schedule",
"repair",
match &repaired {
Ok(_) => "ok",
Err(e) => e.code(),
},
);
repaired.map(|_| true)
}
pub(crate) fn an_apps_own_program(program: &Path) -> bool {
let mut dirs = program
.ancestors()
.skip(1)
.map(|dir| dir.file_name().and_then(|n| n.to_str()));
dirs.next() == Some(Some("MacOS")) && dirs.next() == Some(Some("Contents"))
}
fn put(
ctx: &Context,
permit: Permit,
program: &Path,
environment: &[(String, String)],
) -> Result<PathBuf> {
let Some(scheduler) = scheduler(ctx) else {
return Err(Error::ScheduleUnsupported);
};
scheduler.put(ctx, permit, program, environment)?;
Ok(scheduler.location(ctx))
}
pub(crate) fn uninstall(ctx: &Context, permit: Permit) -> Result<bool> {
let Some(scheduler) = scheduler(ctx) else {
return Err(Error::ScheduleUnsupported);
};
if !scheduler.remove(ctx, permit)? {
return Ok(false);
}
crate::audit::record(ctx, permit, "schedule", "uninstall", "ok");
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::memory::MemoryHost;
use std::sync::Arc;
struct Scratch(tempfile::TempDir, Arc<MemoryHost>);
impl Scratch {
fn new() -> Scratch {
Scratch(
tempfile::tempdir().expect("a scratch home"),
MemoryHost::new(),
)
}
fn path(&self) -> &Path {
self.0.path()
}
fn ctx(&self) -> Context {
Context::new(self.path().to_path_buf()).with_memory_stores(Arc::clone(&self.1))
}
fn read(&self, vars: &[(&str, &str)]) -> Context {
let home = self.path().to_string_lossy().into_owned();
let env: crate::context::Environment = [("HOME", home.as_str())]
.iter()
.chain(vars)
.copied()
.collect();
Context::for_command_line(&env).with_memory_stores(Arc::clone(&self.1))
}
}
fn given(ctx: &Context) -> Vec<(String, String)> {
scheduler(ctx).expect("a scheduler").environment(ctx)
}
fn pairs(given: &[(&str, &str)]) -> Vec<(String, String)> {
given
.iter()
.map(|(name, value)| ((*name).to_string(), (*value).to_string()))
.collect()
}
fn a_program_at(path: &Path) {
std::fs::create_dir_all(path.parent().expect("a directory")).expect("its directory");
std::fs::write(path, "").expect("a program");
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn a_schedule_the_scheduler_will_not_start_is_not_left_on_disk() {
let home = Scratch::new();
let program = home.path().join("bin/pitboard");
a_program_at(&program);
let ctx = home.ctx().with_schedule_program(program);
home.1.refuse_next_start();
let refused = install(&ctx, Permit::for_a_test()).expect_err("refused");
assert_eq!(refused.code(), "schedule_refused");
assert_eq!(status(&ctx), Installed::No);
assert_eq!(installed_program(&ctx), None);
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn a_repair_the_scheduler_will_not_start_leaves_the_schedule_as_it_was() {
let home = Scratch::new();
std::fs::create_dir_all(home.path().join(".pitboard")).expect("a Pitboard home");
let app = home
.path()
.join("Applications/Pitboard.app/Contents/MacOS/Pitboard");
let bundled = home
.path()
.join("Applications/Pitboard.app/Contents/Helpers/pitboard");
a_program_at(&app);
a_program_at(&bundled);
let ctx = home.ctx();
install(
&ctx.clone().with_schedule_program(app.clone()),
Permit::for_a_test(),
)
.expect("0.3.0's schedule");
home.1.refuse_next_start();
let refused = repair(
&ctx.clone().with_schedule_program(bundled),
Permit::for_a_test(),
)
.expect_err("refused");
assert_eq!(refused.code(), "schedule_refused");
assert!(matches!(status(&ctx), Installed::Yes { .. }));
assert_eq!(installed_program(&ctx), Some(app));
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn nothing_is_installed_on_a_machine_where_nothing_was_installed() {
let home = Scratch::new();
let ctx = home.ctx();
assert_eq!(status(&ctx), Installed::No);
assert_eq!(installed_program(&ctx), None);
}
#[test]
fn a_machine_with_no_scheduler_says_so() {
let home = Scratch::new();
home.1.without_a_scheduler();
let program = home.path().join("bin/pitboard");
a_program_at(&program);
let ctx = home.ctx().with_schedule_program(program);
assert_eq!(status(&ctx), Installed::Unsupported);
assert_eq!(path(&ctx), None);
assert_eq!(
install(&ctx, Permit::for_a_test())
.expect_err("nowhere")
.code(),
"schedule_unsupported"
);
assert_eq!(
uninstall(&ctx, Permit::for_a_test())
.expect_err("nowhere")
.code(),
"schedule_unsupported"
);
assert!(!repair(&ctx, Permit::for_a_test()).expect("nothing to do"));
}
#[test]
fn the_schedule_runs_the_pitboard_the_context_names_and_otherwise_this_one() {
let home = Scratch::new();
let ctx = home.ctx();
let scheduled = program(&ctx).expect("this program");
let running = std::env::current_exe().expect("this test's own program");
assert_eq!(
std::fs::canonicalize(&scheduled).expect("there"),
std::fs::canonicalize(&running).expect("there"),
"the command line schedules itself"
);
let bundled = home
.path()
.join("Applications/Pitboard.app/Contents/Helpers/pitboard");
a_program_at(&bundled);
assert_eq!(
program(&ctx.with_schedule_program(bundled.clone())).expect("the named one"),
bundled,
"an app schedules the command line it comes with"
);
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn a_named_pitboard_that_will_not_be_there_is_refused() {
let home = Scratch::new();
let ctx = home.ctx();
let missing = home.path().join("Pitboard.app/Contents/Helpers/pitboard");
let refused = install(
&ctx.clone().with_schedule_program(missing.clone()),
Permit::for_a_test(),
)
.expect_err("nothing there to run");
assert_eq!(refused.code(), "schedule_program_missing");
assert!(
refused.to_string().contains(&missing.display().to_string()),
"{refused}"
);
let temporary = home
.path()
.join("AppTranslocation/6A1C/d/Pitboard.app/Contents/Helpers/pitboard");
a_program_at(&temporary);
let refused = install(
&ctx.clone().with_schedule_program(temporary.clone()),
Permit::for_a_test(),
)
.expect_err("a copy that goes away");
assert_eq!(refused.code(), "schedule_program_temporary");
assert!(
refused
.to_string()
.contains(&temporary.display().to_string())
&& refused.to_string().contains("Applications folder"),
"{refused}"
);
assert_eq!(status(&ctx), Installed::No, "nothing was written");
}
#[test]
fn a_temporary_copy_is_told_by_its_path() {
for (path, temporary) in [
(
"/private/var/folders/xy/abc/T/AppTranslocation/0A1B2C/d/Pitboard.app/Contents/\
Helpers/pitboard",
true,
),
(
"/Applications/Pitboard.app/Contents/Helpers/pitboard",
false,
),
(
"/Users/x/MyAppTranslocation/Pitboard.app/Contents/Helpers/pitboard",
false,
),
(
"/Users/x/AppTranslocations/Pitboard.app/Contents/Helpers/pitboard",
false,
),
] {
assert_eq!(in_a_temporary_copy(Path::new(path)), temporary, "{path}");
}
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn an_installed_schedule_says_which_pitboard_it_runs() {
let home = Scratch::new();
let bundled = home
.path()
.join("Tools&Apps/Pitboard.app/Contents/Helpers/pitboard");
a_program_at(&bundled);
let ctx = home.ctx().with_schedule_program(bundled.clone());
install(&ctx, Permit::for_a_test()).expect("installed");
assert!(matches!(status(&ctx), Installed::Yes { .. }));
assert_eq!(installed_program(&ctx), Some(bundled));
assert!(uninstall(&ctx, Permit::for_a_test()).expect("taken away"));
assert_eq!(status(&ctx), Installed::No);
assert_eq!(installed_program(&ctx), None);
assert!(!uninstall(&ctx, Permit::for_a_test()).expect("nothing there"));
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn a_schedule_that_runs_an_app_is_pointed_at_its_command_line() {
let home = Scratch::new();
std::fs::create_dir_all(home.path().join(".pitboard")).expect("a Pitboard home");
let app = home
.path()
.join("Applications/Pitboard.app/Contents/MacOS/Pitboard");
let bundled = home
.path()
.join("Applications/Pitboard.app/Contents/Helpers/pitboard");
a_program_at(&app);
a_program_at(&bundled);
let ctx = home.ctx();
let the_app = ctx.clone().with_schedule_program(bundled.clone());
assert!(
!repair(&the_app, Permit::for_a_test()).expect("nothing to do"),
"nothing installed"
);
install(
&ctx.clone().with_schedule_program(app.clone()),
Permit::for_a_test(),
)
.expect("0.3.0's schedule");
assert!(
!repair(&ctx, Permit::for_a_test()).expect("nothing to do"),
"no command line named"
);
let gone = home.path().join("Old.app/Contents/Helpers/pitboard");
assert!(
!repair(
&ctx.clone().with_schedule_program(gone),
Permit::for_a_test()
)
.expect("nothing to do"),
"a command line that is not there"
);
assert!(
!repair(
&the_app
.clone()
.with_pitboard_home(home.path().join("elsewhere")),
Permit::for_a_test()
)
.expect("nothing to do"),
"a schedule another home's Pitboard looks after"
);
assert_eq!(installed_program(&ctx), Some(app.clone()));
assert!(repair(&the_app, Permit::for_a_test()).expect("repaired"));
assert_eq!(installed_program(&ctx), Some(bundled));
let logged = crate::audit::read(&ctx, 1);
assert_eq!(
logged
.iter()
.map(|e| (e.verb.as_str(), e.subject.as_str(), e.outcome.as_str()))
.collect::<Vec<_>>(),
[("schedule", "repair", "ok")]
);
assert!(
!repair(&the_app, Permit::for_a_test()).expect("nothing to do"),
"a schedule that runs a command line already"
);
install(
&ctx.clone().with_schedule_program(app.clone()),
Permit::for_a_test(),
)
.expect("the app again");
let temporary = home
.path()
.join("AppTranslocation/6A1C/d/Pitboard.app/Contents/Helpers/pitboard");
a_program_at(&temporary);
assert!(
!repair(
&ctx.clone().with_schedule_program(temporary),
Permit::for_a_test()
)
.expect("nothing to do"),
"a command line that is gone once the app quits"
);
assert_eq!(installed_program(&ctx), Some(app));
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn a_process_another_job_started_still_repairs() {
let home = Scratch::new();
std::fs::create_dir_all(home.path().join(".pitboard")).expect("a Pitboard home");
let app = home
.path()
.join("Applications/Pitboard.app/Contents/MacOS/Pitboard");
let bundled = home
.path()
.join("Applications/Pitboard.app/Contents/Helpers/pitboard");
a_program_at(&app);
a_program_at(&bundled);
let ctx = home.ctx();
install(
&ctx.clone().with_schedule_program(app),
Permit::for_a_test(),
)
.expect("0.3.0's schedule");
let opened = ctx
.clone()
.with_schedule_program(bundled.clone())
.with_scheduled_job("application.com.datlechin.pitboard.1.2".into());
assert!(repair(&opened, Permit::for_a_test()).expect("repaired"));
assert_eq!(installed_program(&ctx), Some(bundled));
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn a_schedule_pitboard_did_not_write_names_no_program() {
let home = Scratch::new();
let ctx = home.ctx();
let installed = path(&ctx).expect("a scheduler here");
std::fs::create_dir_all(installed.parent().expect("its directory")).expect("made");
std::fs::write(&installed, "not what install writes\n").expect("written");
if let Some(beside) = installed.parent() {
std::fs::write(
beside.join("pitboard-renew.service"),
"[Service]\nExecStart=/bin/true\n",
)
.expect("written");
}
assert!(matches!(status(&ctx), Installed::Yes { .. }));
assert_eq!(installed_program(&ctx), None);
}
#[test]
#[cfg_attr(windows, ignore = "W14: Pitboard's own folder on Windows")]
fn the_schedule_belongs_to_the_default_home_alone() {
let ctx = Context::new(PathBuf::from("/home/x"));
assert!(serves(&ctx));
assert!(serves(
&ctx.clone()
.with_pitboard_home(PathBuf::from("/home/x/.pitboard/"))
));
assert!(!serves(
&ctx.with_pitboard_home(PathBuf::from("/tmp/elsewhere"))
));
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn a_schedule_is_installed_only_where_pitboard_home_is_the_default_home() {
let home = Scratch::new();
std::fs::create_dir_all(home.path().join(".pitboard")).expect("a Pitboard home");
let program = home.path().join("bin/pitboard");
a_program_at(&program);
let ctx = home.ctx().with_schedule_program(program);
let default = home.path().join(".pitboard");
let elsewhere = home.path().join("elsewhere");
let another = ctx.clone().with_pitboard_home(elsewhere.clone());
home.1.refuse_next_start();
let refused = install(&another, Permit::for_a_test()).expect_err("another home");
assert_eq!(refused.code(), "schedule_not_default_home");
assert_eq!(refused.exit_code(), 1);
assert_eq!(
refused.to_string(),
format!(
"the renewal schedule renews only the parked logins in {}, not those in {}, \
which PITBOARD_HOME names. Nothing was scheduled. To schedule it for {}, \
unset PITBOARD_HOME and install it again.",
default.display(),
elsewhere.display(),
default.display()
)
);
assert_eq!(status(&ctx), Installed::No, "nothing was written");
assert!(crate::audit::read(&another, 10).is_empty(), "nor logged");
let spelled = PathBuf::from(format!("{}/", default.display()));
let refused = install(
&ctx.clone().with_pitboard_home(spelled),
Permit::for_a_test(),
)
.expect_err("the default home reaches the scheduler, which refuses this once");
assert_eq!(refused.code(), "schedule_refused");
install(&ctx, Permit::for_a_test()).expect("installed from the default home");
assert!(matches!(status(&ctx), Installed::Yes { .. }));
assert!(!repair(&another, Permit::for_a_test()).expect("not refused"));
assert!(uninstall(&another, Permit::for_a_test()).expect("taken away from another home"));
assert_eq!(status(&ctx), Installed::No);
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn a_schedule_is_given_the_proxy_variables_of_whoever_installs_it() {
let home = Scratch::new();
std::fs::create_dir_all(home.path().join(".pitboard")).expect("a Pitboard home");
let program = home.path().join("bin/pitboard");
a_program_at(&program);
let proxied = home
.read(&[
("PITBOARD_NO_ARGV", "1"),
("no_proxy", "localhost"),
("HTTPS_PROXY", "http://proxy.example.com:3128"),
("ALL_PROXY", "socks5h://alice:s3cret@127.0.0.1:1080"),
("NO_PROXY", ""),
("PATH", "/usr/bin"),
])
.with_schedule_program(program.clone());
install(&proxied, Permit::for_a_test()).expect("installed");
assert_eq!(
given(&proxied),
pairs(&[
("PITBOARD_NO_ARGV", "1"),
("ALL_PROXY", "socks5h://alice:s3cret@127.0.0.1:1080"),
("HTTPS_PROXY", "http://proxy.example.com:3128"),
("NO_PROXY", ""),
("no_proxy", "localhost"),
])
);
let its = installed_proxies(&proxied).expect("installed");
assert!(its.same_way(&proxied.proxy), "{its:?}");
assert_eq!(its.variables(), proxied.proxy.variables());
let written = path(&proxied).expect("a scheduler here");
assert_eq!(
crate::host::fs::access(&written).map(|access| access.described),
Some("mode 600".to_string()),
"a proxy's password is the person's alone to read"
);
let direct = home.read(&[]).with_schedule_program(program);
install(&direct, Permit::for_a_test()).expect("installed again");
assert_eq!(given(&direct), Vec::new(), "a reinstall takes this run's");
assert!(
installed_proxies(&direct)
.expect("installed")
.same_way(&direct.proxy)
);
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn a_repair_writes_the_proxy_the_schedules_file_gives_and_never_the_apps() {
let home = Scratch::new();
std::fs::create_dir_all(home.path().join(".pitboard")).expect("a Pitboard home");
let app = home
.path()
.join("Applications/Pitboard.app/Contents/MacOS/Pitboard");
let bundled = home
.path()
.join("Applications/Pitboard.app/Contents/Helpers/pitboard");
a_program_at(&app);
a_program_at(&bundled);
let apps_own = [("HTTPS_PROXY", "http://app.example.com:3128")];
install(
&home.read(&[]).with_schedule_program(app.clone()),
Permit::for_a_test(),
)
.expect("0.3.0's schedule, which gives no proxy");
let the_app = home.read(&apps_own).with_schedule_program(bundled.clone());
assert!(repair(&the_app, Permit::for_a_test()).expect("repaired"));
assert_eq!(installed_program(&the_app), Some(bundled.clone()));
assert_eq!(given(&the_app), Vec::new(), "no proxy, not the app's");
let edited = pairs(&[
("https_proxy", "http://alice:s3cret@proxy.example.com:3128"),
("NO_PROXY", ".anthropic.com"),
]);
install(
&home
.read(&[
("https_proxy", "http://alice:s3cret@proxy.example.com:3128"),
("NO_PROXY", ".anthropic.com"),
])
.with_schedule_program(app),
Permit::for_a_test(),
)
.expect("an app's schedule that gives a proxy, as one edited by hand");
assert!(repair(&the_app, Permit::for_a_test()).expect("repaired"));
assert_eq!(installed_program(&the_app), Some(bundled));
assert_eq!(given(&the_app), edited, "the proxy its file gave");
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn a_test_never_reaches_the_systems_own_scheduler() {
let root = std::env::temp_dir().join(format!(
"pitboard-schedule-real-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
let program = root.join("bin/pitboard");
a_program_at(&program);
let ctx = Context::new(root.clone()).with_schedule_program(program);
let refused =
install(&ctx, Permit::for_a_test()).expect_err("refused before it reaches the system");
assert_eq!(refused.code(), "schedule_refused");
assert_eq!(status(&ctx), Installed::No, "and nothing is left written");
let _ = std::fs::remove_dir_all(&root);
}
}