From 5649c836c97501e8e8e0e386673cbc739f9ce4a1 Mon Sep 17 00:00:00 2001 From: Sean Huh Date: Tue, 3 Nov 2026 16:38:47 -0800 Subject: [PATCH] Widen CEL durations to match google.protobuf.Duration bounds. PiperOrigin-RevId: 1013387191 --- MODULE.bazel | 9 +- .../main/java/dev/cel/common/CelOptions.java | 8 +- .../java/dev/cel/common/internal/BUILD.bazel | 3 +- .../cel/common/internal/DateTimeHelpers.java | 241 +++++++++++------- .../cel/common/internal/ProtoTimeUtils.java | 97 +++---- .../java/dev/cel/common/internal/BUILD.bazel | 2 + .../common/internal/DateTimeHelpersTest.java | 241 ++++++++++++++++++ .../common/internal/ProtoTimeUtilsTest.java | 210 ++++++++++++++- .../test/java/dev/cel/conformance/BUILD.bazel | 10 +- .../src/main/java/dev/cel/runtime/BUILD.bazel | 6 +- .../java/dev/cel/runtime/RuntimeHelpers.java | 24 +- .../runtime/standard/SubtractOperator.java | 24 +- .../runtime/standard/TimestampFunction.java | 6 +- .../ProtoMessageRuntimeHelpersTest.java | 27 +- 14 files changed, 670 insertions(+), 238 deletions(-) create mode 100644 common/src/test/java/dev/cel/common/internal/DateTimeHelpersTest.java diff --git a/MODULE.bazel b/MODULE.bazel index ee804697a..0b37bccd6 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -28,6 +28,14 @@ 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") + +# TODO: Remove git_override and bump cel-spec once a new release is cut on BCR. +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 @@ -102,7 +110,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", ], diff --git a/common/src/main/java/dev/cel/common/CelOptions.java b/common/src/main/java/dev/cel/common/CelOptions.java index e1063e567..84ab99bcd 100644 --- a/common/src/main/java/dev/cel/common/CelOptions.java +++ b/common/src/main/java/dev/cel/common/CelOptions.java @@ -520,12 +520,12 @@ 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. + * No-op; retained for source compatibility. * - *

Disabling this option is an out-of-conformance behavior that suppresses nanosecond - * overflow validation when subtracting timestamps. + * @deprecated CEL durations now support the full protobuf duration range, so subtracting valid + * timestamps never overflows. Remove calls to this method. */ + @Deprecated public abstract Builder enableTimestampOverflowCheck(boolean value); /** diff --git a/common/src/main/java/dev/cel/common/internal/BUILD.bazel b/common/src/main/java/dev/cel/common/internal/BUILD.bazel index 409ddfb8f..9de68482e 100644 --- a/common/src/main/java/dev/cel/common/internal/BUILD.bazel +++ b/common/src/main/java/dev/cel/common/internal/BUILD.bazel @@ -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", ], @@ -438,6 +437,7 @@ java_library( tags = [ ], deps = [ + ":proto_time_utils", "//common/annotations", "//common/exceptions:bad_format", "@maven//:com_google_guava_guava", @@ -451,6 +451,7 @@ cel_android_library( tags = [ ], deps = [ + ":proto_time_utils_android", "//common/annotations", "//common/exceptions:bad_format", "@maven_android//:com_google_guava_guava", diff --git a/common/src/main/java/dev/cel/common/internal/DateTimeHelpers.java b/common/src/main/java/dev/cel/common/internal/DateTimeHelpers.java index 805a9bd75..d2bd283c9 100644 --- a/common/src/main/java/dev/cel/common/internal/DateTimeHelpers.java +++ b/common/src/main/java/dev/cel/common/internal/DateTimeHelpers.java @@ -14,7 +14,14 @@ package dev.cel.common.internal; -import com.google.common.base.Strings; +import static com.google.common.base.Preconditions.checkArgument; +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.protobuf.Timestamp; import dev.cel.common.annotations.Internal; import dev.cel.common.exceptions.CelBadFormatException; @@ -32,14 +39,10 @@ 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); + private static final String DURATION_OUT_OF_RANGE_MESSAGE = + "Duration is out of valid range [-315,576,000,000.999999999s, +315,576,000,000.999999999s]"; /** * Constructs a new {@link LocalDateTime} instance @@ -79,6 +82,13 @@ 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. */ + @SuppressWarnings("GoodTime-ApiWithNumericTimeUnit") // CEL timestamp(int) takes epoch seconds. + public static Instant fromSecondsToInstant(long seconds) { + checkSecondsValid(seconds); + return Instant.ofEpochSecond(seconds); + } + /** * Parse from RFC 3339 date string to {@link java.time.Instant}. * @@ -92,6 +102,69 @@ 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) { + int len = text.length(); + boolean negative = text.startsWith("-"); + int i = (negative || text.startsWith("+")) ? 1 : 0; + 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(); + } + } + + // Truncate sub-nanosecond fractions exactly, matching cel-cpp/Abseil rather than Go float64. + 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(); + } + } + 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); @@ -102,10 +175,9 @@ 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; + Duration res = d1.plus(d2); + checkValid(res); + return res; } /** Subtracts a duration to an instant. */ @@ -118,54 +190,67 @@ 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); + Duration res = d1.minus(d2); + checkValid(res); + return res; + } - return newDuration; + /** Calculates the difference between two instants ({@code to - from}). */ + public static Duration between(Instant from, Instant to) { + checkValid(from); + checkValid(to); + return Duration.between(from, to); } - /** - * Formats a {@link Duration} into a minimal seconds-based representation. - * - *

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; + } - 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 boolean isAsciiDigit(char c) { + return c >= '0' && c <= '9'; + } + + private static long parseUnitNanos(String text, int start, int end) { + switch (text.substring(start, end)) { + case "ns": + return 1L; + case "us": + case "\u00b5s": + case "\u03bcs": + return 1_000L; + case "ms": + return 1_000_000L; + case "s": + return NANOS_PER_SECOND; + case "m": + return 60L * NANOS_PER_SECOND; + case "h": + return 3600L * NANOS_PER_SECOND; + default: + throw invalidDurationFormat(); } + } + + private static IllegalArgumentException invalidDurationFormat() { + return new IllegalArgumentException("invalid duration format"); + } - sb.append('s'); - return sb.toString(); + private static IllegalArgumentException outOfRangeDuration() { + return new IllegalArgumentException(DURATION_OUT_OF_RANGE_MESSAGE); } /** @@ -205,55 +290,21 @@ 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(); - - if (seconds < TIMESTAMP_SECONDS_MIN || seconds > TIMESTAMP_SECONDS_MAX) { - 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())); - } + checkSecondsValid(instant.getEpochSecond()); } - /** 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)) { - 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)); - } + checkArgument( + duration.compareTo(DURATION_MIN) >= 0 && duration.compareTo(DURATION_MAX) <= 0, + DURATION_OUT_OF_RANGE_MESSAGE); } - /** - * 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]. - * - *

Note: 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; - } - } - return true; + private static void checkSecondsValid(long seconds) { + checkArgument( + seconds >= TIMESTAMP_SECONDS_MIN && seconds <= TIMESTAMP_SECONDS_MAX, + "Timestamp is not valid. Seconds (%s) must be in range [-62,135,596,800," + + " +253,402,300,799].", + seconds); } private DateTimeHelpers() {} diff --git a/common/src/main/java/dev/cel/common/internal/ProtoTimeUtils.java b/common/src/main/java/dev/cel/common/internal/ProtoTimeUtils.java index b5405382b..e4eec565c 100644 --- a/common/src/main/java/dev/cel/common/internal/ProtoTimeUtils.java +++ b/common/src/main/java/dev/cel/common/internal/ProtoTimeUtils.java @@ -14,6 +14,7 @@ package dev.cel.common.internal; +import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.math.LongMath.checkedAdd; import static com.google.common.math.LongMath.checkedMultiply; import static com.google.common.math.LongMath.checkedSubtract; @@ -54,10 +55,10 @@ public final class ProtoTimeUtils { public static final long TIMESTAMP_SECONDS_MAX = 253402300799L; public static final long DURATION_SECONDS_MIN = -315576000000L; public static final long DURATION_SECONDS_MAX = 315576000000L; + static final int NANOS_PER_SECOND = 1000000000; + public static final int MAX_NANOS = NANOS_PER_SECOND - 1; private static final int MILLIS_PER_SECOND = 1000; - - private static final int NANOS_PER_SECOND = 1000000000; private static final int NANOS_PER_MILLISECOND = 1000000; private static final int NANOS_PER_MICROSECOND = 1000; @@ -180,7 +181,7 @@ public static long toMillis(Duration duration) { /** Create a Timestamp from the number of seconds elapsed from the epoch. */ public static Timestamp fromSecondsToTimestamp(long seconds) { - return normalizedTimestamp(seconds, 0); + return checkValid(normalizedTimestamp(seconds, 0)); } /** Create a Timestamp from the number of seconds elapsed from the epoch. */ @@ -198,17 +199,7 @@ public static Duration fromMillisToDuration(long milliseconds) { /** Throws an {@link IllegalArgumentException} if the given {@link Duration} is not valid. */ @CanIgnoreReturnValue private static Duration checkValid(Duration duration) { - long seconds = duration.getSeconds(); - int nanos = duration.getNanos(); - if (!isDurationValid(seconds, nanos)) { - throw new IllegalArgumentException( - Strings.lenientFormat( - "Duration is not valid. See proto definition for valid values. " - + "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)); - } + checkDurationValid(duration.getSeconds(), duration.getNanos()); return duration; } @@ -228,6 +219,17 @@ private static Timestamp checkValid(Timestamp timestamp) { return timestamp; } + private static void checkDurationValid(long seconds, int nanos) { + checkArgument( + isDurationValid(seconds, nanos), + "Duration is not valid. See proto definition for valid values. " + + "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); + } + /** * Convert Timestamp to RFC 3339 date string format. The output will always be Z-normalized and * uses 0, 3, 6 or 9 fractional digits as required to represent the exact value. Note that @@ -268,11 +270,12 @@ public static String toString(Timestamp timestamp) { * @throws IllegalArgumentException if the given duration is not in the valid range. */ public static String toString(Duration duration) { - checkValid(duration); - - long seconds = duration.getSeconds(); - int nanos = duration.getNanos(); + return formatDuration(duration.getSeconds(), duration.getNanos()); + } + /** Formats normalized duration components (same sign) as a seconds string (e.g. "-0.500s"). */ + public static String formatDuration(long seconds, int nanos) { + checkDurationValid(seconds, nanos); StringBuilder result = new StringBuilder(); if (seconds < 0 || nanos < 0) { result.append("-"); @@ -355,60 +358,34 @@ public static Timestamp parse(String value) throws ParseException { } } - /** Adds two durations */ + /** Adds two durations. */ public static Duration add(Duration d1, Duration d2) { - java.time.Duration javaDuration1 = ProtoTimeUtils.toJavaDuration(checkValid(d1)); - java.time.Duration javaDuration2 = ProtoTimeUtils.toJavaDuration(checkValid(d2)); - - java.time.Duration sum = javaDuration1.plus(javaDuration2); - - return ProtoTimeUtils.toProtoDuration(sum); + return checkValid( + toProtoDuration(toJavaDuration(checkValid(d1)).plus(toJavaDuration(checkValid(d2))))); } - /** Adds two timestamps. */ + /** Adds a duration to a timestamp. */ public static Timestamp add(Timestamp ts, Duration dur) { - Instant javaInstant = ProtoTimeUtils.toJavaInstant(checkValid(ts)); - java.time.Duration javaDuration = ProtoTimeUtils.toJavaDuration(checkValid(dur)); - - Instant newInstant = javaInstant.plus(javaDuration); - - return ProtoTimeUtils.toProtoTimestamp(newInstant); + return checkValid( + toProtoTimestamp(toJavaInstant(checkValid(ts)).plus(toJavaDuration(checkValid(dur))))); } - /** Subtract a duration from another. */ + /** Subtracts a duration from another. */ public static Duration subtract(Duration d1, Duration d2) { - java.time.Duration javaDuration1 = ProtoTimeUtils.toJavaDuration(checkValid(d1)); - java.time.Duration javaDuration2 = ProtoTimeUtils.toJavaDuration(checkValid(d2)); - - java.time.Duration sum = javaDuration1.minus(javaDuration2); - - return ProtoTimeUtils.toProtoDuration(sum); + return checkValid( + toProtoDuration(toJavaDuration(checkValid(d1)).minus(toJavaDuration(checkValid(d2))))); } - /** Subtracts two timestamps */ + /** Subtracts a duration from a timestamp. */ public static Timestamp subtract(Timestamp ts, Duration dur) { - Instant javaInstant = ProtoTimeUtils.toJavaInstant(checkValid(ts)); - java.time.Duration javaDuration = ProtoTimeUtils.toJavaDuration(checkValid(dur)); - - Instant newInstant = javaInstant.minus(javaDuration); - - return ProtoTimeUtils.toProtoTimestamp(newInstant); + return checkValid( + toProtoTimestamp(toJavaInstant(checkValid(ts)).minus(toJavaDuration(checkValid(dur))))); } - /** Calculate the difference between two timestamps. */ - public static Duration between(Timestamp from, Timestamp to, boolean validateOverflow) { - Instant javaFrom = ProtoTimeUtils.toJavaInstant(checkValid(from)); - Instant javaTo = ProtoTimeUtils.toJavaInstant(checkValid(to)); - - java.time.Duration between = java.time.Duration.between(javaFrom, javaTo); - if (validateOverflow) { - // Call toNanos() to validate 64-bit nanosecond overflow (throws ArithmeticException). - // Suppress unused variable warning as the duration object itself is returned. - @SuppressWarnings("unused") - long unused = between.toNanos(); - } - - return ProtoTimeUtils.toProtoDuration(between); + /** Calculates the difference between two timestamps. */ + public static Duration between(Timestamp from, Timestamp to) { + return toProtoDuration( + java.time.Duration.between(toJavaInstant(checkValid(from)), toJavaInstant(checkValid(to)))); } /** diff --git a/common/src/test/java/dev/cel/common/internal/BUILD.bazel b/common/src/test/java/dev/cel/common/internal/BUILD.bazel index a70489fb4..19afc5f89 100644 --- a/common/src/test/java/dev/cel/common/internal/BUILD.bazel +++ b/common/src/test/java/dev/cel/common/internal/BUILD.bazel @@ -23,6 +23,7 @@ java_library( "//common/internal:cel_descriptor_pools", "//common/internal:comparison_functions", "//common/internal:converter", + "//common/internal:date_time_helpers", "//common/internal:default_instance_message_factory", "//common/internal:default_lite_descriptor_pool", "//common/internal:default_message_factory", @@ -47,6 +48,7 @@ java_library( "@maven//:com_google_guava_guava", "@maven//:com_google_protobuf_protobuf_java", "@maven//:com_google_testparameterinjector_test_parameter_injector", + "@maven//:com_google_truth_extensions_truth_proto_extension", "@maven//:junit_junit", "@maven_android//:com_google_protobuf_protobuf_javalite", ], diff --git a/common/src/test/java/dev/cel/common/internal/DateTimeHelpersTest.java b/common/src/test/java/dev/cel/common/internal/DateTimeHelpersTest.java new file mode 100644 index 000000000..3516ba264 --- /dev/null +++ b/common/src/test/java/dev/cel/common/internal/DateTimeHelpersTest.java @@ -0,0 +1,241 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dev.cel.common.internal; + +import static com.google.common.truth.Truth.assertThat; +import static dev.cel.common.internal.ProtoTimeUtils.MAX_NANOS; +import static dev.cel.common.internal.ProtoTimeUtils.TIMESTAMP_SECONDS_MAX; +import static dev.cel.common.internal.ProtoTimeUtils.TIMESTAMP_SECONDS_MIN; +import static org.junit.Assert.assertThrows; + +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import com.google.testing.junit.testparameterinjector.TestParameters; +import java.time.Duration; +import java.time.Instant; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +@SuppressWarnings("GoodTime-ApiWithNumericTimeUnit") // Tests raw seconds/nanos boundaries. +public final class DateTimeHelpersTest { + + @Test + @TestParameters("{text: '0', expectedSeconds: 0, expectedNanoAdjustment: 0}") + @TestParameters("{text: '+0', expectedSeconds: 0, expectedNanoAdjustment: 0}") + @TestParameters("{text: '-0', expectedSeconds: 0, expectedNanoAdjustment: 0}") + @TestParameters("{text: '0s', expectedSeconds: 0, expectedNanoAdjustment: 0}") + @TestParameters("{text: '+1s', expectedSeconds: 1, expectedNanoAdjustment: 0}") + @TestParameters("{text: '-1s', expectedSeconds: -1, expectedNanoAdjustment: 0}") + @TestParameters("{text: '1ns', expectedSeconds: 0, expectedNanoAdjustment: 1}") + @TestParameters("{text: '1us', expectedSeconds: 0, expectedNanoAdjustment: 1000}") + @TestParameters("{text: '1\u00b5s', expectedSeconds: 0, expectedNanoAdjustment: 1000}") + @TestParameters("{text: '1\u03bcs', expectedSeconds: 0, expectedNanoAdjustment: 1000}") + @TestParameters("{text: '1ms', expectedSeconds: 0, expectedNanoAdjustment: 1000000}") + @TestParameters("{text: '1m', expectedSeconds: 60, expectedNanoAdjustment: 0}") + @TestParameters("{text: '1h', expectedSeconds: 3600, expectedNanoAdjustment: 0}") + @TestParameters("{text: '1m2s', expectedSeconds: 62, expectedNanoAdjustment: 0}") + @TestParameters("{text: '1m.5s', expectedSeconds: 60, expectedNanoAdjustment: 500000000}") + @TestParameters("{text: '.5s', expectedSeconds: 0, expectedNanoAdjustment: 500000000}") + @TestParameters("{text: '1.s', expectedSeconds: 1, expectedNanoAdjustment: 0}") + @TestParameters("{text: '0.1234567899s', expectedSeconds: 0, expectedNanoAdjustment: 123456789}") + @TestParameters( + "{text: '-0.1234567899s', expectedSeconds: 0, expectedNanoAdjustment: -123456789}") + @TestParameters( + "{text: '0.0900000000000000000s', expectedSeconds: 0, expectedNanoAdjustment: 90000000}") + @TestParameters( + "{text: '0.999999999999999999h', expectedSeconds: 3599, expectedNanoAdjustment: 999999999}") + @TestParameters( + "{text: '-9223372036854775808ns'," + + " expectedSeconds: -9223372036, expectedNanoAdjustment: -854775808}") + @TestParameters( + "{text: '315576000000s', expectedSeconds: 315576000000, expectedNanoAdjustment: 0}") + @TestParameters( + "{text: '315576000000999999999ns'," + + " expectedSeconds: 315576000000, expectedNanoAdjustment: 999999999}") + @TestParameters( + "{text: '-315576000000999999999ns'," + + " expectedSeconds: -315576000000, expectedNanoAdjustment: -999999999}") + @TestParameters( + "{text: '315576000000.999999999s'," + + " expectedSeconds: 315576000000, expectedNanoAdjustment: 999999999}") + @TestParameters( + "{text: '-315576000000.999999999s'," + + " expectedSeconds: -315576000000, expectedNanoAdjustment: -999999999}") + public void parseDuration_valid(String text, long expectedSeconds, long expectedNanoAdjustment) { + assertThat(DateTimeHelpers.parseDuration(text)) + .isEqualTo(Duration.ofSeconds(expectedSeconds, expectedNanoAdjustment)); + } + + @Test + @TestParameters("{text: ''}") + @TestParameters("{text: '+'}") + @TestParameters("{text: '-'}") + @TestParameters("{text: '1'}") + @TestParameters("{text: '1.'}") + @TestParameters("{text: '1.5'}") + @TestParameters("{text: '.'}") + @TestParameters("{text: '00'}") + @TestParameters("{text: '0x'}") + @TestParameters("{text: '.s'}") + @TestParameters("{text: 's'}") + @TestParameters("{text: '/s'}") + @TestParameters("{text: ':s'}") + @TestParameters("{text: '-1-1s'}") + @TestParameters("{text: 'bad'}") + @TestParameters("{text: 'inf'}") + @TestParameters("{text: '1d'}") + @TestParameters("{text: '1ma'}") + @TestParameters("{text: '1xs'}") + @TestParameters("{text: '999999999999999999999x'}") + public void parseDuration_invalidFormatThrows(String text) { + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> DateTimeHelpers.parseDuration(text)); + + assertThat(e).hasMessageThat().isEqualTo("invalid duration format"); + } + + @Test + @TestParameters("{text: '315576000001s'}") + @TestParameters("{text: '-315576000001s'}") + @TestParameters("{text: '315576000000s1000000000ns'}") + @TestParameters("{text: '315576000000.5s0.5s'}") + @TestParameters("{text: '315576000000.999999999s1ns'}") + @TestParameters("{text: '-315576000000.999999999s1ns'}") + @TestParameters("{text: '18446744073709551616s'}") + public void parseDuration_outOfRangeThrows(String text) { + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> DateTimeHelpers.parseDuration(text)); + + assertThat(e) + .hasMessageThat() + .isEqualTo( + "Duration is out of valid range [-315,576,000,000.999999999s," + + " +315,576,000,000.999999999s]"); + } + + @Test + @TestParameters("{seconds: -62135596800}") + @TestParameters("{seconds: 0}") + @TestParameters("{seconds: 253402300799}") + public void fromSecondsToInstant_valid(long seconds) { + assertThat(DateTimeHelpers.fromSecondsToInstant(seconds)) + .isEqualTo(Instant.ofEpochSecond(seconds)); + } + + @Test + @TestParameters("{seconds: -62135596801}") + @TestParameters("{seconds: 253402300800}") + @TestParameters("{seconds: -9223372036854775808}") + @TestParameters("{seconds: 9223372036854775807}") + public void fromSecondsToInstant_outOfRangeThrows(long seconds) { + assertThrows( + IllegalArgumentException.class, () -> DateTimeHelpers.fromSecondsToInstant(seconds)); + } + + @Test + @TestParameters( + "{d1Seconds: 315576000000, d1Nanos: 999999998, d2Nanos: 1," + + " expectedSeconds: 315576000000, expectedNanos: 999999999}") + @TestParameters( + "{d1Seconds: -315576000000, d1Nanos: -999999998, d2Nanos: -1," + + " expectedSeconds: -315576000000, expectedNanos: -999999999}") + public void add_duration_atBounds( + long d1Seconds, long d1Nanos, long d2Nanos, long expectedSeconds, long expectedNanos) { + assertThat( + DateTimeHelpers.add(Duration.ofSeconds(d1Seconds, d1Nanos), Duration.ofNanos(d2Nanos))) + .isEqualTo(Duration.ofSeconds(expectedSeconds, expectedNanos)); + } + + @Test + @TestParameters("{d1Seconds: 315576000000, d1Nanos: 999999999, d2Nanos: 1}") + @TestParameters("{d1Seconds: -315576000000, d1Nanos: -999999999, d2Nanos: -1}") + public void add_duration_outOfRangeThrows(long d1Seconds, long d1Nanos, long d2Nanos) { + Duration d1 = Duration.ofSeconds(d1Seconds, d1Nanos); + Duration d2 = Duration.ofNanos(d2Nanos); + + assertThrows(IllegalArgumentException.class, () -> DateTimeHelpers.add(d1, d2)); + } + + @Test + @TestParameters( + "{d1Seconds: -315576000000, d1Nanos: -999999998, d2Nanos: 1," + + " expectedSeconds: -315576000000, expectedNanos: -999999999}") + @TestParameters( + "{d1Seconds: 315576000000, d1Nanos: 999999998, d2Nanos: -1," + + " expectedSeconds: 315576000000, expectedNanos: 999999999}") + public void subtract_duration_atBounds( + long d1Seconds, long d1Nanos, long d2Nanos, long expectedSeconds, long expectedNanos) { + assertThat( + DateTimeHelpers.subtract( + Duration.ofSeconds(d1Seconds, d1Nanos), Duration.ofNanos(d2Nanos))) + .isEqualTo(Duration.ofSeconds(expectedSeconds, expectedNanos)); + } + + @Test + @TestParameters("{d1Seconds: -315576000000, d1Nanos: -999999999, d2Nanos: 1}") + @TestParameters("{d1Seconds: 315576000000, d1Nanos: 999999999, d2Nanos: -1}") + public void subtract_duration_outOfRangeThrows(long d1Seconds, long d1Nanos, long d2Nanos) { + Duration d1 = Duration.ofSeconds(d1Seconds, d1Nanos); + Duration d2 = Duration.ofNanos(d2Nanos); + + assertThrows(IllegalArgumentException.class, () -> DateTimeHelpers.subtract(d1, d2)); + } + + @Test + public void between_fullTimestampRange() { + Instant minTs = Instant.ofEpochSecond(TIMESTAMP_SECONDS_MIN); + Instant maxTs = Instant.ofEpochSecond(TIMESTAMP_SECONDS_MAX, MAX_NANOS); + + assertThat(DateTimeHelpers.between(minTs, maxTs)) + .isEqualTo(Duration.ofSeconds(TIMESTAMP_SECONDS_MAX - TIMESTAMP_SECONDS_MIN, MAX_NANOS)); + assertThat(DateTimeHelpers.between(maxTs, minTs)) + .isEqualTo(Duration.ofSeconds(TIMESTAMP_SECONDS_MIN - TIMESTAMP_SECONDS_MAX, -MAX_NANOS)); + } + + @Test + @TestParameters("{fromSeconds: -62135596801, toSeconds: 0}") + @TestParameters("{fromSeconds: 0, toSeconds: 253402300800}") + public void between_invalidTimestampThrows(long fromSeconds, long toSeconds) { + Instant from = Instant.ofEpochSecond(fromSeconds); + Instant to = Instant.ofEpochSecond(toSeconds); + + assertThrows(IllegalArgumentException.class, () -> DateTimeHelpers.between(from, to)); + } + + @Test + @TestParameters("{seconds: 0, nanoAdjustment: 0, expected: '0s'}") + @TestParameters("{seconds: 0, nanoAdjustment: 500, expected: '0.000000500s'}") + @TestParameters("{seconds: 0, nanoAdjustment: -500, expected: '-0.000000500s'}") + @TestParameters("{seconds: -1, nanoAdjustment: 0, expected: '-1s'}") + @TestParameters("{seconds: -1, nanoAdjustment: -500, expected: '-1.000000500s'}") + @TestParameters( + "{seconds: 315576000000, nanoAdjustment: 999999999, expected: '315576000000.999999999s'}") + @TestParameters( + "{seconds: -315576000000, nanoAdjustment: -999999999, expected: '-315576000000.999999999s'}") + public void toString_duration(long seconds, long nanoAdjustment, String expected) { + assertThat(DateTimeHelpers.toString(Duration.ofSeconds(seconds, nanoAdjustment))) + .isEqualTo(expected); + } + + @Test + @TestParameters("{seconds: 315576000000, nanoAdjustment: 1000000000}") + @TestParameters("{seconds: -315576000000, nanoAdjustment: -1000000000}") + @TestParameters("{seconds: -9223372036854775808, nanoAdjustment: 0}") + public void toString_duration_outOfRangeThrows(long seconds, long nanoAdjustment) { + Duration duration = Duration.ofSeconds(seconds, nanoAdjustment); + + assertThrows(IllegalArgumentException.class, () -> DateTimeHelpers.toString(duration)); + } +} diff --git a/common/src/test/java/dev/cel/common/internal/ProtoTimeUtilsTest.java b/common/src/test/java/dev/cel/common/internal/ProtoTimeUtilsTest.java index 31ee9a9eb..9c6dae34b 100644 --- a/common/src/test/java/dev/cel/common/internal/ProtoTimeUtilsTest.java +++ b/common/src/test/java/dev/cel/common/internal/ProtoTimeUtilsTest.java @@ -15,44 +15,47 @@ package dev.cel.common.internal; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat; 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.TIMESTAMP_SECONDS_MAX; +import static dev.cel.common.internal.ProtoTimeUtils.TIMESTAMP_SECONDS_MIN; +import static org.junit.Assert.assertThrows; import com.google.protobuf.Duration; import com.google.protobuf.Timestamp; import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import com.google.testing.junit.testparameterinjector.TestParameters; import java.time.Instant; import org.junit.Test; import org.junit.runner.RunWith; @RunWith(TestParameterInjector.class) +@SuppressWarnings("GoodTime-ApiWithNumericTimeUnit") // Tests proto seconds/nanos fields. public class ProtoTimeUtilsTest { @Test public void toJavaInstant_overRfc3339Range() { - Timestamp ts = - Timestamp.newBuilder().setSeconds(ProtoTimeUtils.TIMESTAMP_SECONDS_MAX + 1).build(); + Timestamp ts = Timestamp.newBuilder().setSeconds(TIMESTAMP_SECONDS_MAX + 1).build(); Instant instant = ProtoTimeUtils.toJavaInstant(ts); - assertThat(instant).isEqualTo(Instant.ofEpochSecond(ProtoTimeUtils.TIMESTAMP_SECONDS_MAX + 1)); + assertThat(instant).isEqualTo(Instant.ofEpochSecond(TIMESTAMP_SECONDS_MAX + 1)); } @Test public void toJavaInstant_underRfc3339Range() { - Timestamp ts = - Timestamp.newBuilder().setSeconds(ProtoTimeUtils.TIMESTAMP_SECONDS_MIN - 1).build(); + Timestamp ts = Timestamp.newBuilder().setSeconds(TIMESTAMP_SECONDS_MIN - 1).build(); Instant instant = ProtoTimeUtils.toJavaInstant(ts); - assertThat(instant).isEqualTo(Instant.ofEpochSecond(ProtoTimeUtils.TIMESTAMP_SECONDS_MIN - 1)); + assertThat(instant).isEqualTo(Instant.ofEpochSecond(TIMESTAMP_SECONDS_MIN - 1)); } @Test public void toJavaDuration_overRfc3339Range() { - Duration d = Duration.newBuilder() - .setSeconds(DURATION_SECONDS_MAX + 1) - .build(); + Duration d = Duration.newBuilder().setSeconds(DURATION_SECONDS_MAX + 1).build(); java.time.Duration duration = ProtoTimeUtils.toJavaDuration(d); @@ -61,12 +64,195 @@ public void toJavaDuration_overRfc3339Range() { @Test public void toJavaDuration_underRfc3339Range() { - Duration d = Duration.newBuilder() - .setSeconds(DURATION_SECONDS_MIN - 1) - .build(); + Duration d = Duration.newBuilder().setSeconds(DURATION_SECONDS_MIN - 1).build(); java.time.Duration duration = ProtoTimeUtils.toJavaDuration(d); assertThat(duration).isEqualTo(java.time.Duration.ofSeconds(DURATION_SECONDS_MIN - 1)); } + + @Test + @TestParameters("{seconds: -62135596800}") + @TestParameters("{seconds: 0}") + @TestParameters("{seconds: 253402300799}") + public void fromSecondsToTimestamp_valid(long seconds) { + assertThat(ProtoTimeUtils.fromSecondsToTimestamp(seconds)) + .isEqualTo(Timestamp.newBuilder().setSeconds(seconds).build()); + } + + @Test + @TestParameters("{seconds: -62135596801}") + @TestParameters("{seconds: 253402300800}") + @TestParameters("{seconds: -9223372036854775808}") + @TestParameters("{seconds: 9223372036854775807}") + public void fromSecondsToTimestamp_outOfRangeThrows(long seconds) { + assertThrows( + IllegalArgumentException.class, () -> ProtoTimeUtils.fromSecondsToTimestamp(seconds)); + } + + @Test + @TestParameters("{seconds: 0, nanos: 0, expected: '0s'}") + @TestParameters("{seconds: 1, nanos: 100000000, expected: '1.100s'}") + @TestParameters("{seconds: 1, nanos: 100000, expected: '1.000100s'}") + @TestParameters("{seconds: 1, nanos: 100, expected: '1.000000100s'}") + @TestParameters("{seconds: 0, nanos: -500000000, expected: '-0.500s'}") + @TestParameters("{seconds: 315576000000, nanos: 999999999, expected: '315576000000.999999999s'}") + @TestParameters( + "{seconds: -315576000000, nanos: -999999999, expected: '-315576000000.999999999s'}") + public void toString_duration(long seconds, int nanos, String expected) { + Duration d = Duration.newBuilder().setSeconds(seconds).setNanos(nanos).build(); + + assertThat(ProtoTimeUtils.toString(d)).isEqualTo(expected); + } + + @Test + @TestParameters("{seconds: -315576000001, nanos: 0}") + @TestParameters("{seconds: 315576000001, nanos: 0}") + @TestParameters("{seconds: 0, nanos: -1000000000}") + @TestParameters("{seconds: 0, nanos: 1000000000}") + @TestParameters("{seconds: 1, nanos: -1}") + @TestParameters("{seconds: -1, nanos: 1}") + public void toString_duration_invalidThrows(long seconds, int nanos) { + Duration invalid = Duration.newBuilder().setSeconds(seconds).setNanos(nanos).build(); + + assertThrows(IllegalArgumentException.class, () -> ProtoTimeUtils.toString(invalid)); + } + + @Test + @TestParameters( + "{d1Seconds: 315576000000, d1Nanos: 999999998, d2Nanos: 1," + + " expectedSeconds: 315576000000, expectedNanos: 999999999}") + @TestParameters( + "{d1Seconds: -315576000000, d1Nanos: -999999998, d2Nanos: -1," + + " expectedSeconds: -315576000000, expectedNanos: -999999999}") + public void add_duration_atBounds( + long d1Seconds, int d1Nanos, int d2Nanos, long expectedSeconds, int expectedNanos) { + Duration d1 = Duration.newBuilder().setSeconds(d1Seconds).setNanos(d1Nanos).build(); + Duration d2 = Duration.newBuilder().setNanos(d2Nanos).build(); + + assertThat(ProtoTimeUtils.add(d1, d2)) + .isEqualTo( + Duration.newBuilder().setSeconds(expectedSeconds).setNanos(expectedNanos).build()); + } + + @Test + @TestParameters("{d1Seconds: 315576000000, d1Nanos: 999999999, d2Nanos: 1}") + @TestParameters("{d1Seconds: -315576000000, d1Nanos: -999999999, d2Nanos: -1}") + public void add_duration_outOfRangeThrows(long d1Seconds, int d1Nanos, int d2Nanos) { + Duration d1 = Duration.newBuilder().setSeconds(d1Seconds).setNanos(d1Nanos).build(); + Duration d2 = Duration.newBuilder().setNanos(d2Nanos).build(); + + assertThrows(IllegalArgumentException.class, () -> ProtoTimeUtils.add(d1, d2)); + } + + @Test + @TestParameters( + "{d1Seconds: -315576000000, d1Nanos: -999999998, d2Nanos: 1," + + " expectedSeconds: -315576000000, expectedNanos: -999999999}") + @TestParameters( + "{d1Seconds: 315576000000, d1Nanos: 999999998, d2Nanos: -1," + + " expectedSeconds: 315576000000, expectedNanos: 999999999}") + public void subtract_duration_atBounds( + long d1Seconds, int d1Nanos, int d2Nanos, long expectedSeconds, int expectedNanos) { + Duration d1 = Duration.newBuilder().setSeconds(d1Seconds).setNanos(d1Nanos).build(); + Duration d2 = Duration.newBuilder().setNanos(d2Nanos).build(); + + assertThat(ProtoTimeUtils.subtract(d1, d2)) + .isEqualTo( + Duration.newBuilder().setSeconds(expectedSeconds).setNanos(expectedNanos).build()); + } + + @Test + @TestParameters("{d1Seconds: -315576000000, d1Nanos: -999999999, d2Nanos: 1}") + @TestParameters("{d1Seconds: 315576000000, d1Nanos: 999999999, d2Nanos: -1}") + public void subtract_duration_outOfRangeThrows(long d1Seconds, int d1Nanos, int d2Nanos) { + Duration d1 = Duration.newBuilder().setSeconds(d1Seconds).setNanos(d1Nanos).build(); + Duration d2 = Duration.newBuilder().setNanos(d2Nanos).build(); + + assertThrows(IllegalArgumentException.class, () -> ProtoTimeUtils.subtract(d1, d2)); + } + + @Test + @TestParameters( + "{tsSeconds: 253402300799, tsNanos: 999999998, durNanos: 1," + + " expectedSeconds: 253402300799, expectedNanos: 999999999}") + @TestParameters( + "{tsSeconds: -62135596800, tsNanos: 1, durNanos: -1," + + " expectedSeconds: -62135596800, expectedNanos: 0}") + public void add_timestamp_atBounds( + long tsSeconds, int tsNanos, int durNanos, long expectedSeconds, int expectedNanos) { + Timestamp ts = Timestamp.newBuilder().setSeconds(tsSeconds).setNanos(tsNanos).build(); + Duration dur = Duration.newBuilder().setNanos(durNanos).build(); + + assertThat(ProtoTimeUtils.add(ts, dur)) + .isEqualTo( + Timestamp.newBuilder().setSeconds(expectedSeconds).setNanos(expectedNanos).build()); + } + + @Test + @TestParameters("{tsSeconds: 253402300799, tsNanos: 999999999, durNanos: 1}") + @TestParameters("{tsSeconds: -62135596800, tsNanos: 0, durNanos: -1}") + public void add_timestamp_outOfRangeThrows(long tsSeconds, int tsNanos, int durNanos) { + Timestamp ts = Timestamp.newBuilder().setSeconds(tsSeconds).setNanos(tsNanos).build(); + Duration dur = Duration.newBuilder().setNanos(durNanos).build(); + + assertThrows(IllegalArgumentException.class, () -> ProtoTimeUtils.add(ts, dur)); + } + + @Test + @TestParameters( + "{tsSeconds: -62135596800, tsNanos: 1, durNanos: 1," + + " expectedSeconds: -62135596800, expectedNanos: 0}") + @TestParameters( + "{tsSeconds: 253402300799, tsNanos: 999999998, durNanos: -1," + + " expectedSeconds: 253402300799, expectedNanos: 999999999}") + public void subtract_timestamp_atBounds( + long tsSeconds, int tsNanos, int durNanos, long expectedSeconds, int expectedNanos) { + Timestamp ts = Timestamp.newBuilder().setSeconds(tsSeconds).setNanos(tsNanos).build(); + Duration dur = Duration.newBuilder().setNanos(durNanos).build(); + + assertThat(ProtoTimeUtils.subtract(ts, dur)) + .isEqualTo( + Timestamp.newBuilder().setSeconds(expectedSeconds).setNanos(expectedNanos).build()); + } + + @Test + @TestParameters("{tsSeconds: -62135596800, tsNanos: 0, durNanos: 1}") + @TestParameters("{tsSeconds: 253402300799, tsNanos: 999999999, durNanos: -1}") + public void subtract_timestamp_outOfRangeThrows(long tsSeconds, int tsNanos, int durNanos) { + Timestamp ts = Timestamp.newBuilder().setSeconds(tsSeconds).setNanos(tsNanos).build(); + Duration dur = Duration.newBuilder().setNanos(durNanos).build(); + + assertThrows(IllegalArgumentException.class, () -> ProtoTimeUtils.subtract(ts, dur)); + } + + @Test + public void between_fullTimestampRange() { + Timestamp min = Timestamp.newBuilder().setSeconds(TIMESTAMP_SECONDS_MIN).build(); + Timestamp max = + Timestamp.newBuilder().setSeconds(TIMESTAMP_SECONDS_MAX).setNanos(MAX_NANOS).build(); + + assertThat(ProtoTimeUtils.between(min, max)) + .isEqualTo( + Duration.newBuilder() + .setSeconds(TIMESTAMP_SECONDS_MAX - TIMESTAMP_SECONDS_MIN) + .setNanos(MAX_NANOS) + .build()); + assertThat(ProtoTimeUtils.between(max, min)) + .isEqualTo( + Duration.newBuilder() + .setSeconds(TIMESTAMP_SECONDS_MIN - TIMESTAMP_SECONDS_MAX) + .setNanos(-MAX_NANOS) + .build()); + } + + @Test + @TestParameters("{fromSeconds: 0, fromNanos: 1000000000, toSeconds: 253402300799}") + @TestParameters("{fromSeconds: -62135596800, fromNanos: 0, toSeconds: 253402300800}") + public void between_invalidTimestampThrows(long fromSeconds, int fromNanos, long toSeconds) { + Timestamp from = Timestamp.newBuilder().setSeconds(fromSeconds).setNanos(fromNanos).build(); + Timestamp to = Timestamp.newBuilder().setSeconds(toSeconds).build(); + + assertThrows(IllegalArgumentException.class, () -> ProtoTimeUtils.between(from, to)); + } } diff --git a/conformance/src/test/java/dev/cel/conformance/BUILD.bazel b/conformance/src/test/java/dev/cel/conformance/BUILD.bazel index 8a455005f..82bae1afc 100644 --- a/conformance/src/test/java/dev/cel/conformance/BUILD.bazel +++ b/conformance/src/test/java/dev/cel/conformance/BUILD.bazel @@ -138,15 +138,7 @@ _ALL_TESTS = [ ] _TESTS_TO_SKIP_SPEC_IMPORT = [ - # Skip until duration/timestamp range and proto wrapper validation are updated for spec parity. - "timestamps/duration_conversions/from_string_max", - "timestamps/duration_conversions/from_string_min", - "timestamps/duration_conversions/toString_duration_max", - "timestamps/duration_conversions/toString_duration_min", - "timestamps/timestamp_arithmetic/add_duration_to_duration_max", - "timestamps/timestamp_arithmetic/subtract_duration_from_duration_min", - "timestamps/timestamp_range/sub_time_duration_over", - "timestamps/timestamp_range/sub_time_duration_under", + # Skip until proto wrapper validation is updated for spec parity. "wrappers/duration/nanos_over", "wrappers/duration/nanos_under", "wrappers/duration/negative_seconds_positive_nanos", diff --git a/runtime/src/main/java/dev/cel/runtime/BUILD.bazel b/runtime/src/main/java/dev/cel/runtime/BUILD.bazel index f065c750c..28c8d0a10 100644 --- a/runtime/src/main/java/dev/cel/runtime/BUILD.bazel +++ b/runtime/src/main/java/dev/cel/runtime/BUILD.bazel @@ -431,10 +431,11 @@ cel_android_library( "//common/exceptions:invalid_argument", "//common/exceptions:numeric_overflow", "//common/internal:converter", + "//common/internal:date_time_helpers_android", + "//common/internal:proto_time_utils_android", "//common/values:values_android", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:com_google_re2j_re2j", - "@maven//:org_threeten_threeten_extra", "@maven_android//:com_google_guava_guava", "@maven_android//:com_google_protobuf_protobuf_javalite", ], @@ -456,12 +457,13 @@ java_library( "//common/exceptions:invalid_argument", "//common/exceptions:numeric_overflow", "//common/internal:converter", + "//common/internal:date_time_helpers", + "//common/internal:proto_time_utils", "//common/values", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:com_google_guava_guava", "@maven//:com_google_protobuf_protobuf_java", "@maven//:com_google_re2j_re2j", - "@maven//:org_threeten_threeten_extra", ], ) diff --git a/runtime/src/main/java/dev/cel/runtime/RuntimeHelpers.java b/runtime/src/main/java/dev/cel/runtime/RuntimeHelpers.java index fb2ef35d6..7e4a95ddd 100644 --- a/runtime/src/main/java/dev/cel/runtime/RuntimeHelpers.java +++ b/runtime/src/main/java/dev/cel/runtime/RuntimeHelpers.java @@ -14,8 +14,6 @@ package dev.cel.runtime; -import static com.google.common.base.Preconditions.checkArgument; - import com.google.common.primitives.Ints; import com.google.common.primitives.UnsignedLong; import com.google.common.primitives.UnsignedLongs; @@ -31,12 +29,12 @@ import dev.cel.common.exceptions.CelInvalidArgumentException; import dev.cel.common.exceptions.CelNumericOverflowException; import dev.cel.common.internal.Converter; +import dev.cel.common.internal.DateTimeHelpers; +import dev.cel.common.internal.ProtoTimeUtils; import dev.cel.common.values.NullValue; -import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import org.threeten.extra.AmountFormats; /** * Helper methods for common CEL related routines. @@ -47,10 +45,6 @@ @Internal public class RuntimeHelpers { - // Maximum and minimum range supported by protobuf Duration values. - private static final java.time.Duration DURATION_MAX = java.time.Duration.ofDays(3652500); - private static final java.time.Duration DURATION_MIN = DURATION_MAX.negated(); - public static RuntimeHelpers create() { return new RuntimeHelpers(); } @@ -60,22 +54,12 @@ public static RuntimeHelpers create() { /** Convert a string to a Protobuf Duration. */ public static Duration createDurationFromString(String d) { - java.time.Duration dv = createJavaDurationFromString(d); - return Duration.newBuilder().setSeconds(dv.getSeconds()).setNanos(dv.getNano()).build(); + return ProtoTimeUtils.toProtoDuration(createJavaDurationFromString(d)); } /** Convert a string to a native Java Duration. */ public static java.time.Duration createJavaDurationFromString(String d) { - try { - java.time.Duration dv = AmountFormats.parseUnitBasedDuration(d); - // Ensure that the duration value can be adequately represented within a protobuf.Duration. - checkArgument( - dv.compareTo(DURATION_MAX) <= 0 && dv.compareTo(DURATION_MIN) >= 0, - "invalid duration range"); - return dv; - } catch (DateTimeParseException e) { - throw new IllegalArgumentException("invalid duration format", e); - } + return DateTimeHelpers.parseDuration(d); } public static Pattern compileRegexPattern(String regexp, CelOptions celOptions) { diff --git a/runtime/src/main/java/dev/cel/runtime/standard/SubtractOperator.java b/runtime/src/main/java/dev/cel/runtime/standard/SubtractOperator.java index 822c3066a..f9f39e238 100644 --- a/runtime/src/main/java/dev/cel/runtime/standard/SubtractOperator.java +++ b/runtime/src/main/java/dev/cel/runtime/standard/SubtractOperator.java @@ -68,33 +68,13 @@ public enum SubtractOverload implements CelStandardOverload { "subtract_timestamp_timestamp", Instant.class, Instant.class, - (Instant i1, Instant i2) -> { - java.time.Duration between = java.time.Duration.between(i2, i1); - if (celOptions.enableTimestampOverflowCheck()) { - try { - // Call toNanos() to validate 64-bit nanosecond overflow (throws - // ArithmeticException). - @SuppressWarnings("unused") - long unused = between.toNanos(); - } catch (ArithmeticException e) { - throw new CelNumericOverflowException(e); - } - } - return between; - }); + (Instant i1, Instant i2) -> DateTimeHelpers.between(i2, i1)); } else { return CelFunctionBinding.from( "subtract_timestamp_timestamp", Timestamp.class, Timestamp.class, - (Timestamp t1, Timestamp t2) -> { - try { - return ProtoTimeUtils.between( - t2, t1, celOptions.enableTimestampOverflowCheck()); - } catch (ArithmeticException e) { - throw new CelNumericOverflowException(e); - } - }); + (Timestamp t1, Timestamp t2) -> ProtoTimeUtils.between(t2, t1)); } }), SUBTRACT_TIMESTAMP_DURATION( diff --git a/runtime/src/main/java/dev/cel/runtime/standard/TimestampFunction.java b/runtime/src/main/java/dev/cel/runtime/standard/TimestampFunction.java index 00a4ed43e..ce070c3c3 100644 --- a/runtime/src/main/java/dev/cel/runtime/standard/TimestampFunction.java +++ b/runtime/src/main/java/dev/cel/runtime/standard/TimestampFunction.java @@ -80,11 +80,7 @@ public enum TimestampOverload implements CelStandardOverload { (celOptions, runtimeEquality) -> { if (celOptions.evaluateCanonicalTypesToNativeValues()) { return CelFunctionBinding.from( - "int64_to_timestamp", Long.class, epochSecond -> { - Instant instant = Instant.ofEpochSecond(epochSecond); - DateTimeHelpers.checkValid(instant); - return instant; - }); + "int64_to_timestamp", Long.class, DateTimeHelpers::fromSecondsToInstant); } else { return CelFunctionBinding.from( "int64_to_timestamp", Long.class, ProtoTimeUtils::fromSecondsToTimestamp); diff --git a/runtime/src/test/java/dev/cel/runtime/ProtoMessageRuntimeHelpersTest.java b/runtime/src/test/java/dev/cel/runtime/ProtoMessageRuntimeHelpersTest.java index 45b050fd1..e914a025c 100644 --- a/runtime/src/test/java/dev/cel/runtime/ProtoMessageRuntimeHelpersTest.java +++ b/runtime/src/test/java/dev/cel/runtime/ProtoMessageRuntimeHelpersTest.java @@ -15,6 +15,7 @@ package dev.cel.runtime; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat; import static org.junit.Assert.assertThrows; import com.google.common.collect.ImmutableList; @@ -35,6 +36,8 @@ import com.google.protobuf.UInt32Value; import com.google.protobuf.UInt64Value; import com.google.protobuf.Value; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import com.google.testing.junit.testparameterinjector.TestParameters; import dev.cel.common.CelOptions; import dev.cel.common.exceptions.CelNumericOverflowException; import dev.cel.common.exceptions.CelRuntimeException; @@ -47,9 +50,8 @@ import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; -@RunWith(JUnit4.class) +@RunWith(TestParameterInjector.class) public final class ProtoMessageRuntimeHelpersTest { private static final DynamicProto DYNAMIC_PROTO = DynamicProto.create(DefaultMessageFactory.INSTANCE); @@ -57,16 +59,27 @@ public final class ProtoMessageRuntimeHelpersTest { ProtoMessageRuntimeHelpers.create(DYNAMIC_PROTO, CelOptions.DEFAULT); @Test - public void createDurationFromString() throws Exception { - assertThat(ProtoMessageRuntimeHelpers.createDurationFromString("15.11s")) - .isEqualTo(Duration.newBuilder().setSeconds(15).setNanos(110000000).build()); + @TestParameters("{text: '15.11s', expectedSeconds: 15, expectedNanos: 110000000}") + @TestParameters( + "{text: '315576000000s999999999ns'," + + " expectedSeconds: 315576000000, expectedNanos: 999999999}") + @TestParameters( + "{text: '-315576000000s999999999ns'," + + " expectedSeconds: -315576000000, expectedNanos: -999999999}") + @SuppressWarnings("GoodTime-ApiWithNumericTimeUnit") // Tests proto seconds/nanos fields. + public void createDurationFromString_valid(String text, long expectedSeconds, int expectedNanos) { + assertThat(ProtoMessageRuntimeHelpers.createDurationFromString(text)) + .isEqualTo( + Duration.newBuilder().setSeconds(expectedSeconds).setNanos(expectedNanos).build()); } @Test - public void createDurationFromString_outOfRange() throws Exception { + @TestParameters("{text: '-315576000000s1000000000ns'}") + @TestParameters("{text: '315576000000s1000000000ns'}") + public void createDurationFromString_outOfRangeThrows(String text) { assertThrows( IllegalArgumentException.class, - () -> ProtoMessageRuntimeHelpers.createDurationFromString("-320000000000s")); + () -> ProtoMessageRuntimeHelpers.createDurationFromString(text)); } @Test