Skip to content

g1t/services/security/src/schedule.rs

405 lines16,314 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1//! When each entry of the dependency update file is checked: its
2//! `schedule` (`interval`, `day`, `time`, `timezone`, `cronjob`), worked
3//! out in the entry's time zone. Without a `time`, a time of day is picked
4//! for the repository and kept, so that checks do not all start at once.
5
6use g1t_actions::cron;
7
8use crate::timezones;
9
10const MINUTE_MS: i64 = 60_000;
11const DAY_MS: i64 = 86_400_000;
12/// How far ahead a next run is looked for: a yearly schedule's is within it.
13const HORIZON_DAYS: i64 = 400;
14
15/// `schedule.interval`.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum Interval {
18 /// Every weekday, Monday to Friday.
19 Daily,
20 /// Once a week, on `day` (Monday by default).
21 Weekly,
22 /// The first day of each month.
23 Monthly,
24 /// The first day of January, April, July and October.
25 Quarterly,
26 /// The first day of January and July.
27 Semiannually,
28 /// The first day of January.
29 Yearly,
30 /// When `cronjob` says.
31 Cron,
32}
33
34impl Interval {
35 pub const ALL: [Interval; 7] = [
36 Interval::Daily,
37 Interval::Weekly,
38 Interval::Monthly,
39 Interval::Quarterly,
40 Interval::Semiannually,
41 Interval::Yearly,
42 Interval::Cron,
43 ];
44
45 pub fn as_str(self) -> &'static str {
46 match self {
47 Interval::Daily => "daily",
48 Interval::Weekly => "weekly",
49 Interval::Monthly => "monthly",
50 Interval::Quarterly => "quarterly",
51 Interval::Semiannually => "semiannually",
52 Interval::Yearly => "yearly",
53 Interval::Cron => "cron",
54 }
55 }
56
57 pub fn parse(text: &str) -> Option<Interval> {
58 Interval::ALL.into_iter().find(|interval| interval.as_str() == text)
59 }
60}
61
62/// `schedule.day`, Sunday first, as cron counts.
63pub const WEEKDAYS: [&str; 7] = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
64
65/// A `schedule`, read and checked.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct Schedule {
68 pub interval: Interval,
69 /// 0 for Sunday to 6 for Saturday.
70 pub day: Option<u32>,
71 /// Hours and minutes.
72 pub time: Option<(u32, u32)>,
73 /// An IANA zone name; UTC when absent.
74 pub timezone: Option<String>,
75 /// For `cron`: five fields, read from `cronjob`.
76 pub cron: Option<String>,
77}
78
79/// Days counted from 1970-01-01 to a date (Howard Hinnant's algorithm).
80pub fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
81 let y = if month <= 2 { year - 1 } else { year };
82 let era = y.div_euclid(400);
83 let yoe = y - era * 400;
84 let m = i64::from(month);
85 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(day) - 1;
86 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
87 era * 146_097 + doe - 719_468
88}
89
90/// The date of a day counted from 1970-01-01.
91pub fn civil_from_days(days: i64) -> (i64, u32, u32) {
92 let z = days + 719_468;
93 let era = z.div_euclid(146_097);
94 let doe = z.rem_euclid(146_097);
95 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
96 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
97 let mp = (5 * doy + 2) / 153;
98 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
99 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
100 (yoe + era * 400 + i64::from(month <= 2), month, day)
101}
102
103/// 0 for Sunday.
104fn weekday(days: i64) -> u32 {
105 (days + 4).rem_euclid(7) as u32
106}
107
108/// `hh:mm`, 00:00 to 23:59.
109pub fn parse_time(text: &str) -> Option<(u32, u32)> {
110 let (hours, minutes) = text.trim().split_once(':')?;
111 if hours.len() != 2 || minutes.len() != 2 {
112 return None;
113 }
114 let (hours, minutes) = (hours.parse::<u32>().ok()?, minutes.parse::<u32>().ok()?);
115 (hours < 24 && minutes < 60).then_some((hours, minutes))
116}
117
118/// A time of day said the way people say it: `5pm`, `5:30 pm`, `17:00`,
119/// `noon`, `midnight`.
120fn spoken_time(text: &str) -> Option<(u32, u32)> {
121 let text = text.trim();
122 match text {
123 "noon" => return Some((12, 0)),
124 "midnight" => return Some((0, 0)),
125 _ => {}
126 }
127 let (clock, half) = if let Some(clock) = text.strip_suffix("am") {
128 (clock.trim(), Some(false))
129 } else if let Some(clock) = text.strip_suffix("pm") {
130 (clock.trim(), Some(true))
131 } else {
132 (text, None)
133 };
134 let (hours, minutes) = match clock.split_once(':') {
135 Some((hours, minutes)) if minutes.len() == 2 => (hours.parse::<u32>().ok()?, minutes.parse::<u32>().ok()?),
136 Some(_) => return None,
137 None => (clock.parse::<u32>().ok()?, 0),
138 };
139 if minutes > 59 {
140 return None;
141 }
142 let hours = match half {
143 Some(pm) if (1..=12).contains(&hours) => hours % 12 + if pm { 12 } else { 0 },
144 Some(_) => return None,
145 None if hours < 24 && clock.contains(':') => hours,
146 None => return None,
147 };
148 Some((hours, minutes))
149}
150
151/// `cronjob` as five cron fields: a cron expression as it is, or one of
152/// the natural forms: `every day at 5pm`, `every weekday at 9:30am`,
153/// `every monday at 09:00`, `every hour`, `every 6 hours`.
154pub fn cronjob(text: &str) -> Result<String, String> {
155 let text = text.trim();
156 if text.split_whitespace().count() == 5 {
157 return cron::Schedule::parse(text).map(|_| text.split_whitespace().collect::<Vec<_>>().join(" "));
158 }
159 let lower = text.to_lowercase();
160 let words: Vec<&str> = lower.split_whitespace().collect();
161 let unclear = || format!("`{text}` is neither a cron expression, such as `0 9 * * 1`, nor a schedule g1t reads, such as `every day at 5pm`.");
162 let Some(("every", rest)) = words.split_first().map(|(first, rest)| (*first, rest)) else {
163 return Err(unclear());
164 };
165 match rest {
166 ["hour"] => return Ok("0 * * * *".to_owned()),
167 [n, "hours"] => {
168 let n: u32 = n.parse().map_err(|_| unclear())?;
169 if !(1..=23).contains(&n) {
170 return Err(unclear());
171 }
172 return Ok(format!("0 */{n} * * *"));
173 }
174 _ => {}
175 }
176 let at = rest.iter().position(|word| *word == "at").ok_or_else(unclear)?;
177 let (when, time) = (&rest[..at], rest[at + 1..].join(" "));
178 let (hours, minutes) = spoken_time(&time).ok_or_else(unclear)?;
179 let days = match when {
180 ["day"] => "*".to_owned(),
181 ["weekday"] => "1-5".to_owned(),
182 [name] => {
183 let name = name.strip_suffix('s').filter(|bare| WEEKDAYS.contains(bare)).unwrap_or(name);
184 WEEKDAYS.iter().position(|day| *day == name).ok_or_else(unclear)?.to_string()
185 }
186 _ => return Err(unclear()),
187 };
188 Ok(format!("{minutes} {hours} * * {days}"))
189}
190
191/// The time of day a schedule without `time` runs at, picked from `seed`
192/// (the repository and entry) so it stays the same.
193pub fn default_time(seed: &str) -> (u32, u32) {
194 // FNV-1a.
195 let mut hash: u32 = 0x811c_9dc5;
196 for byte in seed.bytes() {
197 hash ^= u32::from(byte);
198 hash = hash.wrapping_mul(0x0100_0193);
199 }
200 let minute = hash % (24 * 60);
201 (minute / 60, minute % 60)
202}
203
204/// UTC milliseconds for a local time in `zone`.
205fn to_utc(zone: &str, local: i64) -> i64 {
206 let guess = local - i64::from(timezones::offset_minutes(zone, local.max(0) as u64)) * MINUTE_MS;
207 local - i64::from(timezones::offset_minutes(zone, guess.max(0) as u64)) * MINUTE_MS
208}
209
210impl Schedule {
211 fn zone(&self) -> &str {
212 self.timezone.as_deref().unwrap_or("UTC")
213 }
214
215 fn runs_on(&self, days: i64) -> bool {
216 let (_, month, day) = civil_from_days(days);
217 match self.interval {
218 Interval::Daily => (1..=5).contains(&weekday(days)),
219 Interval::Weekly => weekday(days) == self.day.unwrap_or(1),
220 Interval::Monthly => day == 1,
221 Interval::Quarterly => day == 1 && matches!(month, 1 | 4 | 7 | 10),
222 Interval::Semiannually => day == 1 && matches!(month, 1 | 7),
223 Interval::Yearly => day == 1 && month == 1,
224 Interval::Cron => false,
225 }
226 }
227
228 /// The first time after `after_ms` the entry runs, in UTC
229 /// milliseconds. `seed` picks the time of day when `time` is absent.
230 pub fn next_run(&self, after_ms: u64, seed: &str) -> Option<u64> {
231 let after = after_ms as i64;
232 let zone = self.zone();
233 let local_now = after + i64::from(timezones::offset_minutes(zone, after_ms)) * MINUTE_MS;
234 if self.interval == Interval::Cron {
235 let schedule = cron::Schedule::parse(self.cron.as_deref()?).ok()?;
236 let mut local = (local_now.div_euclid(MINUTE_MS) + 1) * MINUTE_MS;
237 let end = local + HORIZON_DAYS * DAY_MS;
238 while local < end {
239 if schedule.fires_at(local as u64) {
240 let utc = to_utc(zone, local);
241 if utc > after {
242 return Some(utc as u64);
243 }
244 }
245 local += MINUTE_MS;
246 }
247 return None;
248 }
249 let (hours, minutes) = self.time.unwrap_or_else(|| default_time(seed));
250 let today = local_now.div_euclid(DAY_MS);
251 (today - 1..today + HORIZON_DAYS).find_map(|days| {
252 if !self.runs_on(days) {
253 return None;
254 }
255 let utc = to_utc(zone, days * DAY_MS + i64::from(hours) * 3_600_000 + i64::from(minutes) * MINUTE_MS);
256 (utc > after).then_some(utc as u64)
257 })
258 }
259
260 /// The schedule in words: "Weekdays at 05:00 (UTC)".
261 pub fn describe(&self, seed: &str) -> String {
262 let zone = self.zone();
263 if self.interval == Interval::Cron {
264 return format!("Cron `{}` ({zone})", self.cron.as_deref().unwrap_or_default());
265 }
266 let (hours, minutes) = self.time.unwrap_or_else(|| default_time(seed));
267 let at = format!("{hours:02}:{minutes:02} ({zone})");
268 let picked = if self.time.is_none() { ", a time picked for this repository" } else { "" };
269 let when = match self.interval {
270 Interval::Daily => "Weekdays".to_owned(),
271 Interval::Weekly => {
272 let day = WEEKDAYS[self.day.unwrap_or(1) as usize];
273 format!("{}{}s", day[..1].to_uppercase(), &day[1..])
274 }
275 Interval::Monthly => "The 1st of each month".to_owned(),
276 Interval::Quarterly => "The 1st of January, April, July and October".to_owned(),
277 Interval::Semiannually => "The 1st of January and July".to_owned(),
278 Interval::Yearly => "January 1st".to_owned(),
279 Interval::Cron => unreachable!(),
280 };
281 format!("{when} at {at}{picked}")
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use g1t_contracts::time::{parse_rfc3339, rfc3339};
289
290 fn at(text: &str) -> u64 {
291 parse_rfc3339(text).unwrap()
292 }
293
294 fn schedule(interval: Interval) -> Schedule {
295 Schedule { interval, day: None, time: Some((5, 0)), timezone: None, cron: None }
296 }
297
298 fn next(schedule: &Schedule, after: &str) -> String {
299 rfc3339(schedule.next_run(at(after), "seed").unwrap())
300 }
301
302 #[test]
303 fn dates_round_trip() {
304 for days in [-1000, 0, 19_000, 20_733, 60_000] {
305 let (y, m, d) = civil_from_days(days);
306 assert_eq!(days_from_civil(y, m, d), days);
307 }
308 assert_eq!(civil_from_days(days_from_civil(2026, 10, 7)), (2026, 10, 7));
309 // 2026-10-07 is a Wednesday.
310 assert_eq!(weekday(days_from_civil(2026, 10, 7)), 3);
311 }
312
313 #[test]
314 fn daily_runs_on_weekdays() {
315 let daily = schedule(Interval::Daily);
316 // Wednesday 04:00 runs at 05:00 the same day; 06:00 runs Thursday.
317 assert_eq!(next(&daily, "2026-10-07T04:00:00Z"), "2026-10-07T05:00:00.000Z");
318 assert_eq!(next(&daily, "2026-10-07T06:00:00Z"), "2026-10-08T05:00:00.000Z");
319 // Friday evening goes to Monday.
320 assert_eq!(next(&daily, "2026-10-09T06:00:00Z"), "2026-10-12T05:00:00.000Z");
321 }
322
323 #[test]
324 fn weekly_monthly_and_longer() {
325 let mut weekly = schedule(Interval::Weekly);
326 assert_eq!(next(&weekly, "2026-10-07T00:00:00Z"), "2026-10-12T05:00:00.000Z");
327 weekly.day = Some(5);
328 assert_eq!(next(&weekly, "2026-10-07T00:00:00Z"), "2026-10-09T05:00:00.000Z");
329 assert_eq!(next(&schedule(Interval::Monthly), "2026-10-07T00:00:00Z"), "2026-11-01T05:00:00.000Z");
330 assert_eq!(next(&schedule(Interval::Quarterly), "2026-10-07T00:00:00Z"), "2027-01-01T05:00:00.000Z");
331 assert_eq!(next(&schedule(Interval::Quarterly), "2026-02-07T00:00:00Z"), "2026-04-01T05:00:00.000Z");
332 assert_eq!(next(&schedule(Interval::Semiannually), "2026-02-07T00:00:00Z"), "2026-07-01T05:00:00.000Z");
333 assert_eq!(next(&schedule(Interval::Yearly), "2026-02-07T00:00:00Z"), "2027-01-01T05:00:00.000Z");
334 }
335
336 #[test]
337 fn time_zones_move_the_hour_with_daylight_saving() {
338 let mut daily = schedule(Interval::Daily);
339 daily.time = Some((9, 30));
340 daily.timezone = Some("America/New_York".into());
341 // Summer: 09:30 EDT is 13:30 UTC; winter: 14:30 UTC.
342 assert_eq!(next(&daily, "2026-07-01T00:00:00Z"), "2026-07-01T13:30:00.000Z");
343 assert_eq!(next(&daily, "2026-12-01T00:00:00Z"), "2026-12-01T14:30:00.000Z");
344 // Late in the UTC day it is still the same local day.
345 assert_eq!(next(&daily, "2026-07-01T03:00:00Z"), "2026-07-01T13:30:00.000Z");
346 let mut tokyo = schedule(Interval::Weekly);
347 tokyo.time = Some((8, 0));
348 tokyo.timezone = Some("Asia/Tokyo".into());
349 // Monday 08:00 in Tokyo is Sunday 23:00 UTC.
350 assert_eq!(next(&tokyo, "2026-10-07T00:00:00Z"), "2026-10-11T23:00:00.000Z");
351 }
352
353 #[test]
354 fn cron_runs_in_its_zone() {
355 let mut cron = schedule(Interval::Cron);
356 cron.cron = Some("0 9 * * 1-5".into());
357 assert_eq!(next(&cron, "2026-10-07T09:00:00Z"), "2026-10-08T09:00:00.000Z");
358 cron.timezone = Some("America/New_York".into());
359 assert_eq!(next(&cron, "2026-10-07T00:00:00Z"), "2026-10-07T13:00:00.000Z");
360 cron.cron = Some("30 2 1 1 *".into());
361 assert_eq!(next(&cron, "2026-10-07T00:00:00Z"), "2027-01-01T07:30:00.000Z");
362 }
363
364 #[test]
365 fn without_a_time_one_is_picked_and_kept() {
366 let mut daily = schedule(Interval::Daily);
367 daily.time = None;
368 let (hours, minutes) = default_time("rep_1:npm:/");
369 assert!(hours < 24 && minutes < 60);
370 assert_eq!(default_time("rep_1:npm:/"), (hours, minutes));
371 assert_ne!(default_time("rep_1:npm:/"), default_time("rep_2:npm:/"));
372 assert!(daily.describe("rep_1:npm:/").contains("a time picked for this repository"));
373 }
374
375 #[test]
376 fn cronjobs_take_cron_or_words() {
377 assert_eq!(cronjob("0 9 * * *").unwrap(), "0 9 * * *");
378 assert_eq!(cronjob("every day at 5pm").unwrap(), "0 17 * * *");
379 assert_eq!(cronjob("every weekday at 9:30am").unwrap(), "30 9 * * 1-5");
380 assert_eq!(cronjob("Every Monday at 09:00").unwrap(), "0 9 * * 1");
381 assert_eq!(cronjob("every fridays at noon").unwrap(), "0 12 * * 5");
382 assert_eq!(cronjob("every 6 hours").unwrap(), "0 */6 * * *");
383 assert_eq!(cronjob("every hour").unwrap(), "0 * * * *");
384 assert!(cronjob("0 9 * *").unwrap_err().contains("neither a cron expression"));
385 assert!(cronjob("61 9 * * *").is_err());
386 assert!(cronjob("every day at 25pm").is_err());
387 assert!(cronjob("sometimes").is_err());
388 }
389
390 #[test]
391 fn times_and_words() {
392 assert_eq!(parse_time("03:00"), Some((3, 0)));
393 assert_eq!(parse_time("23:59"), Some((23, 59)));
394 assert_eq!(parse_time("24:00"), None);
395 assert_eq!(parse_time("3:00"), None);
396 let mut weekly = schedule(Interval::Weekly);
397 weekly.day = Some(0);
398 assert_eq!(weekly.describe("x"), "Sundays at 05:00 (UTC)");
399 assert_eq!(schedule(Interval::Daily).describe("x"), "Weekdays at 05:00 (UTC)");
400 let mut cron = schedule(Interval::Cron);
401 cron.cron = Some("0 9 * * *".into());
402 cron.timezone = Some("Europe/London".into());
403 assert_eq!(cron.describe("x"), "Cron `0 9 * * *` (Europe/London)");
404 }
405}