torrust_tracker_deployer_lib/domain/backup/
cron_schedule.rs1use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16pub struct CronSchedule(String);
17
18#[derive(Debug, Error, PartialEq, Eq)]
20pub enum CronScheduleError {
21 #[error("Cron schedule cannot be empty")]
23 Empty,
24
25 #[error("Cron schedule must have 5 fields (minute hour day month weekday), got {0} fields")]
27 InvalidFieldCount(usize),
28
29 #[error("Cron schedule contains invalid characters: {0}")]
31 InvalidCharacters(String),
32}
33
34impl CronSchedule {
35 pub fn new(schedule: String) -> Result<Self, CronScheduleError> {
54 if schedule.trim().is_empty() {
55 return Err(CronScheduleError::Empty);
56 }
57
58 let valid_chars = |c: char| c.is_ascii_digit() || matches!(c, '*' | '-' | '/' | ',' | ' ');
60 if let Some(invalid) = schedule.chars().find(|c| !valid_chars(*c)) {
61 return Err(CronScheduleError::InvalidCharacters(format!(
62 "found '{invalid}'"
63 )));
64 }
65
66 let fields: Vec<&str> = schedule.split_whitespace().collect();
68 if fields.len() != 5 {
69 return Err(CronScheduleError::InvalidFieldCount(fields.len()));
70 }
71
72 Ok(Self(schedule))
73 }
74
75 #[must_use]
77 pub fn as_str(&self) -> &str {
78 &self.0
79 }
80}
81
82impl Default for CronSchedule {
83 fn default() -> Self {
85 Self("0 3 * * *".to_string())
86 }
87}
88
89impl<'de> Deserialize<'de> for CronSchedule {
90 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
91 where
92 D: serde::Deserializer<'de>,
93 {
94 let schedule = String::deserialize(deserializer)?;
95 Self::new(schedule).map_err(serde::de::Error::custom)
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use rstest::rstest;
102
103 use super::*;
104
105 #[rstest]
106 #[case("0 3 * * *", "3:00 AM daily")]
107 #[case("0 */6 * * *", "Every 6 hours")]
108 #[case("0 0 * * 0", "Midnight every Sunday")]
109 #[case("30 2 1 * *", "2:30 AM on the 1st of every month")]
110 #[case("0 0 1,15 * *", "Midnight on 1st and 15th")]
111 #[case("*/15 * * * *", "Every 15 minutes")]
112 #[case("0 9-17 * * 1-5", "9 AM to 5 PM, Monday to Friday")]
113 fn it_should_accept_valid_cron_schedules(#[case] schedule: &str, #[case] description: &str) {
114 let result = CronSchedule::new(schedule.to_string());
115 assert!(
116 result.is_ok(),
117 "Schedule '{schedule}' ({description}) should be valid, got error: {result:?}"
118 );
119 }
120
121 #[rstest]
122 #[case("")]
123 #[case(" ")]
124 fn it_should_reject_empty_schedule(#[case] schedule: &str) {
125 let result = CronSchedule::new(schedule.to_string());
126 assert_eq!(result, Err(CronScheduleError::Empty));
127 }
128
129 #[rstest]
130 #[case("0 3 *", 3)]
131 #[case("0 3", 2)]
132 #[case("0 3 * * * *", 6)]
133 #[case("0 3 * * * * 2026", 7)]
134 fn it_should_reject_wrong_field_count(#[case] schedule: &str, #[case] expected_count: usize) {
135 let result = CronSchedule::new(schedule.to_string());
136 assert_eq!(
137 result,
138 Err(CronScheduleError::InvalidFieldCount(expected_count)),
139 "Schedule '{schedule}' should be rejected"
140 );
141 }
142
143 #[rstest]
144 #[case("0 3 * * * #comment", "Contains #")]
145 #[case("0 3 * * MON", "Contains letters")]
146 #[case("0 3 * * ?", "Contains ?")]
147 #[case("0 3 * * *; rm -rf /", "Command injection attempt")]
148 fn it_should_reject_invalid_characters(#[case] schedule: &str, #[case] reason: &str) {
149 let result = CronSchedule::new(schedule.to_string());
150 assert!(
151 matches!(result, Err(CronScheduleError::InvalidCharacters(_))),
152 "Schedule '{schedule}' ({reason}) should be rejected as invalid characters, got: {result:?}"
153 );
154 }
155
156 #[test]
157 fn it_should_return_schedule_as_string() {
158 let schedule = CronSchedule::new("0 3 * * *".to_string()).expect("valid schedule");
159 assert_eq!(schedule.as_str(), "0 3 * * *");
160 }
161
162 #[test]
163 fn it_should_use_sensible_default() {
164 let schedule = CronSchedule::default();
165 assert_eq!(schedule.as_str(), "0 3 * * *");
166 }
167
168 #[test]
169 fn it_should_deserialize_valid_cron_schedule() {
170 let json = r#""0 3 * * *""#;
171 let schedule: CronSchedule = serde_json::from_str(json).expect("valid schedule");
172 assert_eq!(schedule.as_str(), "0 3 * * *");
173 }
174
175 #[rstest]
176 #[case(r#""""#, "Empty")]
177 #[case(r#""0 3""#, "Too few fields")]
178 #[case(r#""0 3 * * * *""#, "Too many fields")]
179 #[case(r#""0 3 * * MON""#, "Invalid characters")]
180 fn it_should_reject_invalid_schedule_during_deserialization(
181 #[case] json: &str,
182 #[case] reason: &str,
183 ) {
184 let result: Result<CronSchedule, _> = serde_json::from_str(json);
185 assert!(
186 result.is_err(),
187 "JSON '{json}' ({reason}) should fail deserialization"
188 );
189 }
190
191 #[test]
192 fn it_should_serialize_and_deserialize_correctly() {
193 let original = CronSchedule::new("0 3 * * *".to_string()).expect("valid schedule");
194 let json = serde_json::to_string(&original).expect("serialization should succeed");
195 let deserialized: CronSchedule =
196 serde_json::from_str(&json).expect("deserialization should succeed");
197
198 assert_eq!(original, deserialized);
199 }
200}