Skip to content

Commit da4ff66

Browse files
MonitorSchedule constructor that validates crontab syntax (#625)
* MonitorSchedule constructor the validates crontab syntax * Added tests, and made improvemnts * Applied linter fixes * Deleted commented out code * from_crontab now returns Result * Implement error for CrontabParseError * Removed `is_ok_and` call
1 parent 80e5b13 commit da4ff66

File tree

5 files changed

+278
-0
lines changed

5 files changed

+278
-0
lines changed

Cargo.lock

Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sentry-types/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ thiserror = "1.0.15"
3030
time = { version = "0.3.5", features = ["formatting", "parsing"] }
3131
url = { version = "2.1.1", features = ["serde"] }
3232
uuid = { version = "1.0.0", features = ["serde"] }
33+
34+
[dev-dependencies]
35+
rstest = "0.18.2"

sentry-types/src/crontab_validator.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
use std::ops::RangeInclusive;
2+
3+
struct 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+
11+
const MONTHS: &[&str] = &[
12+
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
13+
];
14+
15+
const DAYS: &[&str] = &["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
16+
17+
const 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+
40+
fn 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+
55+
fn validate_step(step: &str) -> bool {
56+
match step.parse::<u64>() {
57+
Ok(value) => value > 0,
58+
Err(_) => false,
59+
}
60+
}
61+
62+
fn 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+
76+
fn 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+
83+
fn validate_list(list: &str, allowed_values: &SegmentAllowedValues) -> bool {
84+
list.split(',')
85+
.all(|listitem| validate_listitem(listitem, allowed_values))
86+
}
87+
88+
fn 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+
95+
pub 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)]
108+
mod 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+
}

sentry-types/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
mod macros;
4141

4242
mod auth;
43+
mod crontab_validator;
4344
mod dsn;
4445
mod project_id;
4546
pub mod protocol;

sentry-types/src/protocol/monitor.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,50 @@
1+
use std::{error::Error, fmt::Display};
2+
13
use serde::{Deserialize, Serialize, Serializer};
24
use uuid::Uuid;
35

6+
use crate::crontab_validator;
7+
8+
/// Error type for errors with parsing a crontab schedule
9+
#[derive(Debug)]
10+
pub struct CrontabParseError {
11+
invalid_crontab: String,
12+
}
13+
14+
impl Display for CrontabParseError {
15+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16+
write!(
17+
f,
18+
"\"{}\" is not a valid crontab schedule.\n\t \
19+
For help determining why this schedule is invalid, you can use this site: \
20+
https://crontab.guru/#{}",
21+
self.invalid_crontab,
22+
self.invalid_crontab
23+
.split_whitespace()
24+
.collect::<Vec<_>>()
25+
.join("_"),
26+
)
27+
}
28+
}
29+
30+
impl Error for CrontabParseError {}
31+
32+
impl CrontabParseError {
33+
/// Constructs a new CrontabParseError from a given invalid crontab string
34+
///
35+
/// ## Example
36+
/// ```
37+
/// use sentry_types::protocol::v7::CrontabParseError;
38+
///
39+
/// let error = CrontabParseError::new("* * * *");
40+
/// ```
41+
pub fn new(invalid_crontab: &str) -> Self {
42+
Self {
43+
invalid_crontab: String::from(invalid_crontab),
44+
}
45+
}
46+
}
47+
448
/// Represents the status of the monitor check-in
549
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
650
#[serde(rename_all = "snake_case")]
@@ -39,6 +83,39 @@ pub enum MonitorSchedule {
3983
},
4084
}
4185

86+
impl MonitorSchedule {
87+
/// Attempts to create a MonitorSchedule from a provided crontab_str. If the crontab_str is a
88+
/// valid crontab schedule, we return a Result containing the MonitorSchedule; otherwise, we
89+
/// return a Result containing a CrontabParseError.
90+
///
91+
/// ## Example with valid crontab
92+
/// ```
93+
/// use sentry_types::protocol::v7::MonitorSchedule;
94+
///
95+
/// // Create a crontab that runs every other day of the month at midnight.
96+
/// let result = MonitorSchedule::from_crontab("0 0 */2 * *");
97+
/// assert!(result.is_ok())
98+
/// ```
99+
///
100+
/// ## Example with an invalid crontab
101+
/// ```
102+
/// use sentry_types::protocol::v7::MonitorSchedule;
103+
///
104+
/// // Invalid crontab.
105+
/// let result = MonitorSchedule::from_crontab("invalid");
106+
/// assert!(result.is_err());
107+
/// ```
108+
pub fn from_crontab(crontab_str: &str) -> Result<Self, CrontabParseError> {
109+
if crontab_validator::validate(crontab_str) {
110+
Ok(Self::Crontab {
111+
value: String::from(crontab_str),
112+
})
113+
} else {
114+
Err(CrontabParseError::new(crontab_str))
115+
}
116+
}
117+
}
118+
42119
/// The unit for the interval schedule type
43120
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
44121
#[serde(rename_all = "snake_case")]

0 commit comments

Comments
 (0)