Skip to content

Commit 0920c81

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 valid `Timestamp` difference is representable as a CEL duration, and rejects out-of-range `google.protobuf.Duration` and `google.protobuf.Timestamp` message literals at construction. Update duration validation, timestamp and duration arithmetic, `timestamp(int)` epoch-second bounds, protobuf message literal construction, and Z3 verifier bounds and witness formatting to enforce full protobuf duration and RFC 3339 timestamp bounds while preserving unvalidated field selection on existing protobuf messages. Replace `threeten-extra` duration string 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 re-enable the skipped duration and timestamp spec conformance suites. PiperOrigin-RevId: 1010820757
1 parent 08ab677 commit 0920c81

28 files changed

Lines changed: 932 additions & 285 deletions

‎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
@@ -179,8 +179,10 @@ java_library(
179179
],
180180
deps = [
181181
":converter",
182+
":default_message_factory",
182183
":proto_lite_adapter",
183184
":proto_message_factory",
185+
":proto_time_utils",
184186
":well_known_proto",
185187
"//common:options",
186188
"//common/annotations",
@@ -426,7 +428,6 @@ cel_android_library(
426428
deps = [
427429
"//common/annotations",
428430
"@maven//:com_google_errorprone_error_prone_annotations",
429-
"@maven//:com_google_guava_guava",
430431
"@maven_android//:com_google_guava_guava",
431432
"@maven_android//:com_google_protobuf_protobuf_javalite",
432433
],
@@ -438,6 +439,7 @@ java_library(
438439
tags = [
439440
],
440441
deps = [
442+
":proto_time_utils",
441443
"//common/annotations",
442444
"//common/exceptions:bad_format",
443445
"@maven//:com_google_guava_guava",
@@ -451,6 +453,7 @@ cel_android_library(
451453
tags = [
452454
],
453455
deps = [
456+
":proto_time_utils_android",
454457
"//common/annotations",
455458
"//common/exceptions:bad_format",
456459
"@maven_android//:com_google_guava_guava",

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

Lines changed: 149 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
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;
1825
import com.google.protobuf.Timestamp;
1926
import dev.cel.common.annotations.Internal;
@@ -32,14 +39,8 @@
3239
public final class DateTimeHelpers {
3340
public static final String UTC = "UTC";
3441

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;
42+
private static final Duration DURATION_MIN = Duration.ofSeconds(DURATION_SECONDS_MIN, -MAX_NANOS);
43+
private static final Duration DURATION_MAX = Duration.ofSeconds(DURATION_SECONDS_MAX, MAX_NANOS);
4344

4445
/**
4546
* Constructs a new {@link LocalDateTime} instance
@@ -79,6 +80,14 @@ public static LocalDateTime newLocalDateTime(Instant instant, String tz) {
7980
return instant.atZone(timeZone(tz)).toLocalDateTime();
8081
}
8182

83+
/** Creates an {@link Instant} from epoch seconds, validating CEL timestamp bounds. */
84+
// CEL's timestamp(int) conversion accepts epoch seconds as an int64.
85+
@SuppressWarnings("GoodTime-ApiWithNumericTimeUnit")
86+
public static Instant fromSecondsToInstant(long seconds) {
87+
checkSecondsValid(seconds);
88+
return Instant.ofEpochSecond(seconds);
89+
}
90+
8291
/**
8392
* Parse from RFC 3339 date string to {@link java.time.Instant}.
8493
*
@@ -92,6 +101,75 @@ public static Instant parse(String text) {
92101
return instant;
93102
}
94103

104+
/** Parses a CEL duration string (following Go {@code time.ParseDuration} syntax). */
105+
public static Duration parseDuration(String text) {
106+
if (text.isEmpty()) {
107+
throw invalidDurationFormat();
108+
}
109+
int len = text.length();
110+
int i = 0;
111+
char first = text.charAt(0);
112+
boolean negative = (first == '-');
113+
if (negative || first == '+') {
114+
i++;
115+
}
116+
if (i == len) {
117+
throw invalidDurationFormat();
118+
}
119+
if (len - i == 1 && text.charAt(i) == '0') {
120+
return Duration.ZERO;
121+
}
122+
long totalSeconds = 0;
123+
long totalNanos = 0;
124+
while (i < len) {
125+
int intStart = i;
126+
int intEnd = scanAsciiDigits(text, intStart);
127+
int fracStart = intEnd;
128+
int fracEnd = intEnd;
129+
if (intEnd < len && text.charAt(intEnd) == '.') {
130+
fracStart = intEnd + 1;
131+
fracEnd = scanAsciiDigits(text, fracStart);
132+
}
133+
if (intStart == intEnd && fracStart == fracEnd) {
134+
throw invalidDurationFormat();
135+
}
136+
i = fracEnd;
137+
int unitStart = i;
138+
while (i < len && text.charAt(i) != '.' && !isAsciiDigit(text.charAt(i))) {
139+
i++;
140+
}
141+
long unitNanos = parseUnitNanos(text.substring(unitStart, i));
142+
143+
long segSeconds = 0;
144+
long segNanos = 0;
145+
for (int p = intStart; p < intEnd; p++) {
146+
int digit = text.charAt(p) - '0';
147+
segSeconds *= 10;
148+
segNanos = segNanos * 10 + digit * unitNanos;
149+
segSeconds += segNanos / NANOS_PER_SECOND;
150+
segNanos %= NANOS_PER_SECOND;
151+
if (segSeconds > DURATION_SECONDS_MAX) {
152+
throw outOfRangeDuration(text);
153+
}
154+
}
155+
156+
long fracNanos = 0;
157+
for (int p = fracEnd - 1; p >= fracStart; p--) {
158+
fracNanos = ((text.charAt(p) - '0') * unitNanos + fracNanos) / 10;
159+
}
160+
161+
totalNanos += segNanos + fracNanos;
162+
totalSeconds += segSeconds + totalNanos / NANOS_PER_SECOND;
163+
totalNanos %= NANOS_PER_SECOND;
164+
if (totalSeconds > DURATION_SECONDS_MAX) {
165+
throw outOfRangeDuration(text);
166+
}
167+
}
168+
return negative
169+
? Duration.ofSeconds(-totalSeconds, -totalNanos)
170+
: Duration.ofSeconds(totalSeconds, totalNanos);
171+
}
172+
95173
/** Adds a duration to an instant. */
96174
public static Instant add(Instant ts, Duration dur) {
97175
Instant newInstant = ts.plus(dur);
@@ -124,48 +202,63 @@ public static Duration subtract(Duration d1, Duration d2) {
124202
return newDuration;
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+
/** Calculates the difference between two instants. */
206+
public static Duration between(Instant d1, Instant d2) {
207+
Duration diff = Duration.between(d1, d2);
208+
checkValid(diff);
209+
return diff;
210+
}
211+
212+
/** Formats a {@link Duration} into a minimal seconds-based representation. */
132213
public static String toString(Duration duration) {
133-
if (duration.isZero()) {
134-
return "0s";
214+
checkValid(duration);
215+
long seconds = duration.getSeconds();
216+
int nanos = duration.getNano();
217+
if (seconds < 0 && nanos > 0) {
218+
seconds++;
219+
nanos -= NANOS_PER_SECOND;
135220
}
221+
return ProtoTimeUtils.formatDuration(seconds, nanos);
222+
}
136223

137-
long totalNanos = duration.toNanos();
138-
StringBuilder sb = new StringBuilder();
139-
140-
if (totalNanos < 0) {
141-
sb.append('-');
142-
totalNanos = -totalNanos;
224+
private static int scanAsciiDigits(String text, int i) {
225+
while (i < text.length() && isAsciiDigit(text.charAt(i))) {
226+
i++;
143227
}
228+
return i;
229+
}
144230

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));
164-
}
231+
private static boolean isAsciiDigit(char c) {
232+
return c >= '0' && c <= '9';
233+
}
234+
235+
private static long parseUnitNanos(String unit) {
236+
switch (unit) {
237+
case "ns":
238+
return 1L;
239+
case "us":
240+
case "\u00b5s":
241+
case "\u03bcs":
242+
return 1_000L;
243+
case "ms":
244+
return 1_000_000L;
245+
case "s":
246+
return NANOS_PER_SECOND;
247+
case "m":
248+
return 60L * NANOS_PER_SECOND;
249+
case "h":
250+
return 3600L * NANOS_PER_SECOND;
251+
default:
252+
throw invalidDurationFormat();
165253
}
254+
}
255+
256+
private static IllegalArgumentException invalidDurationFormat() {
257+
return new IllegalArgumentException("invalid duration format");
258+
}
166259

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

171264
/**
@@ -205,55 +298,28 @@ private static ZoneId timeZone(String tz) {
205298

206299
/** Throws an {@link IllegalArgumentException} if the given {@link Timestamp} is not valid. */
207300
public static void checkValid(Instant instant) {
208-
long seconds = instant.getEpochSecond();
209-
210-
if (seconds < TIMESTAMP_SECONDS_MIN || seconds > TIMESTAMP_SECONDS_MAX) {
211-
throw new IllegalArgumentException(
212-
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()));
217-
}
301+
checkSecondsValid(instant.getEpochSecond());
218302
}
219303

220304
/** 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)) {
305+
public static void checkValid(Duration duration) {
306+
if (duration.compareTo(DURATION_MIN) < 0 || duration.compareTo(DURATION_MAX) > 0) {
225307
throw new IllegalArgumentException(
226308
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));
309+
"Duration is out of valid range [-315,576,000,000.999999999s,"
310+
+ " +315,576,000,000.999999999s]: %s",
311+
duration));
232312
}
233313
}
234314

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-
}
315+
private static void checkSecondsValid(long seconds) {
316+
if (seconds < TIMESTAMP_SECONDS_MIN || seconds > TIMESTAMP_SECONDS_MAX) {
317+
throw new IllegalArgumentException(
318+
Strings.lenientFormat(
319+
"Timestamp is not valid. Seconds (%s) must be in range [-62,135,596,800,"
320+
+ " +253,402,300,799].",
321+
seconds));
255322
}
256-
return true;
257323
}
258324

259325
private DateTimeHelpers() {}

0 commit comments

Comments
 (0)