1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "fmt"
7 "strconv"
8 "strings"
9 "time"
10
11 // The time zones of schedules do not depend on the host's zoneinfo (the
12 // image has none).
13 _ "time/tzdata"
14)
15
16// Cron is a parsed "cron:" of a schedule trigger: five fields (minute, hour, day
17// of month, month, day of week), matched against a wall clock (UTC, or the
18// "timezone" of the entry: CronSchedule). The syntax is that of GitHub's schedule: "*", numbers, lists
19// "a,b", ranges "a-b", steps "*/n", "a/n" and "a-b/n", the names jan-dec and
20// sun-sat in any case; the day of week is 0-6 (Sunday is 0).
21//
22// As in Vixie cron, when both the day of month and the day of week are
23// restricted (neither starts with "*") a day matches if either does; otherwise
24// both must.
25type Cron struct {
26 minute, hour, dom, month, dow uint64 // bit sets
27 domStar, dowStar bool
28}
29
30type cronField struct {
31 min, max int
32 names map[string]int
33}
34
35var (
36 cronMinuteField = cronField{0, 59, nil}
37 cronHourField = cronField{0, 23, nil}
38 cronDomField = cronField{1, 31, nil}
39 cronMonthField = cronField{1, 12, map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}}
40 cronDowField = cronField{0, 6, map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}}
41)
42
43// ParseCron parses the five fields of a cron expression separated by spaces.
44func ParseCron(s string) (Cron, error) {
45 parts := strings.Fields(s)
46 if len(parts) != 5 {
47 return Cron{}, fmt.Errorf("%w: cron %q must have 5 fields: minute hour day-of-month month day-of-week", ErrInvalid, s)
48 }
49 var c Cron
50 var err error
51 for i, f := range []struct {
52 spec cronField
53 dst *uint64
54 }{
55 {cronMinuteField, &c.minute}, {cronHourField, &c.hour}, {cronDomField, &c.dom}, {cronMonthField, &c.month}, {cronDowField, &c.dow},
56 } {
57 if *f.dst, err = parseCronField(parts[i], f.spec); err != nil {
58 return Cron{}, fmt.Errorf("%w: cron %q: %v", ErrInvalid, s, err)
59 }
60 }
61 c.domStar, c.dowStar = strings.HasPrefix(parts[2], "*"), strings.HasPrefix(parts[4], "*")
62 return c, nil
63}
64
65func parseCronField(s string, f cronField) (uint64, error) {
66 var set uint64
67 for _, item := range strings.Split(s, ",") {
68 if item == "" {
69 return 0, fmt.Errorf("empty item in %q", s)
70 }
71 rangePart, stepPart, hasStep := strings.Cut(item, "/")
72 step := 1
73 if hasStep {
74 n, err := strconv.Atoi(stepPart)
75 if err != nil || n < 1 || strings.Trim(stepPart, "0123456789") != "" {
76 return 0, fmt.Errorf("invalid step %q", stepPart)
77 }
78 // A step larger than the field is accepted, as cron does, and keeps
79 // only the first value: it is clamped to the size of the field, which
80 // has the same meaning and cannot overflow the loop below.
81 step = min(n, f.max-f.min+1)
82 }
83 lo, hi := f.min, f.max
84 if rangePart != "*" {
85 a, b, isRange := strings.Cut(rangePart, "-")
86 var err error
87 if lo, err = cronValue(a, f); err != nil {
88 return 0, err
89 }
90 switch {
91 case isRange:
92 if hi, err = cronValue(b, f); err != nil {
93 return 0, err
94 }
95 if hi < lo {
96 return 0, fmt.Errorf("range %q goes backwards", rangePart)
97 }
98 case hasStep:
99 // "a/n" is "a-max/n".
100 default:
101 hi = lo
102 }
103 }
104 for v := lo; v <= hi; v += step {
105 set |= 1 << uint(v)
106 if step > hi-v {
107 break // the next value is past the range (and v+step could overflow)
108 }
109 }
110 }
111 return set, nil
112}
113
114func cronValue(s string, f cronField) (int, error) {
115 if v, ok := f.names[strings.ToLower(s)]; ok {
116 return v, nil
117 }
118 n, err := strconv.Atoi(s)
119 if err != nil || strings.Trim(s, "0123456789") != "" || n < f.min || n > f.max {
120 return 0, fmt.Errorf("invalid value %q (%d-%d)", s, f.min, f.max)
121 }
122 return n, nil
123}
124
125// Matches reports whether the minute of t (in UTC) is one the expression fires
126// at.
127func (c Cron) Matches(t time.Time) bool {
128 t = t.UTC()
129 if c.minute&(1<<uint(t.Minute())) == 0 || c.hour&(1<<uint(t.Hour())) == 0 || c.month&(1<<uint(t.Month())) == 0 {
130 return false
131 }
132 domOK := c.dom&(1<<uint(t.Day())) != 0
133 dowOK := c.dow&(1<<uint(t.Weekday())) != 0
134 if !c.domStar && !c.dowStar {
135 return domOK || dowOK
136 }
137 return domOK && dowOK
138}
139
140// CronSchedule is a cron entry with its time zone (CI-4 D6). GitHub runs the
141// entry at the wall clock of the zone, UTC when it has none. In a zone with
142// daylight saving time, an entry whose time is skipped by a spring-forward
143// transition runs at the next valid time (docs.github.com, "Events that trigger
144// workflows", schedule: "a 2:30 AM schedule advances to 3:00 AM"). The docs do
145// not say what a fall-back transition does: a wall clock that happens twice
146// runs once, at its first occurrence, as cron does.
147type CronSchedule struct {
148 Cron Cron
149 Loc *time.Location
150}
151
152// ParseCronTimezone reads the "timezone" of a schedule entry: an IANA time zone
153// name, UTC when empty. "Local" (the server's zone) is not one.
154func ParseCronTimezone(name string) (*time.Location, error) {
155 if name == "" {
156 return time.UTC, nil
157 }
158 if name == "Local" || strings.HasPrefix(name, "/") || strings.Contains(name, "..") {
159 return nil, fmt.Errorf("%w: invalid schedule timezone %q: expected an IANA time zone name such as America/New_York", ErrInvalid, name)
160 }
161 loc, err := time.LoadLocation(name)
162 if err != nil {
163 return nil, fmt.Errorf("%w: invalid schedule timezone %q: expected an IANA time zone name such as America/New_York", ErrInvalid, name)
164 }
165 return loc, nil
166}
167
168// cronGapLimit bounds how far back a transition is looked for: the largest
169// clock change of the time zone database (Samoa skipped a whole day in 2011).
170const cronGapLimit = 25 * time.Hour
171
172// Due reports whether the entry fires at the minute that starts at t.
173func (s CronSchedule) Due(t time.Time) bool {
174 loc := s.Loc
175 if loc == nil {
176 loc = time.UTC
177 }
178 t = t.Truncate(time.Minute)
179 wall := wallMinute(t, loc)
180 if s.Cron.Matches(wall) {
181 // A wall clock already seen in the last hours (a fall-back
182 // transition repeats it) does not fire again.
183 return !s.repeated(t, wall, loc)
184 }
185 // The first minute after a spring-forward gap runs the entries whose wall
186 // clock fell in the gap.
187 prev := wallMinute(t.Add(-time.Minute), loc)
188 for w := prev.Add(time.Minute); w.Before(wall); w = w.Add(time.Minute) {
189 if s.Cron.Matches(w) {
190 return true
191 }
192 }
193 return false
194}
195
196// repeated reports whether the wall clock of t was already shown earlier: an
197// instant t-d has it when the offset there is the offset at t plus d, so only
198// the larger offsets of the last hours are candidates.
199func (s CronSchedule) repeated(t, wall time.Time, loc *time.Location) bool {
200 if loc == time.UTC {
201 return false
202 }
203 _, now := t.In(loc).Zone()
204 for h := time.Hour; h <= cronGapLimit; h += time.Hour {
205 _, off := t.Add(-h).In(loc).Zone()
206 if back := time.Duration(off-now) * time.Second; back > 0 && wallMinute(t.Add(-back), loc).Equal(wall) {
207 return true
208 }
209 }
210 return false
211}
212
213// wallMinute is the wall clock of t in loc, as a UTC time (Matches reads its
214// fields).
215func wallMinute(t time.Time, loc *time.Location) time.Time {
216 l := t.In(loc)
217 return time.Date(l.Year(), l.Month(), l.Day(), l.Hour(), l.Minute(), 0, 0, time.UTC)
218}