bitwarden_pam/rotation/
schedule.rs1use serde::{Deserialize, Serialize};
4
5const 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#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
15#[bitwarden_ffi::wasm_record]
16#[serde(rename_all = "snake_case")]
17pub enum QuartzSchedulePreset {
18 None,
20 Hourly,
22 Every6Hours,
24 Daily,
26 Weekly,
28 Monthly,
30 Custom,
32}
33
34impl QuartzSchedulePreset {
35 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
49pub 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
74pub 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#[bitwarden_ffi::wasm_object]
98pub struct RotationScheduleClient;
99
100#[bitwarden_ffi::wasm_export]
101impl RotationScheduleClient {
102 pub fn preset_for_cron(&self, cron: Option<String>) -> QuartzSchedulePreset {
104 preset_for_cron(cron.as_deref())
105 }
106
107 pub fn cron_for_preset(&self, preset: QuartzSchedulePreset) -> Option<String> {
109 preset.cron().map(ToString::to_string)
110 }
111
112 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 #[test]
160 fn a_blank_expression_is_not_a_custom_schedule() {
161 assert_eq!(preset_for_cron(Some(" ")), QuartzSchedulePreset::None);
162 }
163
164 #[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 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 #[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}