33import io .sentry .MonitorConfig ;
44import io .sentry .MonitorSchedule ;
55import io .sentry .MonitorScheduleUnit ;
6+ import java .util .ArrayList ;
7+ import java .util .Arrays ;
68import java .util .Collections ;
79import java .util .HashMap ;
10+ import java .util .HashSet ;
11+ import java .util .List ;
812import java .util .Locale ;
913import java .util .Map ;
14+ import java .util .Set ;
15+ import java .util .TimeZone ;
1016import java .util .concurrent .TimeUnit ;
1117import java .util .regex .Matcher ;
1218import java .util .regex .Pattern ;
@@ -22,6 +28,34 @@ public final class MonitorConfigUtils {
2228 private static final @ NotNull Map <String , String > CRON_MACROS ;
2329 private static final @ NotNull Pattern SIMPLE_DURATION =
2430 Pattern .compile ("^([+-]?\\ d+)([a-zA-Z]{0,2})$" );
31+ private static final @ NotNull Pattern FIXED_OFFSET =
32+ Pattern .compile ("^(?:GMT|UTC)?([+-])(\\ d{1,2})(?::?(\\ d{2}))?$" );
33+ private static final @ NotNull Pattern DIGITS = Pattern .compile ("\\ d+" );
34+
35+ private static final int DAY_OF_MONTH = 2 ;
36+ private static final int MONTH = 3 ;
37+ private static final int DAY_OF_WEEK = 4 ;
38+ private static final int [] FIELD_MIN = {0 , 0 , 1 , 1 , 0 };
39+ private static final int [] FIELD_MAX = {59 , 23 , 31 , 12 , 7 };
40+ private static final int LAST = -1 ;
41+ private static final int [] DAYS_IN_MONTH = {31 , 29 , 31 , 30 , 31 , 30 , 31 , 31 , 30 , 31 , 30 , 31 };
42+ private static final @ NotNull List <String > MONTH_NAMES =
43+ Arrays .asList (
44+ "JAN" , "FEB" , "MAR" , "APR" , "MAY" , "JUN" , "JUL" , "AUG" , "SEP" , "OCT" , "NOV" , "DEC" );
45+ private static final @ NotNull List <String > DAY_NAMES =
46+ Arrays .asList ("SUN" , "MON" , "TUE" , "WED" , "THU" , "FRI" , "SAT" );
47+
48+ /** IANA zones without a slash; Java also knows non-IANA short IDs like PST. */
49+ private static final @ NotNull Set <String > TOP_LEVEL_ZONES =
50+ new HashSet <>(
51+ Arrays .asList (
52+ ("CET CST6CDT Cuba EET EST EST5EDT Egypt Eire GB GB-Eire GMT GMT+0 GMT-0 GMT0 "
53+ + "Greenwich HST Hongkong Iceland Iran Israel Jamaica Japan Kwajalein Libya "
54+ + "MET MST MST7MDT NZ NZ-CHAT Navajo PRC PST8PDT Poland Portugal ROC ROK "
55+ + "Singapore Turkey UCT UTC Universal W-SU WET Zulu" )
56+ .split (" " )));
57+
58+ private static volatile @ Nullable Set <String > availableZoneIds ;
2559
2660 static {
2761 final @ NotNull Map <String , String > macros = new HashMap <>();
@@ -60,7 +94,11 @@ private MonitorConfigUtils() {}
6094 }
6195 final @ NotNull MonitorConfig config = new MonitorConfig (MonitorSchedule .crontab (crontab ));
6296 if (zone != null && !zone .isEmpty ()) {
63- config .setTimezone (zone );
97+ final @ Nullable String timezone = toTimezone (zone );
98+ if (timezone == null ) {
99+ return null ;
100+ }
101+ config .setTimezone (timezone );
64102 }
65103 return config ;
66104 }
@@ -71,11 +109,19 @@ private MonitorConfigUtils() {}
71109 if (millis < MINUTE_MILLIS || millis % MINUTE_MILLIS != 0 ) {
72110 return null ;
73111 }
74- final long minutes = millis / MINUTE_MILLIS ;
75- if (minutes > Integer .MAX_VALUE ) {
112+ long value = millis / MINUTE_MILLIS ;
113+ @ NotNull MonitorScheduleUnit unit = MonitorScheduleUnit .MINUTE ;
114+ if (value % (24 * 60 ) == 0 ) {
115+ value /= 24 * 60 ;
116+ unit = MonitorScheduleUnit .DAY ;
117+ } else if (value % 60 == 0 ) {
118+ value /= 60 ;
119+ unit = MonitorScheduleUnit .HOUR ;
120+ }
121+ if (value > Integer .MAX_VALUE ) {
76122 return null ;
77123 }
78- return new MonitorConfig (MonitorSchedule .interval ((int ) minutes , MonitorScheduleUnit . MINUTE ));
124+ return new MonitorConfig (MonitorSchedule .interval ((int ) value , unit ));
79125 }
80126 return null ;
81127 }
@@ -129,7 +175,50 @@ private MonitorConfigUtils() {}
129175 }
130176 }
131177
132- /** Drops a fixed seconds field; returns null otherwise, as crontab has no seconds. */
178+ /**
179+ * Returns a zone ID Sentry accepts: IANA region IDs as is, whole-hour fixed offsets like {@code
180+ * GMT+2} as {@code Etc/GMT-2}, null otherwise.
181+ */
182+ static @ Nullable String toTimezone (final @ NotNull String zone ) {
183+ final @ NotNull Matcher offset = FIXED_OFFSET .matcher (zone );
184+ if (offset .matches ()) {
185+ final @ Nullable String minutes = offset .group (3 );
186+ if (minutes != null && !"00" .equals (minutes )) {
187+ return null ;
188+ }
189+ final int hours = Integer .parseInt (offset .group (2 ));
190+ if (hours == 0 ) {
191+ return "Etc/GMT" ;
192+ }
193+ final boolean east = "+" .equals (offset .group (1 ));
194+ if (hours > (east ? 14 : 12 )) {
195+ return null ;
196+ }
197+ // Etc/GMT signs are inverted
198+ return "Etc/GMT" + (east ? "-" : "+" ) + hours ;
199+ }
200+ if (TOP_LEVEL_ZONES .contains (zone )) {
201+ return zone ;
202+ }
203+ if (zone .indexOf ('/' ) > 0 && !zone .startsWith ("SystemV/" ) && availableZones ().contains (zone )) {
204+ return zone ;
205+ }
206+ return null ;
207+ }
208+
209+ private static @ NotNull Set <String > availableZones () {
210+ @ Nullable Set <String > zones = availableZoneIds ;
211+ if (zones == null ) {
212+ zones = new HashSet <>(Arrays .asList (TimeZone .getAvailableIDs ()));
213+ availableZoneIds = zones ;
214+ }
215+ return zones ;
216+ }
217+
218+ /**
219+ * Drops a fixed seconds field. Returns null if Sentry would reject the result or read it
220+ * differently, as crontab has no seconds and ORs day of month and day of week.
221+ */
133222 static @ Nullable String toCrontab (final @ NotNull String cron ) {
134223 final @ NotNull String trimmed = cron .trim ();
135224 if (CRON_DISABLED .equals (trimmed )) {
@@ -139,17 +228,152 @@ private MonitorConfigUtils() {}
139228 return CRON_MACROS .get (trimmed .toLowerCase (Locale .ROOT ));
140229 }
141230 final @ NotNull String [] fields = trimmed .split ("\\ s+" , -1 );
142- if (fields .length != 6 || !fields [0 ].matches ("\\ d{1,2}" )) {
231+ if (fields .length != 6 || !fields [0 ].matches ("\\ d{1,2}" ) || Integer . parseInt ( fields [ 0 ]) > 59 ) {
143232 return null ;
144233 }
145- final @ NotNull StringBuilder crontab = new StringBuilder ();
146- for (int i = 1 ; i < fields .length ; i ++) {
147- if (i > 1 ) {
148- crontab .append (' ' );
149- }
234+ final @ NotNull String [] crontab = new String [5 ];
235+ for (int i = 0 ; i < crontab .length ; i ++) {
236+ final @ NotNull String field = fields [i + 1 ];
150237 // '?' means '*' in Spring and Quartz cron
151- crontab .append ("?" .equals (fields [i ]) ? "*" : fields [i ]);
238+ final boolean dayField = i == DAY_OF_MONTH || i == DAY_OF_WEEK ;
239+ crontab [i ] = dayField && "?" .equals (field ) ? "*" : field ;
240+ }
241+ // crontab ORs day of month and day of week unless one starts with '*', Spring and Quartz AND
242+ if (!crontab [DAY_OF_MONTH ].startsWith ("*" ) && !crontab [DAY_OF_WEEK ].startsWith ("*" )) {
243+ return null ;
244+ }
245+ final @ NotNull List <Set <Integer >> values = new ArrayList <>();
246+ for (int i = 0 ; i < crontab .length ; i ++) {
247+ final @ Nullable Set <Integer > parsed = parseField (crontab [i ].toUpperCase (Locale .ROOT ), i );
248+ if (parsed == null ) {
249+ return null ;
250+ }
251+ values .add (parsed );
252+ }
253+ if (!dayExistsInMonths (values .get (DAY_OF_MONTH ), values .get (MONTH ))) {
254+ return null ;
255+ }
256+ final @ NotNull StringBuilder result = new StringBuilder ();
257+ for (int i = 0 ; i < crontab .length ; i ++) {
258+ if (i > 0 ) {
259+ result .append (' ' );
260+ }
261+ result .append (crontab [i ]);
262+ }
263+ return result .toString ();
264+ }
265+
266+ // Mirrors the parser Sentry uses (cronsim). Returns null for anything it rejects or that we
267+ // can't send as is. Day of week L and # entries are not expanded, as only the count matters.
268+ private static @ Nullable Set <Integer > parseField (final @ NotNull String field , final int index ) {
269+ final @ NotNull Set <Integer > result = new HashSet <>();
270+ for (final @ NotNull String term : field .split ("," , -1 )) {
271+ final @ Nullable Set <Integer > parsed = parseTerm (term , index );
272+ if (parsed == null ) {
273+ return null ;
274+ }
275+ result .addAll (parsed );
276+ }
277+ return result ;
278+ }
279+
280+ private static @ Nullable Set <Integer > parseTerm (final @ NotNull String term , final int index ) {
281+ if ("*" .equals (term )) {
282+ return range (FIELD_MIN [index ], FIELD_MAX [index ], 1 );
283+ }
284+ if (index == DAY_OF_WEEK && term .indexOf ('L' ) >= 0 ) {
285+ final @ NotNull String day = term .substring (0 , term .length () - 1 );
286+ if (!term .endsWith ("L" ) || !DIGITS .matcher (day ).matches () || value (day , index ) == null ) {
287+ return null ;
288+ }
289+ return Collections .singleton (LAST );
290+ }
291+ if (index == DAY_OF_WEEK && term .indexOf ('#' ) >= 0 ) {
292+ final int hash = term .indexOf ('#' );
293+ final @ Nullable Integer nth = number (term .substring (hash + 1 ));
294+ if (nth == null || nth < 1 || nth > 5 || value (term .substring (0 , hash ), index ) == null ) {
295+ return null ;
296+ }
297+ return Collections .singleton (LAST );
298+ }
299+ if (term .indexOf ('/' ) >= 0 ) {
300+ final int slash = term .indexOf ('/' );
301+ final @ Nullable Integer step = number (term .substring (slash + 1 ));
302+ final @ NotNull String base = term .substring (0 , slash );
303+ if (step == null || step == 0 || "L" .equals (base ) || "LW" .equals (base )) {
304+ return null ;
305+ }
306+ final @ Nullable Set <Integer > items = parseTerm (base , index );
307+ if (items == null ) {
308+ return null ;
309+ }
310+ if (items .size () == 1 ) {
311+ return range (items .iterator ().next (), FIELD_MAX [index ], step );
312+ }
313+ final @ NotNull List <Integer > sorted = new ArrayList <>(items );
314+ Collections .sort (sorted );
315+ final @ NotNull Set <Integer > result = new HashSet <>();
316+ for (int i = 0 ; i < sorted .size (); i += step ) {
317+ result .add (sorted .get (i ));
318+ }
319+ return result ;
320+ }
321+ if (term .indexOf ('-' ) >= 0 ) {
322+ final int dash = term .indexOf ('-' );
323+ final @ Nullable Integer start = value (term .substring (0 , dash ), index );
324+ final @ Nullable Integer end = value (term .substring (dash + 1 ), index );
325+ if (start == null || end == null || end < start ) {
326+ return null ;
327+ }
328+ return range (start , end , 1 );
329+ }
330+ if (index == DAY_OF_MONTH && ("L" .equals (term ) || "LW" .equals (term ))) {
331+ return Collections .singleton (LAST );
332+ }
333+ final @ Nullable Integer value = value (term , index );
334+ return value == null ? null : Collections .singleton (value );
335+ }
336+
337+ private static @ Nullable Integer value (final @ NotNull String value , final int index ) {
338+ if (index == MONTH && MONTH_NAMES .contains (value )) {
339+ return MONTH_NAMES .indexOf (value ) + 1 ;
340+ }
341+ if (index == DAY_OF_WEEK && DAY_NAMES .contains (value )) {
342+ return DAY_NAMES .indexOf (value );
343+ }
344+ final @ Nullable Integer number = number (value );
345+ if (number == null || number < FIELD_MIN [index ] || number > FIELD_MAX [index ]) {
346+ return null ;
347+ }
348+ return number ;
349+ }
350+
351+ private static @ Nullable Integer number (final @ NotNull String value ) {
352+ if (value .isEmpty () || value .length () > 9 || !DIGITS .matcher (value ).matches ()) {
353+ return null ;
354+ }
355+ return Integer .parseInt (value );
356+ }
357+
358+ private static @ NotNull Set <Integer > range (final int start , final int end , final int step ) {
359+ final @ NotNull Set <Integer > result = new HashSet <>();
360+ for (int i = start ; i <= end ; i += step ) {
361+ result .add (i );
362+ }
363+ return result ;
364+ }
365+
366+ private static boolean dayExistsInMonths (
367+ final @ NotNull Set <Integer > days , final @ NotNull Set <Integer > months ) {
368+ final int firstDay = Collections .min (days );
369+ if (firstDay <= 29 ) {
370+ return true ;
371+ }
372+ for (final int month : months ) {
373+ if (firstDay <= DAYS_IN_MONTH [month - 1 ]) {
374+ return true ;
375+ }
152376 }
153- return crontab . toString () ;
377+ return false ;
154378 }
155379}
0 commit comments