Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 126 additions & 18 deletions CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
Expand All @@ -28,6 +29,8 @@
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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

private static class DateTimeValidator {

private static final List<DateTimeFormatter> FORMATTERS = Arrays.asList(
private static final Pattern OFFSET_PATTERN = Pattern.compile("([+-])(\\d{2})(\\d{2})$");

// Formatters for UTC datetime
private static final List<DateTimeFormatter> UTC_FORMATTERS = Arrays.asList(
DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT),
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss'Z'")
.withResolverStyle(ResolverStyle.STRICT),
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'")
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ssX")
.withResolverStyle(ResolverStyle.STRICT),
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ssXX")
.withResolverStyle(ResolverStyle.STRICT),
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSXX")
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSX")
.withResolverStyle(ResolverStyle.STRICT));

// Formatters for local datetime parts (without offset)
private static final List<DateTimeFormatter> LOCAL_FORMATTERS = Arrays.asList(
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss").withResolverStyle(ResolverStyle.STRICT),
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS").withResolverStyle(ResolverStyle.STRICT));

// Earliest valid instant: 0000-01-01T00:00:00+2359
private static final Instant MIN_INSTANT = Instant.ofEpochMilli(-62167305540000L);
// Latest valid instant: 9999-12-31T23:59:59-2359
private static final Instant MAX_INSTANT = Instant.ofEpochMilli(253402387139000L);

/**
* Validates that the instant is within the allowed range.
*
* @param instant the parsed instant to validate
* @return true if the instant is valid, false otherwise
*/
private static boolean isValidInstant(Instant instant) {
return !instant.isBefore(MIN_INSTANT) && !instant.isAfter(MAX_INSTANT);
}

/**
* Parses a datetime string and returns the parsed Instant. Combines validation and parsing
* into a single operation to avoid redundancy. All datetime formats are normalized to
* Instant for consistent equality comparison.
* Parses a datetime string and returns the parsed Instant.
*
* @param dateTimeString the string to parse
* @return Optional containing the parsed Instant, or empty if parsing fails
*/
private static Optional<Instant> parseToInstant(String dateTimeString) {
if (dateTimeString == null || dateTimeString.trim().isEmpty()) {
return java.util.Optional.empty();
return Optional.empty();
}

Matcher offsetMatcher = OFFSET_PATTERN.matcher(dateTimeString);

Optional<Instant> result;
if (offsetMatcher.find()) {
result = parseWithCustomOffset(dateTimeString, offsetMatcher);
} else {
result = UTC_FORMATTERS.stream()
.flatMap(formatter -> tryParseUTCDateTime(dateTimeString, formatter).stream())
.findFirst();
}

return FORMATTERS.stream()
.flatMap(formatter -> tryParseWithFormatter(dateTimeString, formatter).stream())
.findFirst();
// Validate instant range
if (result.isPresent() && !isValidInstant(result.get())) {
return Optional.empty();
}

return result;
}

/**
* Attempts to parse a datetime string with a specific formatter.
* Parses datetime string with custom offset handling for extreme values.
*
* @param dateTimeString the full datetime string
* @param offsetMatcher the matcher that found the offset pattern
* @return Optional containing the parsed Instant, or empty if parsing fails
*/
private static Optional<Instant> parseWithCustomOffset(String dateTimeString, Matcher offsetMatcher) {
try {
String sign = offsetMatcher.group(1);
int offsetHours = Integer.parseInt(offsetMatcher.group(2));
int offsetMinutes = Integer.parseInt(offsetMatcher.group(3));

if (offsetHours > 23 || offsetMinutes > 59) {
return Optional.empty();
}

String dateTimeWithoutOffset = dateTimeString.substring(0, offsetMatcher.start());

Optional<LocalDateTime> localDateTime = LOCAL_FORMATTERS.stream()
.flatMap(formatter -> tryParseLocalDateTime(dateTimeWithoutOffset, formatter).stream())
.findFirst();

if (localDateTime.isEmpty()) {
return Optional.empty();
}

long epochMillis = localDateTime.get().toInstant(ZoneOffset.UTC).toEpochMilli();
long offsetMillis = convertOffsetToMilliseconds(sign, offsetHours, offsetMinutes);
long adjustedEpochMillis = epochMillis - offsetMillis;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had a double take as to why we're subtracting an offset here instead of adding

Copy link
Contributor Author

@muditchaudhary muditchaudhary Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah. this does look confusing.

For example, let's take this timestamp 10/23/2025-19:00::00 UTC. This is equivalent to 1,761,246,000,000 epochMillis.

For UTC-4:00 the equivalent timestamp for the above timestamp is 10/23/2025-15:00::00 UTC-4:00. So, their epochMillis should be equal. Now, let's say we want to convert 10/23/2025-15:00::00 UTC-4:00 to epochMillis.

This is how the code will do it:

  1. Extract non-timezone part of the timestamp 10/23/2025-15:00::00, treat it as UTC and get its epochMillis i.e., 1,761,231,600,000. 1,761,231,600,000 (10/23/2025-15:00::00) < 1,761,246,000,000 (10/23/2025-19:00::00 UTC). So, this extracted epochMillis is behind the actual target value.
  2. Calculate the millisecond offset of the timezone with sign i.e., -4:00 = -14,400,000.
  3. Subtract the offset millisecond from epochMillis in Step 1 i.e., 1,761,231,600,000 - (-14,400,000) = 1761246000000 = 10/23/2025-19:00::00 UTC

So, eventually the offset is added when it is a negative offset (UTC is ahead) and subtracted when it is a positive (UTC is behind)


return Optional.of(Instant.ofEpochMilli(adjustedEpochMillis));

} catch (Exception e) {
return Optional.empty();
}
}

/**
* Attempts to parse a local datetime string with a specific formatter.
*
* @param dateTimeString the string to parse
* @param formatter the formatter to use
* @return Optional containing the parsed LocalDateTime, or empty if parsing fails
*/
private static Optional<LocalDateTime> tryParseLocalDateTime(String dateTimeString, DateTimeFormatter formatter) {
try {
return Optional.of(LocalDateTime.parse(dateTimeString, formatter));
} catch (DateTimeParseException e) {
return Optional.empty();
}
}

/**
* Attempts to parse a UTC datetime string with a specific formatter.
*
* @param dateTimeString the string to parse
* @param formatter the formatter to use
* @return Optional containing the parsed Instant, or empty if parsing fails
*/
private static Optional<Instant> tryParseWithFormatter(String dateTimeString,
DateTimeFormatter formatter) {
private static Optional<Instant> tryParseUTCDateTime(String dateTimeString, DateTimeFormatter formatter) {
try {
if (formatter == FORMATTERS.get(0)) {
if (formatter == UTC_FORMATTERS.get(0)) {
// Date-only format - convert to start of day UTC
LocalDate date = LocalDate.parse(dateTimeString, formatter);
return Optional.of(date.atStartOfDay(ZoneOffset.UTC).toInstant());
} else {
// UTC format - only accept 'Z' as timezone, not other offsets
if (!dateTimeString.endsWith("Z")) {
return Optional.empty();
}
// DateTime format - parse and convert to Instant
OffsetDateTime dateTime = OffsetDateTime.parse(dateTimeString, formatter);
return Optional.of(dateTime.toInstant());
Expand All @@ -96,6 +186,20 @@ private static Optional<Instant> tryParseWithFormatter(String dateTimeString,
return Optional.empty();
}
}

/**
* Converts timezone offset to milliseconds.
*
* @param sign the sign of the offset ("+" or "-")
* @param hours the hours component of the offset
* @param minutes the minutes component of the offset
* @return offset in milliseconds
*/
private static long convertOffsetToMilliseconds(String sign, int hours, int minutes) {
long totalMinutes = hours * 60L + minutes;
long milliseconds = totalMinutes * 60L * 1000L;
return "+".equals(sign) ? milliseconds : -milliseconds;
}
}

/** Datetime as a string. */
Expand Down Expand Up @@ -157,4 +261,8 @@ public int hashCode() {
public String toString() {
return dateTime;
}

public long toEpochMilli() {
return parsedInstant.toEpochMilli();
}
}
Loading