Skip to content

Commit 28761ae

Browse files
committed
fix(core): Only send monitor configs Sentry reads the same way
Return no config when day of month and day of week are both set, for cron syntax Sentry rejects, and for zones that are not IANA region IDs. Whole-hour fixed offsets map to Etc/GMT zones. Intervals use hours or days when they divide evenly.
1 parent ea62aa1 commit 28761ae

2 files changed

Lines changed: 372 additions & 15 deletions

File tree

‎sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java‎

Lines changed: 237 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@
33
import io.sentry.MonitorConfig;
44
import io.sentry.MonitorSchedule;
55
import io.sentry.MonitorScheduleUnit;
6+
import java.util.ArrayList;
7+
import java.util.Arrays;
68
import java.util.Collections;
79
import java.util.HashMap;
10+
import java.util.HashSet;
11+
import java.util.List;
812
import java.util.Locale;
913
import java.util.Map;
14+
import java.util.Set;
15+
import java.util.TimeZone;
1016
import java.util.concurrent.TimeUnit;
1117
import java.util.regex.Matcher;
1218
import 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

Comments
 (0)