sentry_types/
crontab_validator.rs

1use std::ops::RangeInclusive;
2
3struct SegmentAllowedValues<'a> {
4    /// Range of permitted numeric values
5    numeric_range: RangeInclusive<u64>,
6
7    /// Allowed alphabetic single values
8    single_values: &'a [&'a str],
9}
10
11const MONTHS: &[&str] = &[
12    "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
13];
14
15const DAYS: &[&str] = &["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
16
17const ALLOWED_VALUES: &[&SegmentAllowedValues] = &[
18    &SegmentAllowedValues {
19        numeric_range: 0..=59,
20        single_values: &[],
21    },
22    &SegmentAllowedValues {
23        numeric_range: 0..=23,
24        single_values: &[],
25    },
26    &SegmentAllowedValues {
27        numeric_range: 1..=31,
28        single_values: &[],
29    },
30    &SegmentAllowedValues {
31        numeric_range: 1..=12,
32        single_values: MONTHS,
33    },
34    &SegmentAllowedValues {
35        numeric_range: 0..=6,
36        single_values: DAYS,
37    },
38];
39
40fn validate_range(range: &str, allowed_values: &SegmentAllowedValues) -> bool {
41    if range == "*" {
42        return true;
43    }
44
45    let range_limits: Vec<_> = range.split('-').map(str::parse::<u64>).collect();
46
47    range_limits.len() == 2
48        && range_limits.iter().all(|limit| match limit {
49            Ok(limit) => allowed_values.numeric_range.contains(limit),
50            Err(_) => false,
51        })
52        && range_limits[0].as_ref().unwrap() <= range_limits[1].as_ref().unwrap()
53}
54
55fn validate_step(step: &str) -> bool {
56    match step.parse::<u64>() {
57        Ok(value) => value > 0,
58        Err(_) => false,
59    }
60}
61
62fn validate_steprange(steprange: &str, allowed_values: &SegmentAllowedValues) -> bool {
63    let mut steprange_split = steprange.splitn(2, '/');
64    let range_is_valid = match steprange_split.next() {
65        Some(range) => validate_range(range, allowed_values),
66        None => false,
67    };
68
69    range_is_valid
70        && match steprange_split.next() {
71            Some(step) => validate_step(step),
72            None => true,
73        }
74}
75
76fn validate_listitem(listitem: &str, allowed_values: &SegmentAllowedValues) -> bool {
77    match listitem.parse::<u64>() {
78        Ok(value) => allowed_values.numeric_range.contains(&value),
79        Err(_) => validate_steprange(listitem, allowed_values),
80    }
81}
82
83fn validate_list(list: &str, allowed_values: &SegmentAllowedValues) -> bool {
84    list.split(',')
85        .all(|listitem| validate_listitem(listitem, allowed_values))
86}
87
88fn validate_segment(segment: &str, allowed_values: &SegmentAllowedValues) -> bool {
89    allowed_values
90        .single_values
91        .contains(&segment.to_lowercase().as_ref())
92        || validate_list(segment, allowed_values)
93}
94
95pub fn validate(crontab: &str) -> bool {
96    let lists: Vec<_> = crontab.split_whitespace().collect();
97    if lists.len() != 5 {
98        return false;
99    }
100
101    lists
102        .iter()
103        .zip(ALLOWED_VALUES)
104        .all(|(segment, allowed_values)| validate_segment(segment, allowed_values))
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use rstest::rstest;
111
112    #[rstest]
113    #[case("* * * * *", true)]
114    #[case(" *  *  *      *    * ", true)]
115    #[case("invalid", false)]
116    #[case("", false)]
117    #[case("* * * *", false)]
118    #[case("* * * * * *", false)]
119    #[case("0 0 1 1 0", true)]
120    #[case("0 0 0 1 0", false)]
121    #[case("0 0 1 0 0", false)]
122    #[case("59 23 31 12 6", true)]
123    #[case("0 0 1 may sun", true)]
124    #[case("0 0 1 may sat,sun", false)]
125    #[case("0 0 1 may,jun sat", false)]
126    #[case("0 0 1 fri sun", false)]
127    #[case("0 0 1 JAN WED", true)]
128    #[case("0,24 5,23,6 1,2,3,31 1,2 5,6", true)]
129    #[case("0-20 * * * *", true)]
130    #[case("20-0 * * * *", false)]
131    #[case("0-20/3 * * * *", true)]
132    #[case("20/3 * * * *", false)]
133    #[case("*/3 * * * *", true)]
134    #[case("*/3,2 * * * *", true)]
135    #[case("*/foo * * * *", false)]
136    #[case("1-foo * * * *", false)]
137    #[case("foo-34 * * * *", false)]
138    fn test_parse(#[case] crontab: &str, #[case] expected: bool) {
139        assert_eq!(
140            validate(crontab),
141            expected,
142            "\"{crontab}\" is {}a valid crontab",
143            match expected {
144                true => "",
145                false => "not ",
146            },
147        );
148    }
149}