Skip to content
Open
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
6 changes: 5 additions & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ bazel_dep(name = "rules_android", version = "0.7.3")
bazel_dep(name = "rules_shell", version = "0.8.0")
bazel_dep(name = "googleapis-java", version = "1.1.5")
bazel_dep(name = "cel-spec", version = "0.25.3", repo_name = "cel_spec")
git_override(
module_name = "cel-spec",
commit = "21ec6f4e52e8d694687ff9ed7408d3eccbb955d7",
remote = "https://github.com/cel-expr/cel-spec",
)
bazel_dep(name = "rules_go", version = "0.65.0")

# Required by cel-spec to satisfy gazelle transitive dependency
Expand Down Expand Up @@ -102,7 +107,6 @@ maven.install(
"org.jline:jline-reader:" + JLINE_VERSION,
"org.jline:jline-terminal:" + JLINE_VERSION,
"org.jspecify:jspecify:1.0.1",
"org.threeten:threeten-extra:1.11.0",
"org.yaml:snakeyaml:2.7",
"tools.aqua:z3-turnkey:4.14.1",
],
Expand Down
8 changes: 3 additions & 5 deletions common/src/main/java/dev/cel/common/CelOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -520,12 +520,10 @@ public abstract static class Builder {
public abstract Builder enableJsonFieldNames(boolean value);

/**
* Enable or disable validating that duration values resulting from timestamp arithmetic do not
* overflow 64-bit nanoseconds. Defaults to enabled.
*
* <p>Disabling this option is an out-of-conformance behavior that suppresses nanosecond
* overflow validation when subtracting timestamps.
* @deprecated No-op. CEL durations now support the full protobuf duration range, so subtracting
* valid timestamps never overflows.
*/
@Deprecated
public abstract Builder enableTimestampOverflowCheck(boolean value);

/**
Expand Down
5 changes: 4 additions & 1 deletion common/src/main/java/dev/cel/common/internal/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,6 @@ cel_android_library(
deps = [
"//common/annotations",
"@maven//:com_google_errorprone_error_prone_annotations",
"@maven//:com_google_guava_guava",
"@maven_android//:com_google_guava_guava",
"@maven_android//:com_google_protobuf_protobuf_javalite",
],
Expand All @@ -438,8 +437,10 @@ java_library(
tags = [
],
deps = [
":proto_time_utils",
"//common/annotations",
"//common/exceptions:bad_format",
"@maven//:com_google_errorprone_error_prone_annotations",
"@maven//:com_google_guava_guava",
"@maven//:com_google_protobuf_protobuf_java",
],
Expand All @@ -451,8 +452,10 @@ cel_android_library(
tags = [
],
deps = [
":proto_time_utils_android",
"//common/annotations",
"//common/exceptions:bad_format",
"@maven//:com_google_errorprone_error_prone_annotations",
"@maven_android//:com_google_guava_guava",
"@maven_android//:com_google_protobuf_protobuf_javalite",
],
Expand Down
246 changes: 157 additions & 89 deletions common/src/main/java/dev/cel/common/internal/DateTimeHelpers.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@

package dev.cel.common.internal;

import static dev.cel.common.internal.ProtoTimeUtils.DURATION_SECONDS_MAX;
import static dev.cel.common.internal.ProtoTimeUtils.DURATION_SECONDS_MIN;
import static dev.cel.common.internal.ProtoTimeUtils.MAX_NANOS;
import static dev.cel.common.internal.ProtoTimeUtils.NANOS_PER_SECOND;
import static dev.cel.common.internal.ProtoTimeUtils.TIMESTAMP_SECONDS_MAX;
import static dev.cel.common.internal.ProtoTimeUtils.TIMESTAMP_SECONDS_MIN;

import com.google.common.base.Strings;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.protobuf.Timestamp;
import dev.cel.common.annotations.Internal;
import dev.cel.common.exceptions.CelBadFormatException;
Expand All @@ -32,14 +40,8 @@
public final class DateTimeHelpers {
public static final String UTC = "UTC";

// Timestamp for "0001-01-01T00:00:00Z"
private static final long TIMESTAMP_SECONDS_MIN = -62135596800L;
// Timestamp for "9999-12-31T23:59:59Z"
private static final long TIMESTAMP_SECONDS_MAX = 253402300799L;

private static final long DURATION_SECONDS_MIN = -315576000000L;
private static final long DURATION_SECONDS_MAX = 315576000000L;
private static final int NANOS_PER_SECOND = 1000000000;
private static final Duration DURATION_MIN = Duration.ofSeconds(DURATION_SECONDS_MIN, -MAX_NANOS);
private static final Duration DURATION_MAX = Duration.ofSeconds(DURATION_SECONDS_MAX, MAX_NANOS);

/**
* Constructs a new {@link LocalDateTime} instance
Expand Down Expand Up @@ -79,6 +81,14 @@ public static LocalDateTime newLocalDateTime(Instant instant, String tz) {
return instant.atZone(timeZone(tz)).toLocalDateTime();
}

/** Creates an {@link Instant} from epoch seconds, validating CEL timestamp bounds. */
// CEL's timestamp(int) conversion accepts epoch seconds as an int64.
@SuppressWarnings("GoodTime-ApiWithNumericTimeUnit")
public static Instant fromSecondsToInstant(long seconds) {
checkSecondsValid(seconds);
return Instant.ofEpochSecond(seconds);
}

/**
* Parse from RFC 3339 date string to {@link java.time.Instant}.
*
Expand All @@ -92,6 +102,75 @@ public static Instant parse(String text) {
return instant;
}

/** Parses a CEL duration string (following Go {@code time.ParseDuration} syntax). */
public static Duration parseDuration(String text) {
if (text.isEmpty()) {
throw invalidDurationFormat();
}
int len = text.length();
int i = 0;
char first = text.charAt(0);
boolean negative = (first == '-');
if (negative || first == '+') {
i++;
}
if (i == len) {
throw invalidDurationFormat();
}
if (len - i == 1 && text.charAt(i) == '0') {
return Duration.ZERO;
}
long totalSeconds = 0;
long totalNanos = 0;
while (i < len) {
int intStart = i;
int intEnd = scanAsciiDigits(text, intStart);
int fracStart = intEnd;
int fracEnd = intEnd;
if (intEnd < len && text.charAt(intEnd) == '.') {
fracStart = intEnd + 1;
fracEnd = scanAsciiDigits(text, fracStart);
}
if (intStart == intEnd && fracStart == fracEnd) {
throw invalidDurationFormat();
}
i = fracEnd;
int unitStart = i;
while (i < len && text.charAt(i) != '.' && !isAsciiDigit(text.charAt(i))) {
i++;
}
long unitNanos = parseUnitNanos(text, unitStart, i);

long segSeconds = 0;
long segNanos = 0;
for (int p = intStart; p < intEnd; p++) {
int digit = Character.digit(text.charAt(p), 10);
segSeconds *= 10;
segNanos = segNanos * 10 + digit * unitNanos;
segSeconds += segNanos / NANOS_PER_SECOND;
segNanos %= NANOS_PER_SECOND;
if (segSeconds > DURATION_SECONDS_MAX) {
throw outOfRangeDuration(text);
}
}

long fracNanos = 0;
for (int p = fracEnd - 1; p >= fracStart; p--) {
fracNanos = (Character.digit(text.charAt(p), 10) * unitNanos + fracNanos) / 10;
}

totalNanos += segNanos + fracNanos;
totalSeconds += segSeconds + totalNanos / NANOS_PER_SECOND;
totalNanos %= NANOS_PER_SECOND;
if (totalSeconds > DURATION_SECONDS_MAX) {
throw outOfRangeDuration(text);
}
}
return negative
? Duration.ofSeconds(-totalSeconds, -totalNanos)
: Duration.ofSeconds(totalSeconds, totalNanos);
}

/** Adds a duration to an instant. */
public static Instant add(Instant ts, Duration dur) {
Instant newInstant = ts.plus(dur);
Expand All @@ -102,10 +181,7 @@ public static Instant add(Instant ts, Duration dur) {

/** Adds two durations */
public static Duration add(Duration d1, Duration d2) {
Duration newDuration = d1.plus(d2);
checkValid(newDuration);

return newDuration;
return checkValid(d1.plus(d2));
}

/** Subtracts a duration to an instant. */
Expand All @@ -118,54 +194,72 @@ public static Instant subtract(Instant ts, Duration dur) {

/** Subtract a duration from another. */
public static Duration subtract(Duration d1, Duration d2) {
Duration newDuration = d1.minus(d2);
checkValid(newDuration);
return checkValid(d1.minus(d2));
}

return newDuration;
/** Calculates the difference between two instants. */
public static Duration between(Instant d1, Instant d2) {
return checkValid(Duration.between(d1, d2));
}

/**
* Formats a {@link Duration} into a minimal seconds-based representation.
*
* <p>Note: follows {@code ProtoTimeUtils#toString(Duration)} implementation
*/
/** Formats a {@link Duration} into a minimal seconds-based representation. */
public static String toString(Duration duration) {
if (duration.isZero()) {
return "0s";
long seconds = duration.getSeconds();
int nanos = duration.getNano();
if (seconds < 0 && nanos > 0) {
seconds++;
nanos -= NANOS_PER_SECOND;
}
return ProtoTimeUtils.formatDuration(seconds, nanos);
}

long totalNanos = duration.toNanos();
StringBuilder sb = new StringBuilder();

if (totalNanos < 0) {
sb.append('-');
totalNanos = -totalNanos;
private static int scanAsciiDigits(String text, int i) {
while (i < text.length() && isAsciiDigit(text.charAt(i))) {
i++;
}
return i;
}

private static boolean isAsciiDigit(char c) {
return c >= '0' && c <= '9';
}

long seconds = totalNanos / 1_000_000_000;
int nanos = (int) (totalNanos % 1_000_000_000);

sb.append(seconds);

// Follows ProtoTimeUtils.toString(Duration) implementation
if (nanos > 0) {
sb.append('.');
if (nanos % 1_000_000 == 0) {
// Millisecond precision (3 digits)
int millis = nanos / 1_000_000;
sb.append(String.format(Locale.US, "%03d", millis));
} else if (nanos % 1_000 == 0) {
// Microsecond precision (6 digits)
int micros = nanos / 1_000;
sb.append(String.format(Locale.US, "%06d", micros));
} else {
// Nanosecond precision (9 digits)
sb.append(String.format(Locale.US, "%09d", nanos));
private static long parseUnitNanos(String text, int start, int end) {
int len = end - start;
if (len == 1) {
switch (text.charAt(start)) {
case 's':
return NANOS_PER_SECOND;
case 'm':
return 60L * NANOS_PER_SECOND;
case 'h':
return 3600L * NANOS_PER_SECOND;
default:
break;
}
} else if (len == 2 && text.charAt(start + 1) == 's') {
switch (text.charAt(start)) {
case 'n':
return 1L;
case 'u':
case '\u00b5':
case '\u03bc':
return 1_000L;
case 'm':
return 1_000_000L;
default:
break;
}
}
throw invalidDurationFormat();
}

private static IllegalArgumentException invalidDurationFormat() {
return new IllegalArgumentException("invalid duration format");
}

sb.append('s');
return sb.toString();
private static IllegalArgumentException outOfRangeDuration(String text) {
return new IllegalArgumentException("Duration is out of valid range: " + text);
}

/**
Expand Down Expand Up @@ -205,55 +299,29 @@ private static ZoneId timeZone(String tz) {

/** Throws an {@link IllegalArgumentException} if the given {@link Timestamp} is not valid. */
public static void checkValid(Instant instant) {
long seconds = instant.getEpochSecond();
checkSecondsValid(instant.getEpochSecond());
}

if (seconds < TIMESTAMP_SECONDS_MIN || seconds > TIMESTAMP_SECONDS_MAX) {
@CanIgnoreReturnValue
private static Duration checkValid(Duration duration) {
if (duration.compareTo(DURATION_MIN) < 0 || duration.compareTo(DURATION_MAX) > 0) {
throw new IllegalArgumentException(
Strings.lenientFormat(
"Timestamp is not valid. "
+ "Seconds (%s) must be in range [-62,135,596,800, +253,402,300,799]. "
+ "Nanos (%s) must be in range [0, +999,999,999].",
seconds, instant.getNano()));
"Duration is out of valid range [-315,576,000,000.999999999s,"
+ " +315,576,000,000.999999999s]: %s",
duration));
}
return duration;
}

/** Throws an {@link IllegalArgumentException} if the given {@link Duration} is not valid. */
private static void checkValid(Duration duration) {
long seconds = duration.getSeconds();
int nanos = duration.getNano();
if (!isDurationValid(seconds, nanos)) {
private static void checkSecondsValid(long seconds) {
if (seconds < TIMESTAMP_SECONDS_MIN || seconds > TIMESTAMP_SECONDS_MAX) {
throw new IllegalArgumentException(
Strings.lenientFormat(
"Duration is not valid. "
+ "Seconds (%s) must be in range [-315,576,000,000, +315,576,000,000]. "
+ "Nanos (%s) must be in range [-999,999,999, +999,999,999]. "
+ "Nanos must have the same sign as seconds",
seconds, nanos));
}
}

/**
* Returns true if the given number of seconds and nanos is a valid {@link Duration}. The {@code
* seconds} value must be in the range [-315,576,000,000, +315,576,000,000]. The {@code nanos}
* value must be in the range [-999,999,999, +999,999,999].
*
* <p><b>Note:</b> Durations less than one second are represented with a 0 {@code seconds} field
* and a positive or negative {@code nanos} field. For durations of one second or more, a non-zero
* value for the {@code nanos} field must be of the same sign as the {@code seconds} field.
*/
private static boolean isDurationValid(long seconds, int nanos) {
if (seconds < DURATION_SECONDS_MIN || seconds > DURATION_SECONDS_MAX) {
return false;
}
if (nanos < -999999999L || nanos >= NANOS_PER_SECOND) {
return false;
}
if (seconds < 0 || nanos < 0) {
if (seconds > 0 || nanos > 0) {
return false;
}
"Timestamp is not valid. Seconds (%s) must be in range [-62,135,596,800,"
+ " +253,402,300,799].",
seconds));
}
return true;
}

private DateTimeHelpers() {}
Expand Down
Loading
Loading