1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/// Read the current user crontab via `crontab -l`.
///
/// Returns an empty string when no crontab exists for the user.
pub(crate) fn read_crontab() -> Result<String, SyncError> {
let out = Command::new(crontab_bin())
.arg("-l")
.output()
.map_err(|err| SyncError::CrontabCommand(format!("failed to run crontab -l: {err}")))?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
// "no crontab for <user>" is a normal condition — treat as empty.
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("no crontab") {
return Ok(String::new());
}
Err(SyncError::CrontabCommand(stderr.into_owned()))
}
/// Install `content` as the user's crontab via `crontab -`.
pub(crate) fn write_crontab(content: &str) -> Result<(), SyncError> {
let mut child = Command::new(crontab_bin())
.arg("-")
.stdin(Stdio::piped())
.spawn()
.map_err(|err| SyncError::CrontabCommand(format!("failed to spawn crontab: {err}")))?;
// Taking stdin drops (closes) it after write_all, signalling EOF. A write
// failure (e.g. the child exits early — a strict `crontab` rejecting
// malformed input mid-stream — and closes its end of the pipe) is a real,
// externally-triggerable I/O condition, not a programmer error, so it is
// propagated as `SyncError::Io` instead of panicking: every caller of
// crontab sync already treats a `SyncError` as warn-and-continue (see the
// module docs), and a panic here would defeat that graceful degradation.
#[allow(
clippy::expect_used,
reason = "`.stdin(Stdio::piped())` is set on this same `Command` a few lines above, so \
`take()` returning `None` here would mean the stdlib itself broke that \
contract, not a real runtime error"
)]
let write_result = child
.stdin
.take()
.expect("stdin is piped")
.write_all(content.as_bytes());
// Always wait to reap the child, even when the write above failed. Use a
// timeout instead of plain `wait()`: on macOS, privacy/TCC prompts for
// `SystemPolicySysAdminFiles` can leave setuid `crontab -` blocked
// headlessly, which otherwise wedges daemon startup before HTTP binds.
let status = wait_for_crontab_write(&mut child)?;
write_result?;
if !status.success() {
return Err(SyncError::CrontabCommand(format!(
"crontab - exited with {status}"
)));
}
Ok(())
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod sync_tests;
#[cfg(test)]
#[path = "crontab_io_tests.rs"]
mod crontab_io_tests;
#[cfg(test)]
#[path = "clear_crontab_tests.rs"]
mod clear_crontab_tests;
#[cfg(test)]
#[path = "mod_replace_block_tests.rs"]
mod replace_block_tests;