Skip to main content

torrust_tracker_deployer_lib/domain/backup/
cron_schedule.rs

1//! Validated cron schedule expression.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// Validated cron schedule expression (5-field format).
7///
8/// Validates that the cron expression follows the standard 5-field format:
9/// `minute hour day month weekday`
10///
11/// Examples:
12/// - `"0 3 * * *"` - 3:00 AM daily
13/// - `"0 */6 * * *"` - Every 6 hours
14/// - `"0 0 * * 0"` - Midnight every Sunday
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16pub struct CronSchedule(String);
17
18/// Errors that can occur when creating a `CronSchedule`.
19#[derive(Debug, Error, PartialEq, Eq)]
20pub enum CronScheduleError {
21    /// Cron schedule is empty
22    #[error("Cron schedule cannot be empty")]
23    Empty,
24
25    /// Cron schedule has wrong number of fields
26    #[error("Cron schedule must have 5 fields (minute hour day month weekday), got {0} fields")]
27    InvalidFieldCount(usize),
28
29    /// Cron schedule contains invalid characters
30    #[error("Cron schedule contains invalid characters: {0}")]
31    InvalidCharacters(String),
32}
33
34impl CronSchedule {
35    /// Creates a new validated cron schedule.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if:
40    /// - The schedule is empty
41    /// - The schedule doesn't have exactly 5 fields
42    /// - The schedule contains invalid characters
43    ///
44    /// # Examples
45    ///
46    /// ```
47    /// use torrust_tracker_deployer_lib::domain::backup::CronSchedule;
48    ///
49    /// let schedule = CronSchedule::new("0 3 * * *".to_string())?;
50    /// assert_eq!(schedule.as_str(), "0 3 * * *");
51    /// # Ok::<(), Box<dyn std::error::Error>>(())
52    /// ```
53    pub fn new(schedule: String) -> Result<Self, CronScheduleError> {
54        if schedule.trim().is_empty() {
55            return Err(CronScheduleError::Empty);
56        }
57
58        // Validate characters first (before splitting, to catch injection attempts)
59        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        // Validate field count (5 fields: minute hour day month weekday)
67        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    /// Returns the cron schedule as a string slice.
76    #[must_use]
77    pub fn as_str(&self) -> &str {
78        &self.0
79    }
80}
81
82impl Default for CronSchedule {
83    /// Default cron schedule: 3:00 AM daily ("0 3 * * *")
84    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}