1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "fmt"
7 "strconv"
8 "strings"
9 "time"
10)
11
12// Cron is a parsed "cron:" of a schedule trigger: five fields (minute, hour, day
13// of month, month, day of week), evaluated in UTC (CI-3 D5; the "timezone" key
14// is ignored). The syntax is that of GitHub's schedule: "*", numbers, lists
15// "a,b", ranges "a-b", steps "*/n", "a/n" and "a-b/n", the names jan-dec and
16// sun-sat in any case; the day of week is 0-6 (Sunday is 0).
17//
18// As in Vixie cron, when both the day of month and the day of week are
19// restricted (neither starts with "*") a day matches if either does; otherwise
20// both must.
21type Cron struct {
22 minute, hour, dom, month, dow uint64 // bit sets
23 domStar, dowStar bool
24}
25
26type cronField struct {
27 min, max int
28 names map[string]int
29}
30
31var (
32 cronMinuteField = cronField{0, 59, nil}
33 cronHourField = cronField{0, 23, nil}
34 cronDomField = cronField{1, 31, nil}
35 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}}
36 cronDowField = cronField{0, 6, map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}}
37)
38
39// ParseCron parses the five fields of a cron expression separated by spaces.
40func ParseCron(s string) (Cron, error) {
41 parts := strings.Fields(s)
42 if len(parts) != 5 {
43 return Cron{}, fmt.Errorf("%w: cron %q must have 5 fields: minute hour day-of-month month day-of-week", ErrInvalid, s)
44 }
45 var c Cron
46 var err error
47 for i, f := range []struct {
48 spec cronField
49 dst *uint64
50 }{
51 {cronMinuteField, &c.minute}, {cronHourField, &c.hour}, {cronDomField, &c.dom}, {cronMonthField, &c.month}, {cronDowField, &c.dow},
52 } {
53 if *f.dst, err = parseCronField(parts[i], f.spec); err != nil {
54 return Cron{}, fmt.Errorf("%w: cron %q: %v", ErrInvalid, s, err)
55 }
56 }
57 c.domStar, c.dowStar = strings.HasPrefix(parts[2], "*"), strings.HasPrefix(parts[4], "*")
58 return c, nil
59}
60
61func parseCronField(s string, f cronField) (uint64, error) {
62 var set uint64
63 for _, item := range strings.Split(s, ",") {
64 if item == "" {
65 return 0, fmt.Errorf("empty item in %q", s)
66 }
67 rangePart, stepPart, hasStep := strings.Cut(item, "/")
68 step := 1
69 if hasStep {
70 n, err := strconv.Atoi(stepPart)
71 if err != nil || n < 1 || strings.Trim(stepPart, "0123456789") != "" {
72 return 0, fmt.Errorf("invalid step %q", stepPart)
73 }
74 // A step larger than the field is accepted, as cron does, and keeps
75 // only the first value: it is clamped to the size of the field, which
76 // has the same meaning and cannot overflow the loop below.
77 step = min(n, f.max-f.min+1)
78 }
79 lo, hi := f.min, f.max
80 if rangePart != "*" {
81 a, b, isRange := strings.Cut(rangePart, "-")
82 var err error
83 if lo, err = cronValue(a, f); err != nil {
84 return 0, err
85 }
86 switch {
87 case isRange:
88 if hi, err = cronValue(b, f); err != nil {
89 return 0, err
90 }
91 if hi < lo {
92 return 0, fmt.Errorf("range %q goes backwards", rangePart)
93 }
94 case hasStep:
95 // "a/n" is "a-max/n".
96 default:
97 hi = lo
98 }
99 }
100 for v := lo; v <= hi; v += step {
101 set |= 1 << uint(v)
102 if step > hi-v {
103 break // the next value is past the range (and v+step could overflow)
104 }
105 }
106 }
107 return set, nil
108}
109
110func cronValue(s string, f cronField) (int, error) {
111 if v, ok := f.names[strings.ToLower(s)]; ok {
112 return v, nil
113 }
114 n, err := strconv.Atoi(s)
115 if err != nil || strings.Trim(s, "0123456789") != "" || n < f.min || n > f.max {
116 return 0, fmt.Errorf("invalid value %q (%d-%d)", s, f.min, f.max)
117 }
118 return n, nil
119}
120
121// Matches reports whether the minute of t (in UTC) is one the expression fires
122// at.
123func (c Cron) Matches(t time.Time) bool {
124 t = t.UTC()
125 if c.minute&(1<<uint(t.Minute())) == 0 || c.hour&(1<<uint(t.Hour())) == 0 || c.month&(1<<uint(t.Month())) == 0 {
126 return false
127 }
128 domOK := c.dom&(1<<uint(t.Day())) != 0
129 dowOK := c.dow&(1<<uint(t.Weekday())) != 0
130 if !c.domStar && !c.dowStar {
131 return domOK || dowOK
132 }
133 return domOK && dowOK
134}