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