Skip to main content

bitwarden_pam/rotation/
schedule.rs

1//! Quartz cron schedule helpers shared by every rotation surface.
2
3use serde::{Deserialize, Serialize};
4
5/// The canonical Quartz cron expression for each named preset. Quartz has 6 or 7 fields
6/// (`seconds minutes hours day-of-month month day-of-week [year]`), one more than UNIX cron.
7const HOURLY_CRON: &str = "0 0 * * * ?";
8const EVERY_6_HOURS_CRON: &str = "0 0 */6 * * ?";
9const DAILY_CRON: &str = "0 0 0 * * ?";
10const WEEKLY_CRON: &str = "0 0 0 ? * SUN";
11const MONTHLY_CRON: &str = "0 0 0 1 * ?";
12
13/// A named rotation schedule, for presentation only; the server stores only the cron string.
14#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
15#[bitwarden_ffi::wasm_record]
16#[serde(rename_all = "snake_case")]
17pub enum QuartzSchedulePreset {
18    /// No scheduled rotation. On-demand and access-end rotations still apply.
19    None,
20    /// Every hour, on the hour.
21    Hourly,
22    /// Every six hours, starting at midnight.
23    Every6Hours,
24    /// Every day at midnight.
25    Daily,
26    /// Every Sunday at midnight.
27    Weekly,
28    /// The first day of every month, at midnight.
29    Monthly,
30    /// An operator-authored expression that matches no preset.
31    Custom,
32}
33
34impl QuartzSchedulePreset {
35    /// The cron expression for this preset, or `None` for [`None`](QuartzSchedulePreset::None)
36    /// and [`Custom`](QuartzSchedulePreset::Custom), which have no fixed expression.
37    pub fn cron(&self) -> Option<&'static str> {
38        match self {
39            Self::Hourly => Some(HOURLY_CRON),
40            Self::Every6Hours => Some(EVERY_6_HOURS_CRON),
41            Self::Daily => Some(DAILY_CRON),
42            Self::Weekly => Some(WEEKLY_CRON),
43            Self::Monthly => Some(MONTHLY_CRON),
44            Self::None | Self::Custom => None,
45        }
46    }
47}
48
49/// Derives the preset that best describes a stored cron expression. Absent or blank is
50/// [`QuartzSchedulePreset::None`], an exact match (ignoring surrounding whitespace) is that
51/// preset, and anything else is [`Custom`](QuartzSchedulePreset::Custom).
52pub fn preset_for_cron(cron: Option<&str>) -> QuartzSchedulePreset {
53    let Some(cron) = cron else {
54        return QuartzSchedulePreset::None;
55    };
56
57    let trimmed = cron.trim();
58    if trimmed.is_empty() {
59        return QuartzSchedulePreset::None;
60    }
61
62    [
63        QuartzSchedulePreset::Hourly,
64        QuartzSchedulePreset::Every6Hours,
65        QuartzSchedulePreset::Daily,
66        QuartzSchedulePreset::Weekly,
67        QuartzSchedulePreset::Monthly,
68    ]
69    .into_iter()
70    .find(|preset| preset.cron() == Some(trimmed))
71    .unwrap_or(QuartzSchedulePreset::Custom)
72}
73
74/// Reports whether a string is shaped like a Quartz cron expression. Checks only field count and
75/// character set; the server is authoritative.
76pub fn is_likely_quartz_cron(value: &str) -> bool {
77    let trimmed = value.trim();
78    if trimmed.is_empty() {
79        return false;
80    }
81
82    let fields: Vec<&str> = trimmed.split_whitespace().collect();
83    if fields.len() < 6 || fields.len() > 7 {
84        return false;
85    }
86
87    fields.iter().all(|field| {
88        !field.is_empty()
89            && field.chars().all(|c| {
90                c.is_ascii_alphanumeric()
91                    || matches!(c, '*' | '/' | ',' | '-' | '?' | '#' | 'L' | 'W')
92            })
93    })
94}
95
96/// The WASM-facing surface for the schedule helpers.
97#[bitwarden_ffi::wasm_object]
98pub struct RotationScheduleClient;
99
100#[bitwarden_ffi::wasm_export]
101impl RotationScheduleClient {
102    /// See [`preset_for_cron`].
103    pub fn preset_for_cron(&self, cron: Option<String>) -> QuartzSchedulePreset {
104        preset_for_cron(cron.as_deref())
105    }
106
107    /// The cron expression for a preset, or `None` absent a fixed expression.
108    pub fn cron_for_preset(&self, preset: QuartzSchedulePreset) -> Option<String> {
109        preset.cron().map(ToString::to_string)
110    }
111
112    /// See [`is_likely_quartz_cron`].
113    pub fn is_likely_quartz_cron(&self, value: String) -> bool {
114        is_likely_quartz_cron(&value)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn no_cron_is_the_none_preset() {
124        assert_eq!(preset_for_cron(None), QuartzSchedulePreset::None);
125    }
126
127    #[test]
128    fn each_preset_round_trips_through_its_own_cron() {
129        for preset in [
130            QuartzSchedulePreset::Hourly,
131            QuartzSchedulePreset::Every6Hours,
132            QuartzSchedulePreset::Daily,
133            QuartzSchedulePreset::Weekly,
134            QuartzSchedulePreset::Monthly,
135        ] {
136            let cron = preset.cron().expect("a named preset has a cron");
137            assert_eq!(preset_for_cron(Some(cron)), preset, "for {cron}");
138        }
139    }
140
141    #[test]
142    fn surrounding_whitespace_does_not_hide_a_preset() {
143        assert_eq!(
144            preset_for_cron(Some("  0 0 0 * * ?  ")),
145            QuartzSchedulePreset::Daily
146        );
147    }
148
149    #[test]
150    fn an_unrecognized_expression_is_custom() {
151        assert_eq!(
152            preset_for_cron(Some("0 30 9 ? * MON-FRI")),
153            QuartzSchedulePreset::Custom
154        );
155    }
156
157    /// A blank string is not a schedule; reporting `Custom` would render an empty cron as though
158    /// the operator had authored one.
159    #[test]
160    fn a_blank_expression_is_not_a_custom_schedule() {
161        assert_eq!(preset_for_cron(Some("   ")), QuartzSchedulePreset::None);
162    }
163
164    /// The presets differ only in a couple of fields, so a dropped or extra field would still look
165    /// plausible; counting the fields catches it.
166    #[test]
167    fn preset_crons_are_six_field_quartz_expressions() {
168        for preset in [
169            QuartzSchedulePreset::Hourly,
170            QuartzSchedulePreset::Every6Hours,
171            QuartzSchedulePreset::Daily,
172            QuartzSchedulePreset::Weekly,
173            QuartzSchedulePreset::Monthly,
174        ] {
175            let cron = preset.cron().expect("a named preset has a cron");
176            assert_eq!(cron.split_whitespace().count(), 6, "for {cron}");
177            assert!(is_likely_quartz_cron(cron), "for {cron}");
178        }
179    }
180
181    #[test]
182    fn none_and_custom_have_no_fixed_cron() {
183        assert_eq!(QuartzSchedulePreset::None.cron(), None);
184        assert_eq!(QuartzSchedulePreset::Custom.cron(), None);
185    }
186
187    #[test]
188    fn six_and_seven_field_expressions_are_accepted() {
189        assert!(is_likely_quartz_cron("0 0 0 * * ?"));
190        assert!(is_likely_quartz_cron("0 0 0 * * ? 2027"));
191    }
192
193    #[test]
194    fn field_counts_outside_six_or_seven_are_rejected() {
195        // A 5-field UNIX cron is the likeliest mistake, and Quartz would misread every field.
196        assert!(!is_likely_quartz_cron("0 0 * * *"));
197        assert!(!is_likely_quartz_cron("0 0 0 * * ? 2027 extra"));
198    }
199
200    #[test]
201    fn blank_input_is_rejected() {
202        assert!(!is_likely_quartz_cron(""));
203        assert!(!is_likely_quartz_cron("      "));
204    }
205
206    #[test]
207    fn quartz_special_characters_are_accepted() {
208        assert!(is_likely_quartz_cron("0 0 0 L * ?"));
209        assert!(is_likely_quartz_cron("0 0 0 ? * 6#3"));
210        assert!(is_likely_quartz_cron("0 0 0 1W * ?"));
211        assert!(is_likely_quartz_cron("0 0/15 * * * ?"));
212    }
213
214    #[test]
215    fn characters_outside_the_quartz_set_are_rejected() {
216        assert!(!is_likely_quartz_cron("0 0 0 * * ; rm -rf /"));
217        assert!(!is_likely_quartz_cron("0 0 0 * * $(date)"));
218    }
219
220    /// `split_whitespace` collapses runs of spaces, so irregular spacing is not read as an extra
221    /// empty field.
222    #[test]
223    fn runs_of_internal_whitespace_collapse() {
224        assert!(is_likely_quartz_cron("0  0   0 * * ?"));
225        assert_eq!(
226            preset_for_cron(Some("0 0 0 * * ?")),
227            QuartzSchedulePreset::Daily
228        );
229    }
230}