Skip to content

Commit dca1ed4

Browse files
Fixes DateTime to handle extreme time zones (#334)
Signed-off-by: Mudit Chaudhary <chmudit@amazon.com>
1 parent 5f97f20 commit dca1ed4

2 files changed

Lines changed: 225 additions & 186 deletions

File tree

CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java

Lines changed: 126 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
2020
import java.time.Instant;
2121
import java.time.LocalDate;
22+
import java.time.LocalDateTime;
2223
import java.time.OffsetDateTime;
2324
import java.time.ZoneOffset;
2425
import java.time.format.DateTimeFormatter;
@@ -28,6 +29,8 @@
2829
import java.util.List;
2930
import java.util.Objects;
3031
import java.util.Optional;
32+
import java.util.regex.Matcher;
33+
import java.util.regex.Pattern;
3134

3235
/**
3336
* Represents a Cedar datetime extension value. DateTime values are encoded as strings in the
@@ -44,50 +47,137 @@ public class DateTime extends Value {
4447

4548
private static class DateTimeValidator {
4649

47-
private static final List<DateTimeFormatter> FORMATTERS = Arrays.asList(
50+
private static final Pattern OFFSET_PATTERN = Pattern.compile("([+-])(\\d{2})(\\d{2})$");
51+
52+
// Formatters for UTC datetime
53+
private static final List<DateTimeFormatter> UTC_FORMATTERS = Arrays.asList(
4854
DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT),
49-
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss'Z'")
50-
.withResolverStyle(ResolverStyle.STRICT),
51-
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'")
55+
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ssX")
5256
.withResolverStyle(ResolverStyle.STRICT),
53-
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ssXX")
54-
.withResolverStyle(ResolverStyle.STRICT),
55-
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSXX")
57+
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSX")
5658
.withResolverStyle(ResolverStyle.STRICT));
5759

60+
// Formatters for local datetime parts (without offset)
61+
private static final List<DateTimeFormatter> LOCAL_FORMATTERS = Arrays.asList(
62+
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss").withResolverStyle(ResolverStyle.STRICT),
63+
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS").withResolverStyle(ResolverStyle.STRICT));
64+
65+
// Earliest valid instant: 0000-01-01T00:00:00+2359
66+
private static final Instant MIN_INSTANT = Instant.ofEpochMilli(-62167305540000L);
67+
// Latest valid instant: 9999-12-31T23:59:59-2359
68+
private static final Instant MAX_INSTANT = Instant.ofEpochMilli(253402387139000L);
69+
70+
/**
71+
* Validates that the instant is within the allowed range.
72+
*
73+
* @param instant the parsed instant to validate
74+
* @return true if the instant is valid, false otherwise
75+
*/
76+
private static boolean isValidInstant(Instant instant) {
77+
return !instant.isBefore(MIN_INSTANT) && !instant.isAfter(MAX_INSTANT);
78+
}
79+
5880
/**
59-
* Parses a datetime string and returns the parsed Instant. Combines validation and parsing
60-
* into a single operation to avoid redundancy. All datetime formats are normalized to
61-
* Instant for consistent equality comparison.
81+
* Parses a datetime string and returns the parsed Instant.
6282
*
6383
* @param dateTimeString the string to parse
6484
* @return Optional containing the parsed Instant, or empty if parsing fails
6585
*/
6686
private static Optional<Instant> parseToInstant(String dateTimeString) {
6787
if (dateTimeString == null || dateTimeString.trim().isEmpty()) {
68-
return java.util.Optional.empty();
88+
return Optional.empty();
89+
}
90+
91+
Matcher offsetMatcher = OFFSET_PATTERN.matcher(dateTimeString);
92+
93+
Optional<Instant> result;
94+
if (offsetMatcher.find()) {
95+
result = parseWithCustomOffset(dateTimeString, offsetMatcher);
96+
} else {
97+
result = UTC_FORMATTERS.stream()
98+
.flatMap(formatter -> tryParseUTCDateTime(dateTimeString, formatter).stream())
99+
.findFirst();
69100
}
70101

71-
return FORMATTERS.stream()
72-
.flatMap(formatter -> tryParseWithFormatter(dateTimeString, formatter).stream())
73-
.findFirst();
102+
// Validate instant range
103+
if (result.isPresent() && !isValidInstant(result.get())) {
104+
return Optional.empty();
105+
}
106+
107+
return result;
74108
}
75109

76110
/**
77-
* Attempts to parse a datetime string with a specific formatter.
111+
* Parses datetime string with custom offset handling for extreme values.
112+
*
113+
* @param dateTimeString the full datetime string
114+
* @param offsetMatcher the matcher that found the offset pattern
115+
* @return Optional containing the parsed Instant, or empty if parsing fails
116+
*/
117+
private static Optional<Instant> parseWithCustomOffset(String dateTimeString, Matcher offsetMatcher) {
118+
try {
119+
String sign = offsetMatcher.group(1);
120+
int offsetHours = Integer.parseInt(offsetMatcher.group(2));
121+
int offsetMinutes = Integer.parseInt(offsetMatcher.group(3));
122+
123+
if (offsetHours > 23 || offsetMinutes > 59) {
124+
return Optional.empty();
125+
}
126+
127+
String dateTimeWithoutOffset = dateTimeString.substring(0, offsetMatcher.start());
128+
129+
Optional<LocalDateTime> localDateTime = LOCAL_FORMATTERS.stream()
130+
.flatMap(formatter -> tryParseLocalDateTime(dateTimeWithoutOffset, formatter).stream())
131+
.findFirst();
132+
133+
if (localDateTime.isEmpty()) {
134+
return Optional.empty();
135+
}
136+
137+
long epochMillis = localDateTime.get().toInstant(ZoneOffset.UTC).toEpochMilli();
138+
long offsetMillis = convertOffsetToMilliseconds(sign, offsetHours, offsetMinutes);
139+
long adjustedEpochMillis = epochMillis - offsetMillis;
140+
141+
return Optional.of(Instant.ofEpochMilli(adjustedEpochMillis));
142+
143+
} catch (Exception e) {
144+
return Optional.empty();
145+
}
146+
}
147+
148+
/**
149+
* Attempts to parse a local datetime string with a specific formatter.
150+
*
151+
* @param dateTimeString the string to parse
152+
* @param formatter the formatter to use
153+
* @return Optional containing the parsed LocalDateTime, or empty if parsing fails
154+
*/
155+
private static Optional<LocalDateTime> tryParseLocalDateTime(String dateTimeString, DateTimeFormatter formatter) {
156+
try {
157+
return Optional.of(LocalDateTime.parse(dateTimeString, formatter));
158+
} catch (DateTimeParseException e) {
159+
return Optional.empty();
160+
}
161+
}
162+
163+
/**
164+
* Attempts to parse a UTC datetime string with a specific formatter.
78165
*
79166
* @param dateTimeString the string to parse
80167
* @param formatter the formatter to use
81168
* @return Optional containing the parsed Instant, or empty if parsing fails
82169
*/
83-
private static Optional<Instant> tryParseWithFormatter(String dateTimeString,
84-
DateTimeFormatter formatter) {
170+
private static Optional<Instant> tryParseUTCDateTime(String dateTimeString, DateTimeFormatter formatter) {
85171
try {
86-
if (formatter == FORMATTERS.get(0)) {
172+
if (formatter == UTC_FORMATTERS.get(0)) {
87173
// Date-only format - convert to start of day UTC
88174
LocalDate date = LocalDate.parse(dateTimeString, formatter);
89175
return Optional.of(date.atStartOfDay(ZoneOffset.UTC).toInstant());
90176
} else {
177+
// UTC format - only accept 'Z' as timezone, not other offsets
178+
if (!dateTimeString.endsWith("Z")) {
179+
return Optional.empty();
180+
}
91181
// DateTime format - parse and convert to Instant
92182
OffsetDateTime dateTime = OffsetDateTime.parse(dateTimeString, formatter);
93183
return Optional.of(dateTime.toInstant());
@@ -96,6 +186,20 @@ private static Optional<Instant> tryParseWithFormatter(String dateTimeString,
96186
return Optional.empty();
97187
}
98188
}
189+
190+
/**
191+
* Converts timezone offset to milliseconds.
192+
*
193+
* @param sign the sign of the offset ("+" or "-")
194+
* @param hours the hours component of the offset
195+
* @param minutes the minutes component of the offset
196+
* @return offset in milliseconds
197+
*/
198+
private static long convertOffsetToMilliseconds(String sign, int hours, int minutes) {
199+
long totalMinutes = hours * 60L + minutes;
200+
long milliseconds = totalMinutes * 60L * 1000L;
201+
return "+".equals(sign) ? milliseconds : -milliseconds;
202+
}
99203
}
100204

101205
/** Datetime as a string. */
@@ -157,4 +261,8 @@ public int hashCode() {
157261
public String toString() {
158262
return dateTime;
159263
}
264+
265+
public long toEpochMilli() {
266+
return parsedInstant.toEpochMilli();
267+
}
160268
}

0 commit comments

Comments
 (0)