Skip to content

Commit bd5ed37

Browse files
l46kokcopybara-github
authored andcommitted
Widen CEL durations to match google.protobuf.Duration bounds.
CEL specification PR #501 widens valid CEL durations from roughly ±290 years (64-bit signed nanoseconds) to the full `google.protobuf.Duration` range (±10,000 years, -315,576,000,000.999999999s to +315,576,000,000.999999999s) so every difference between valid `Timestamp` values is representable as a CEL duration. Update duration bounds checking, timestamp and duration arithmetic, and `timestamp(int)` epoch-second validation across both native `java.time` and protobuf runtime representations. Replace `threeten-extra` duration parsing with a zero-allocation Go `time.ParseDuration`-compatible parser supporting the ±10,000-year range (removing the `org.threeten:threeten-extra` dependency), deprecate `CelOptions.Builder.enableTimestampOverflowCheck` as a no-op, and enable the widened duration conformance tests. PiperOrigin-RevId: 1010820757
1 parent e23a70e commit bd5ed37

14 files changed

Lines changed: 583 additions & 233 deletions

File tree

‎MODULE.bazel‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ bazel_dep(name = "rules_android", version = "0.7.3")
2828
bazel_dep(name = "rules_shell", version = "0.8.0")
2929
bazel_dep(name = "googleapis-java", version = "1.1.5")
3030
bazel_dep(name = "cel-spec", version = "0.25.3", repo_name = "cel_spec")
31+
git_override(
32+
module_name = "cel-spec",
33+
commit = "21ec6f4e52e8d694687ff9ed7408d3eccbb955d7",
34+
remote = "https://github.com/cel-expr/cel-spec",
35+
)
3136
bazel_dep(name = "rules_go", version = "0.65.0")
3237

3338
# Required by cel-spec to satisfy gazelle transitive dependency
@@ -102,7 +107,6 @@ maven.install(
102107
"org.jline:jline-reader:" + JLINE_VERSION,
103108
"org.jline:jline-terminal:" + JLINE_VERSION,
104109
"org.jspecify:jspecify:1.0.1",
105-
"org.threeten:threeten-extra:1.11.0",
106110
"org.yaml:snakeyaml:2.7",
107111
"tools.aqua:z3-turnkey:4.14.1",
108112
],

‎common/src/main/java/dev/cel/common/CelOptions.java‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -520,12 +520,10 @@ public abstract static class Builder {
520520
public abstract Builder enableJsonFieldNames(boolean value);
521521

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

531529
/**

‎common/src/main/java/dev/cel/common/internal/BUILD.bazel‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,6 @@ cel_android_library(
426426
deps = [
427427
"//common/annotations",
428428
"@maven//:com_google_errorprone_error_prone_annotations",
429-
"@maven//:com_google_guava_guava",
430429
"@maven_android//:com_google_guava_guava",
431430
"@maven_android//:com_google_protobuf_protobuf_javalite",
432431
],
@@ -438,8 +437,10 @@ java_library(
438437
tags = [
439438
],
440439
deps = [
440+
":proto_time_utils",
441441
"//common/annotations",
442442
"//common/exceptions:bad_format",
443+
"@maven//:com_google_errorprone_error_prone_annotations",
443444
"@maven//:com_google_guava_guava",
444445
"@maven//:com_google_protobuf_protobuf_java",
445446
],
@@ -451,8 +452,10 @@ cel_android_library(
451452
tags = [
452453
],
453454
deps = [
455+
":proto_time_utils_android",
454456
"//common/annotations",
455457
"//common/exceptions:bad_format",
458+
"@maven//:com_google_errorprone_error_prone_annotations",
456459
"@maven_android//:com_google_guava_guava",
457460
"@maven_android//:com_google_protobuf_protobuf_javalite",
458461
],

‎common/src/main/java/dev/cel/common/internal/DateTimeHelpers.java‎

Lines changed: 157 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@
1414

1515
package dev.cel.common.internal;
1616

17+
import static dev.cel.common.internal.ProtoTimeUtils.DURATION_SECONDS_MAX;
18+
import static dev.cel.common.internal.ProtoTimeUtils.DURATION_SECONDS_MIN;
19+
import static dev.cel.common.internal.ProtoTimeUtils.MAX_NANOS;
20+
import static dev.cel.common.internal.ProtoTimeUtils.NANOS_PER_SECOND;
21+
import static dev.cel.common.internal.ProtoTimeUtils.TIMESTAMP_SECONDS_MAX;
22+
import static dev.cel.common.internal.ProtoTimeUtils.TIMESTAMP_SECONDS_MIN;
23+
1724
import com.google.common.base.Strings;
25+
import com.google.errorprone.annotations.CanIgnoreReturnValue;
1826
import com.google.protobuf.Timestamp;
1927
import dev.cel.common.annotations.Internal;
2028
import dev.cel.common.exceptions.CelBadFormatException;
@@ -32,14 +40,8 @@
3240
public final class DateTimeHelpers {
3341
public static final String UTC = "UTC";
3442

35-
// Timestamp for "0001-01-01T00:00:00Z"
36-
private static final long TIMESTAMP_SECONDS_MIN = -62135596800L;
37-
// Timestamp for "9999-12-31T23:59:59Z"
38-
private static final long TIMESTAMP_SECONDS_MAX = 253402300799L;
39-
40-
private static final long DURATION_SECONDS_MIN = -315576000000L;
41-
private static final long DURATION_SECONDS_MAX = 315576000000L;
42-
private static final int NANOS_PER_SECOND = 1000000000;
43+
private static final Duration DURATION_MIN = Duration.ofSeconds(DURATION_SECONDS_MIN, -MAX_NANOS);
44+
private static final Duration DURATION_MAX = Duration.ofSeconds(DURATION_SECONDS_MAX, MAX_NANOS);
4345

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

84+
/** Creates an {@link Instant} from epoch seconds, validating CEL timestamp bounds. */
85+
// CEL's timestamp(int) conversion accepts epoch seconds as an int64.
86+
@SuppressWarnings("GoodTime-ApiWithNumericTimeUnit")
87+
public static Instant fromSecondsToInstant(long seconds) {
88+
checkSecondsValid(seconds);
89+
return Instant.ofEpochSecond(seconds);
90+
}
91+
8292
/**
8393
* Parse from RFC 3339 date string to {@link java.time.Instant}.
8494
*
@@ -92,6 +102,75 @@ public static Instant parse(String text) {
92102
return instant;
93103
}
94104

105+
/** Parses a CEL duration string (following Go {@code time.ParseDuration} syntax). */
106+
public static Duration parseDuration(String text) {
107+
if (text.isEmpty()) {
108+
throw invalidDurationFormat();
109+
}
110+
int len = text.length();
111+
int i = 0;
112+
char first = text.charAt(0);
113+
boolean negative = (first == '-');
114+
if (negative || first == '+') {
115+
i++;
116+
}
117+
if (i == len) {
118+
throw invalidDurationFormat();
119+
}
120+
if (len - i == 1 && text.charAt(i) == '0') {
121+
return Duration.ZERO;
122+
}
123+
long totalSeconds = 0;
124+
long totalNanos = 0;
125+
while (i < len) {
126+
int intStart = i;
127+
int intEnd = scanAsciiDigits(text, intStart);
128+
int fracStart = intEnd;
129+
int fracEnd = intEnd;
130+
if (intEnd < len && text.charAt(intEnd) == '.') {
131+
fracStart = intEnd + 1;
132+
fracEnd = scanAsciiDigits(text, fracStart);
133+
}
134+
if (intStart == intEnd && fracStart == fracEnd) {
135+
throw invalidDurationFormat();
136+
}
137+
i = fracEnd;
138+
int unitStart = i;
139+
while (i < len && text.charAt(i) != '.' && !isAsciiDigit(text.charAt(i))) {
140+
i++;
141+
}
142+
long unitNanos = parseUnitNanos(text, unitStart, i);
143+
144+
long segSeconds = 0;
145+
long segNanos = 0;
146+
for (int p = intStart; p < intEnd; p++) {
147+
int digit = Character.digit(text.charAt(p), 10);
148+
segSeconds *= 10;
149+
segNanos = segNanos * 10 + digit * unitNanos;
150+
segSeconds += segNanos / NANOS_PER_SECOND;
151+
segNanos %= NANOS_PER_SECOND;
152+
if (segSeconds > DURATION_SECONDS_MAX) {
153+
throw outOfRangeDuration(text);
154+
}
155+
}
156+
157+
long fracNanos = 0;
158+
for (int p = fracEnd - 1; p >= fracStart; p--) {
159+
fracNanos = (Character.digit(text.charAt(p), 10) * unitNanos + fracNanos) / 10;
160+
}
161+
162+
totalNanos += segNanos + fracNanos;
163+
totalSeconds += segSeconds + totalNanos / NANOS_PER_SECOND;
164+
totalNanos %= NANOS_PER_SECOND;
165+
if (totalSeconds > DURATION_SECONDS_MAX) {
166+
throw outOfRangeDuration(text);
167+
}
168+
}
169+
return negative
170+
? Duration.ofSeconds(-totalSeconds, -totalNanos)
171+
: Duration.ofSeconds(totalSeconds, totalNanos);
172+
}
173+
95174
/** Adds a duration to an instant. */
96175
public static Instant add(Instant ts, Duration dur) {
97176
Instant newInstant = ts.plus(dur);
@@ -102,10 +181,7 @@ public static Instant add(Instant ts, Duration dur) {
102181

103182
/** Adds two durations */
104183
public static Duration add(Duration d1, Duration d2) {
105-
Duration newDuration = d1.plus(d2);
106-
checkValid(newDuration);
107-
108-
return newDuration;
184+
return checkValid(d1.plus(d2));
109185
}
110186

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

119195
/** Subtract a duration from another. */
120196
public static Duration subtract(Duration d1, Duration d2) {
121-
Duration newDuration = d1.minus(d2);
122-
checkValid(newDuration);
197+
return checkValid(d1.minus(d2));
198+
}
123199

124-
return newDuration;
200+
/** Calculates the difference between two instants. */
201+
public static Duration between(Instant d1, Instant d2) {
202+
return checkValid(Duration.between(d1, d2));
125203
}
126204

127-
/**
128-
* Formats a {@link Duration} into a minimal seconds-based representation.
129-
*
130-
* <p>Note: follows {@code ProtoTimeUtils#toString(Duration)} implementation
131-
*/
205+
/** Formats a {@link Duration} into a minimal seconds-based representation. */
132206
public static String toString(Duration duration) {
133-
if (duration.isZero()) {
134-
return "0s";
207+
long seconds = duration.getSeconds();
208+
int nanos = duration.getNano();
209+
if (seconds < 0 && nanos > 0) {
210+
seconds++;
211+
nanos -= NANOS_PER_SECOND;
135212
}
213+
return ProtoTimeUtils.formatDuration(seconds, nanos);
214+
}
136215

137-
long totalNanos = duration.toNanos();
138-
StringBuilder sb = new StringBuilder();
139-
140-
if (totalNanos < 0) {
141-
sb.append('-');
142-
totalNanos = -totalNanos;
216+
private static int scanAsciiDigits(String text, int i) {
217+
while (i < text.length() && isAsciiDigit(text.charAt(i))) {
218+
i++;
143219
}
220+
return i;
221+
}
222+
223+
private static boolean isAsciiDigit(char c) {
224+
return c >= '0' && c <= '9';
225+
}
144226

145-
long seconds = totalNanos / 1_000_000_000;
146-
int nanos = (int) (totalNanos % 1_000_000_000);
147-
148-
sb.append(seconds);
149-
150-
// Follows ProtoTimeUtils.toString(Duration) implementation
151-
if (nanos > 0) {
152-
sb.append('.');
153-
if (nanos % 1_000_000 == 0) {
154-
// Millisecond precision (3 digits)
155-
int millis = nanos / 1_000_000;
156-
sb.append(String.format(Locale.US, "%03d", millis));
157-
} else if (nanos % 1_000 == 0) {
158-
// Microsecond precision (6 digits)
159-
int micros = nanos / 1_000;
160-
sb.append(String.format(Locale.US, "%06d", micros));
161-
} else {
162-
// Nanosecond precision (9 digits)
163-
sb.append(String.format(Locale.US, "%09d", nanos));
227+
private static long parseUnitNanos(String text, int start, int end) {
228+
int len = end - start;
229+
if (len == 1) {
230+
switch (text.charAt(start)) {
231+
case 's':
232+
return NANOS_PER_SECOND;
233+
case 'm':
234+
return 60L * NANOS_PER_SECOND;
235+
case 'h':
236+
return 3600L * NANOS_PER_SECOND;
237+
default:
238+
break;
239+
}
240+
} else if (len == 2 && text.charAt(start + 1) == 's') {
241+
switch (text.charAt(start)) {
242+
case 'n':
243+
return 1L;
244+
case 'u':
245+
case '\u00b5':
246+
case '\u03bc':
247+
return 1_000L;
248+
case 'm':
249+
return 1_000_000L;
250+
default:
251+
break;
164252
}
165253
}
254+
throw invalidDurationFormat();
255+
}
256+
257+
private static IllegalArgumentException invalidDurationFormat() {
258+
return new IllegalArgumentException("invalid duration format");
259+
}
166260

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

171265
/**
@@ -205,55 +299,29 @@ private static ZoneId timeZone(String tz) {
205299

206300
/** Throws an {@link IllegalArgumentException} if the given {@link Timestamp} is not valid. */
207301
public static void checkValid(Instant instant) {
208-
long seconds = instant.getEpochSecond();
302+
checkSecondsValid(instant.getEpochSecond());
303+
}
209304

210-
if (seconds < TIMESTAMP_SECONDS_MIN || seconds > TIMESTAMP_SECONDS_MAX) {
305+
@CanIgnoreReturnValue
306+
private static Duration checkValid(Duration duration) {
307+
if (duration.compareTo(DURATION_MIN) < 0 || duration.compareTo(DURATION_MAX) > 0) {
211308
throw new IllegalArgumentException(
212309
Strings.lenientFormat(
213-
"Timestamp is not valid. "
214-
+ "Seconds (%s) must be in range [-62,135,596,800, +253,402,300,799]. "
215-
+ "Nanos (%s) must be in range [0, +999,999,999].",
216-
seconds, instant.getNano()));
310+
"Duration is out of valid range [-315,576,000,000.999999999s,"
311+
+ " +315,576,000,000.999999999s]: %s",
312+
duration));
217313
}
314+
return duration;
218315
}
219316

220-
/** Throws an {@link IllegalArgumentException} if the given {@link Duration} is not valid. */
221-
private static void checkValid(Duration duration) {
222-
long seconds = duration.getSeconds();
223-
int nanos = duration.getNano();
224-
if (!isDurationValid(seconds, nanos)) {
317+
private static void checkSecondsValid(long seconds) {
318+
if (seconds < TIMESTAMP_SECONDS_MIN || seconds > TIMESTAMP_SECONDS_MAX) {
225319
throw new IllegalArgumentException(
226320
Strings.lenientFormat(
227-
"Duration is not valid. "
228-
+ "Seconds (%s) must be in range [-315,576,000,000, +315,576,000,000]. "
229-
+ "Nanos (%s) must be in range [-999,999,999, +999,999,999]. "
230-
+ "Nanos must have the same sign as seconds",
231-
seconds, nanos));
232-
}
233-
}
234-
235-
/**
236-
* Returns true if the given number of seconds and nanos is a valid {@link Duration}. The {@code
237-
* seconds} value must be in the range [-315,576,000,000, +315,576,000,000]. The {@code nanos}
238-
* value must be in the range [-999,999,999, +999,999,999].
239-
*
240-
* <p><b>Note:</b> Durations less than one second are represented with a 0 {@code seconds} field
241-
* and a positive or negative {@code nanos} field. For durations of one second or more, a non-zero
242-
* value for the {@code nanos} field must be of the same sign as the {@code seconds} field.
243-
*/
244-
private static boolean isDurationValid(long seconds, int nanos) {
245-
if (seconds < DURATION_SECONDS_MIN || seconds > DURATION_SECONDS_MAX) {
246-
return false;
247-
}
248-
if (nanos < -999999999L || nanos >= NANOS_PER_SECOND) {
249-
return false;
250-
}
251-
if (seconds < 0 || nanos < 0) {
252-
if (seconds > 0 || nanos > 0) {
253-
return false;
254-
}
321+
"Timestamp is not valid. Seconds (%s) must be in range [-62,135,596,800,"
322+
+ " +253,402,300,799].",
323+
seconds));
255324
}
256-
return true;
257325
}
258326

259327
private DateTimeHelpers() {}

0 commit comments

Comments
 (0)