#![cfg(target_os = "macos")]
use iron_shell::env::Env;
use iron_shell::value::Value;
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
static SERIAL: Mutex<()> = Mutex::new(());
const CRONTAB: &str = "/usr/bin/crontab";
fn disposable() -> bool {
std::env::var("AE_DISPOSABLE_HOST").as_deref() == Ok("1")
}
fn s(text: &str) -> Value {
Value::Str(text.to_string())
}
fn call(name: &str, args: Vec<Value>) -> anyhow::Result<Value> {
let mut env = Env::new();
iron_shell::builtins::call(name, args, &mut env)
}
fn code_of(e: &anyhow::Error) -> String {
let text = e.to_string();
match text.find("E_") {
Some(i) => text[i..]
.split(|c: char| !(c.is_ascii_uppercase() || c == '_'))
.next()
.unwrap_or("E_?")
.to_string(),
None => format!("NO_CODE: {text}"),
}
}
fn expect_code(result: anyhow::Result<Value>, want: &str) {
match result {
Ok(v) => panic!("answered {v:?}; expected {want}"),
Err(e) => assert_eq!(code_of(&e), want, "wrong refusal: {e}"),
}
}
struct Approve(Option<std::ffi::OsString>);
impl Approve {
fn new() -> Self {
let prior = std::env::var_os("IRONSHELL_APPROVE_ALL");
std::env::set_var("IRONSHELL_APPROVE_ALL", "1");
Approve(prior)
}
}
impl Drop for Approve {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("IRONSHELL_APPROVE_ALL", value),
None => std::env::remove_var("IRONSHELL_APPROVE_ALL"),
}
}
}
fn crontab_read() -> Option<String> {
let out = Command::new(CRONTAB)
.arg("-l")
.stdin(Stdio::null())
.output()
.unwrap_or_else(|e| panic!("cannot run {CRONTAB}: {e}"));
if out.status.success() {
return Some(String::from_utf8(out.stdout).expect("crontab output is UTF-8"));
}
let err = String::from_utf8_lossy(&out.stderr).into_owned();
if err.contains("no crontab for") {
return None;
}
panic!("crontab -l failed: {err}");
}
fn crontab_text() -> String {
crontab_read().unwrap_or_default()
}
fn crontab_write(text: &str) -> Result<(), String> {
let mut child = Command::new(CRONTAB)
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("cannot run {CRONTAB} -: {e}"))?;
child
.stdin
.take()
.ok_or("crontab - has no stdin")?
.write_all(text.as_bytes())
.map_err(|e| format!("cannot write the crontab: {e}"))?;
let out = child
.wait_with_output()
.map_err(|e| format!("crontab - did not finish: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(format!(
"crontab - failed: {}",
String::from_utf8_lossy(&out.stderr)
))
}
}
fn crontab_restore(original: Option<&str>) -> Result<(), String> {
match original {
Some(text) => crontab_write(text),
None => {
let _ = Command::new(CRONTAB)
.arg("-r")
.stdin(Stdio::null())
.output();
Ok(())
}
}
}
fn marker() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is after 1970")
.as_nanos();
format!("{}-{nanos}", std::process::id())
}
fn cron_lists(listing: &Value, schedule: &str, command: &str) -> bool {
match listing {
Value::Array(rows) => rows.iter().any(|row| match row {
Value::Record(fields) => {
fields.get("schedule") == Some(&s(schedule))
&& fields.get("command") == Some(&s(command))
}
_ => false,
}),
_ => false,
}
}
struct Cleanup {
original: Option<String>,
}
impl Cleanup {
fn new() -> Self {
Cleanup {
original: crontab_read(),
}
}
}
impl Drop for Cleanup {
fn drop(&mut self) {
if let Err(e) = crontab_restore(self.original.as_deref()) {
eprintln!("macos_task_admin: could not restore the original crontab: {e}");
}
}
}
#[test]
#[ignore = "disposable macOS host, as the runner user: set AE_DISPOSABLE_HOST=1"]
fn cron_lifecycle_touches_only_its_own_entry() {
if !disposable() {
return;
}
let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let _approve = Approve::new();
let _cleanup = Cleanup::new();
let marker = marker();
let seed = format!("# ae-cron unrelated {marker}\n17 3 * * * /usr/bin/true # keep-{marker}\n");
crontab_write(&seed).expect("seed the crontab");
let command = format!("/usr/bin/true ae-cron-{marker}");
let entry = format!("30 4 * * * {command}");
let added = call("cron_add", vec![s("30 4 * * *"), s(&command)]).expect("cron_add");
assert_eq!(added, s(&entry), "cron_add returns its lifecycle target");
assert_eq!(
crontab_text(),
format!("{seed}{entry}\n"),
"crontab -l after cron_add: unrelated lines byte-for-byte, plus the one new entry"
);
let listed = call("cron_list", vec![]).expect("cron_list");
assert!(
cron_lists(&listed, "30 4 * * *", &command),
"cron_list does not include the added job: {listed:?}"
);
assert_eq!(
call("cron_disable", vec![s(&entry)]).expect("cron_disable"),
Value::Bool(true)
);
assert_eq!(
crontab_text(),
format!("{seed}# ironshell-disabled: {entry}\n"),
"crontab -l after cron_disable: the entry carries the IronShell comment prefix"
);
let listed = call("cron_list", vec![]).expect("cron_list");
assert!(
!cron_lists(&listed, "30 4 * * *", &command),
"cron_list still lists a disabled job: {listed:?}"
);
assert_eq!(
call("cron_enable", vec![s(&entry)]).expect("cron_enable"),
Value::Bool(true)
);
assert_eq!(
crontab_text(),
format!("{seed}{entry}\n"),
"crontab -l after cron_enable: the entry is active again"
);
assert_eq!(
call("cron_remove", vec![s(&entry)]).expect("cron_remove"),
Value::Bool(true)
);
assert_eq!(
crontab_text(),
seed,
"crontab -l after cron_remove: only the unrelated lines remain"
);
let listed = call("cron_list", vec![]).expect("cron_list");
assert!(
!cron_lists(&listed, "30 4 * * *", &command),
"cron_list still lists a removed job: {listed:?}"
);
}
#[test]
#[ignore = "disposable macOS host, as the runner user: set AE_DISPOSABLE_HOST=1"]
fn cron_refusals_leave_the_crontab_unchanged() {
if !disposable() {
return;
}
let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let _approve = Approve::new();
let _cleanup = Cleanup::new();
let marker = marker();
let seed = format!("# ae-cron unrelated {marker}\n17 3 * * * /usr/bin/true # keep-{marker}\n");
crontab_write(&seed).expect("seed the crontab");
let command = format!("/usr/bin/true ae-cron-{marker}");
let entry = format!("30 4 * * * {command}");
call("cron_add", vec![s("30 4 * * *"), s(&command)]).expect("first cron_add");
let active = format!("{seed}{entry}\n");
assert_eq!(crontab_text(), active);
expect_code(
call("cron_add", vec![s("30 4 * * *"), s(&command)]),
"E_BAD_STATE",
);
assert_eq!(
crontab_text(),
active,
"a refused duplicate cron_add must not touch the crontab"
);
call("cron_disable", vec![s(&entry)]).expect("cron_disable");
let disabled = format!("{seed}# ironshell-disabled: {entry}\n");
assert_eq!(crontab_text(), disabled);
expect_code(
call("cron_add", vec![s("30 4 * * *"), s(&command)]),
"E_BAD_STATE",
);
assert_eq!(
crontab_text(),
disabled,
"a duplicate of a disabled entry is also refused, and nothing changes"
);
let missing = format!("30 4 * * * /usr/bin/true ae-cron-missing-{marker}");
expect_code(call("cron_remove", vec![s(&missing)]), "E_BAD_STATE");
assert_eq!(
crontab_text(),
disabled,
"removing a nonexistent entry must not touch the crontab"
);
}