bitwarden_pam/rotation/
schedule.rs1use serde::{Deserialize, Serialize};
4#[cfg(feature = "wasm")]
5use tsify::Tsify;
6#[cfg(feature = "wasm")]
7use wasm_bindgen::prelude::wasm_bindgen;
8
9const 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#[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 None,
24 Hourly,
26 Every6Hours,
28 Daily,
30 Weekly,
32 Monthly,
34 Custom,
36}
37
38impl QuartzSchedulePreset {
39 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
53pub 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
78pub 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#[cfg_attr(feature = "wasm", wasm_bindgen)]
102pub struct RotationScheduleClient;
103
104#[cfg_attr(feature = "wasm", wasm_bindgen)]
105impl RotationScheduleClient {
106 pub fn preset_for_cron(&self, cron: Option<String>) -> QuartzSchedulePreset {
108 preset_for_cron(cron.as_deref())
109 }
110
111 pub fn cron_for_preset(&self, preset: QuartzSchedulePreset) -> Option<String> {
113 preset.cron().map(ToString::to_string)
114 }
115
116 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 #[test]
164 fn a_blank_expression_is_not_a_custom_schedule() {
165 assert_eq!(preset_for_cron(Some(" ")), QuartzSchedulePreset::None);
166 }
167
168 #[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 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 #[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}