From fb5a0d2f60bf0ed937e427a77015b921cb414149 Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Thu, 21 Aug 2025 12:48:58 -0400 Subject: [PATCH 01/13] adds Duration extension Signed-off-by: Mudit Chaudhary --- .../java/com/cedarpolicy/value/Duration.java | 224 ++++++++++++++++++ .../java/com/cedarpolicy/DurationTests.java | 178 ++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 CedarJava/src/main/java/com/cedarpolicy/value/Duration.java create mode 100644 CedarJava/src/test/java/com/cedarpolicy/DurationTests.java diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java b/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java new file mode 100644 index 0000000..d68e162 --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java @@ -0,0 +1,224 @@ +/* + * Copyright Cedar Contributors + * + * 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 com.cedarpolicy.value; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Represents a Cedar duration extension value. Duration values are encoded as strings in the + * following format: + * + * Duration strings are of the form "1d2h3m4s5ms" where: - d: days - h: hours - m: minutes - s: + * seconds - ms: milliseconds + * + * Duration strings are required to be ordered from largest unit to smallest unit, and contain one + * quantity per unit. Units with zero quantity may be omitted. Examples of valid duration strings: + * "1h", "-10h", "5d3ms", "3h5m", "2h5ms", "1d2ms". + * + * The quantity part must be a natural number (positive integer), but the entire duration can be + * negative by prefixing the first component with a minus sign. + */ +public class Duration extends Value implements Comparable { + + private static class DurationValidator { + + private static final Pattern DURATION_PATTERN = + Pattern.compile("^(-?)(?:(?:(\\d+)d)?(?:(\\d+)h)?(?:(\\d+)m(?!s))?(?:(\\d+)s)?(?:(\\d+)ms)?)$"); + + private static final long DAYS_TO_MS = 86_400_000L; + private static final long HOURS_TO_MS = 3_600_000L; + private static final long MINUTES_TO_MS = 60_000L; + private static final long SECONDS_TO_MS = 1_000L; + private static final long MILLISECONDS_TO_MS = 1L; + + /** + * Parses a duration string and returns the total milliseconds. Combines validation and parsing into + * a single operation to avoid redundancy. All duration formats are normalized to milliseconds for + * consistent equality comparison. + * + * @param durationString the string to parse + * @return the parsed total milliseconds + * @throws IllegalArgumentException if the format is invalid + * @throws ArithmeticException if the value would cause overflow + * @throws NumberFormatException if the number format is invalid + */ + private static long parseToMilliseconds(String durationString) + throws IllegalArgumentException, ArithmeticException { + if (durationString == null || durationString.trim().isEmpty()) { + throw new IllegalArgumentException("Duration string cannot be null or empty"); + } + + Matcher matcher = DURATION_PATTERN.matcher(durationString.trim()); + if (!matcher.matches()) { + throw new IllegalArgumentException("Invalid duration format"); + } + + // Extract the optional negative sign (group 1) + String signStr = matcher.group(1); + boolean isNegative = "-".equals(signStr); + + long totalMs = 0; + boolean hasAnyComponent = false; + + // Extract days (group 2) + String daysStr = matcher.group(2); + if (daysStr != null && !daysStr.isEmpty()) { + long days = Long.parseLong(daysStr); + totalMs = Math.addExact(totalMs, Math.multiplyExact(days, DAYS_TO_MS)); + hasAnyComponent = true; + } + + // Extract hours (group 3) + String hoursStr = matcher.group(3); + if (hoursStr != null && !hoursStr.isEmpty()) { + long hours = Long.parseLong(hoursStr); + totalMs = Math.addExact(totalMs, Math.multiplyExact(hours, HOURS_TO_MS)); + hasAnyComponent = true; + } + + // Extract minutes (group 4) + String minutesStr = matcher.group(4); + if (minutesStr != null && !minutesStr.isEmpty()) { + long minutes = Long.parseLong(minutesStr); + totalMs = Math.addExact(totalMs, Math.multiplyExact(minutes, MINUTES_TO_MS)); + hasAnyComponent = true; + } + + // Extract seconds (group 5) + String secondsStr = matcher.group(5); + if (secondsStr != null && !secondsStr.isEmpty()) { + long seconds = Long.parseLong(secondsStr); + totalMs = Math.addExact(totalMs, Math.multiplyExact(seconds, SECONDS_TO_MS)); + hasAnyComponent = true; + } + + // Extract milliseconds (group 6) + String millisecondsStr = matcher.group(6); + if (millisecondsStr != null && !millisecondsStr.isEmpty()) { + long milliseconds = Long.parseLong(millisecondsStr); + totalMs = Math.addExact(totalMs, Math.multiplyExact(milliseconds, MILLISECONDS_TO_MS)); + hasAnyComponent = true; + } + + // Must have at least one component + if (!hasAnyComponent) { + throw new IllegalArgumentException("Invalid duration format"); + } + + // Apply negative sign to the total if present + if (isNegative) { + totalMs = Math.negateExact(totalMs); + } + + return totalMs; + } + } + + /** Duration as a string. */ + private final String durationString; + + /** Parsed duration as total milliseconds for semantic comparison. */ + private final long totalMilliseconds; + + /** + * Construct Duration. + * + * @param duration Duration as a String. + */ + @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") + public Duration(String duration) throws NullPointerException, IllegalArgumentException { + if (duration == null) { + throw new NullPointerException("Duration string cannot be null"); + } + + try { + this.totalMilliseconds = DurationValidator.parseToMilliseconds(duration); + this.durationString = duration; + } catch (ArithmeticException e) { + throw new IllegalArgumentException("Duration value is too large and would cause overflow: " + duration, e); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Input string is not a supported Duration format: " + duration, e); + } + } + + /** Convert Duration to Cedar expr that can be used in a Cedar policy. */ + @Override + public String toCedarExpr() { + return "duration(\"" + durationString + "\")"; + } + + /** + * Equals based on semantic comparison of the parsed duration values. Two Duration objects are equal + * if they represent the same time span, regardless of their string representation format. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Duration other = (Duration) o; + return this.totalMilliseconds == other.totalMilliseconds; + } + + /** + * Hash based on the parsed duration value for semantic equality. + */ + @Override + public int hashCode() { + return Objects.hash(totalMilliseconds); + } + + /** As a string. */ + @Override + public String toString() { + return durationString; + } + + /** + * Compares this Duration with another Duration based on their total milliseconds. Returns a + * negative integer, zero, or a positive integer as this Duration is less than, equal to, or greater + * than the specified Duration. + * + * @param other the Duration to compare with + * @return a negative integer, zero, or a positive integer as this Duration is less than, equal to, + * or greater than the specified Duration + * @throws NullPointerException if the specified Duration is null + */ + @Override + public int compareTo(Duration other) { + if (other == null) { + throw new NullPointerException("Cannot compare with null Duration"); + } + return Long.compare(this.totalMilliseconds, other.totalMilliseconds); + } + + /** + * Returns the total duration in milliseconds. This is used internally for datetime offset + * operations. + * + * @return the total duration in milliseconds + */ + public long getTotalMilliseconds() { + return this.totalMilliseconds; + } +} diff --git a/CedarJava/src/test/java/com/cedarpolicy/DurationTests.java b/CedarJava/src/test/java/com/cedarpolicy/DurationTests.java new file mode 100644 index 0000000..9892806 --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/DurationTests.java @@ -0,0 +1,178 @@ +/* + * Copyright Cedar Contributors + * + * 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 com.cedarpolicy; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import com.cedarpolicy.value.Duration; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.io.IOException; + +public class DurationTests { + + @Test + public void testValidDurations() { + // Test basic valid formats + assertDoesNotThrow(() -> new Duration("1d2h3m4s5ms")); + assertDoesNotThrow(() -> new Duration("2h5ms")); + assertDoesNotThrow(() -> new Duration("2h")); + assertDoesNotThrow(() -> new Duration("1d2ms")); + assertDoesNotThrow(() -> new Duration("3h5m")); + assertDoesNotThrow(() -> new Duration("-10h")); + assertDoesNotThrow(() -> new Duration("1h")); + assertDoesNotThrow(() -> new Duration("5d3ms")); + + // Test edge cases + assertDoesNotThrow(() -> new Duration("0d")); + assertDoesNotThrow(() -> new Duration("1ms")); + assertDoesNotThrow(() -> new Duration("999s")); + } + + @Test + public void testInvalidDurations() { + // Test null input + assertThrows(NullPointerException.class, () -> new Duration(null)); + + // Test empty string + assertThrows(IllegalArgumentException.class, () -> new Duration("")); + assertThrows(IllegalArgumentException.class, () -> new Duration(" ")); + + // Test wrong order + assertThrows(IllegalArgumentException.class, () -> new Duration("2h1d")); + + // Test duplicate units + assertThrows(IllegalArgumentException.class, () -> new Duration("2d2d")); + + // Test invalid units + assertThrows(IllegalArgumentException.class, () -> new Duration("2x")); + + // Test missing quantity + assertThrows(IllegalArgumentException.class, () -> new Duration("h")); + assertThrows(IllegalArgumentException.class, () -> new Duration("d2h")); + + // Test invalid format + assertThrows(IllegalArgumentException.class, () -> new Duration("2h3m1h")); + assertThrows(IllegalArgumentException.class, () -> new Duration("abc")); + + // Test mixed signs (should be invalid - only negative at beginning allowed) + assertThrows(IllegalArgumentException.class, () -> new Duration("1d-5h")); + assertThrows(IllegalArgumentException.class, () -> new Duration("-1d+5h")); + assertThrows(IllegalArgumentException.class, () -> new Duration("1d+5h")); + assertThrows(IllegalArgumentException.class, () -> new Duration("+1d5h")); + + // Test signs on individual components (should be invalid) + assertThrows(IllegalArgumentException.class, () -> new Duration("+2h")); + assertThrows(IllegalArgumentException.class, () -> new Duration("2d-3m")); + } + + @Test + public void testSemanticEquality() { + // Same duration, different representations should be equal + Duration duration1 = new Duration("60s"); + Duration duration2 = new Duration("1m"); + assertEquals(duration1, duration2); + assertEquals(duration1.hashCode(), duration2.hashCode()); + + // Different durations should not be equal + Duration duration3 = new Duration("1h"); + Duration duration4 = new Duration("1m"); + assertNotEquals(duration3, duration4); + } + + @Test + public void testToString() { + Duration duration = new Duration("1d2h3m"); + assertEquals("1d2h3m", duration.toString()); + } + + @Test + public void testToCedarExpr() { + Duration duration = new Duration("1h30m"); + assertEquals("duration(\"1h30m\")", duration.toCedarExpr()); + } + + @Test + public void testCompareTo() { + Duration oneHour = new Duration("1h"); + Duration twoHours = new Duration("2h"); + Duration oneHourAlternative = new Duration("60m"); + + // Test less than + assertTrue(oneHour.compareTo(twoHours) < 0); + + // Test greater than + assertTrue(twoHours.compareTo(oneHour) > 0); + + // Test equal (same duration, different format) + assertEquals(0, oneHour.compareTo(oneHourAlternative)); + + // Test with null - should throw exception + assertThrows(NullPointerException.class, () -> oneHour.compareTo(null)); + } + + @Test + public void testValidJsonSerialization() throws JsonProcessingException { + String durationString = "1h30m45s"; + Duration duration = new Duration(durationString); + + String json = CedarJson.objectWriter().writeValueAsString(duration); + String expectedJson = "{\"__extn\":{\"fn\":\"duration\",\"arg\":\"1h30m45s\"}}"; + + assertEquals(expectedJson, json); + } + + @Test + public void testValidJsonDeserialization() throws IOException { + String json = "{\"__extn\":{\"fn\":\"duration\",\"arg\":\"2d3h15m\"}}"; + + Duration duration = CedarJson.objectReader().readValue(json, Duration.class); + + assertEquals("2d3h15m", duration.toString()); + assertEquals("duration(\"2d3h15m\")", duration.toCedarExpr()); + } + + @Test + public void testInvalidJsonDeserialization() { + // Test that invalid JSON throws appropriate exceptions + String invalidJson = "{\"__extn\":{\"fn\":\"duration\",\"arg\":\"invalid-duration\"}}"; + + assertThrows(Exception.class, () -> { + CedarJson.objectReader().readValue(invalidJson, Duration.class); + }); + } + + @Test + public void testJsonRoundTrip() throws IOException { + String[] testDurations = {"1d", "2h30m", "5m15s", "1d2h3m4s5ms", "30s", "999ms", "-2h", "0d"}; + + for (String durationString : testDurations) { + Duration original = new Duration(durationString); + + // Serialize to JSON + String json = CedarJson.objectWriter().writeValueAsString(original); + + // Deserialize back from JSON + Duration deserialized = CedarJson.objectReader().readValue(json, Duration.class); + + // Verify they are equal + assertEquals(original, deserialized); + assertEquals(original.toString(), deserialized.toString()); + assertEquals(original.toCedarExpr(), deserialized.toCedarExpr()); + } + } +} From 394c93cb3becbbcb0a6aad4e20f0e1722e17690f Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Thu, 21 Aug 2025 12:49:25 -0400 Subject: [PATCH 02/13] adds Offset function Signed-off-by: Mudit Chaudhary --- .../cedarpolicy/value/functions/Offset.java | 41 +++++++ .../java/com/cedarpolicy/OffsetTests.java | 115 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java create mode 100644 CedarJava/src/test/java/com/cedarpolicy/OffsetTests.java diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java b/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java new file mode 100644 index 0000000..646572f --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java @@ -0,0 +1,41 @@ +/* + * Copyright Cedar Contributors + * + * 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 com.cedarpolicy.value.functions; + +import lombok.Getter; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import com.cedarpolicy.value.DateTime; +import com.cedarpolicy.value.Duration; +import com.cedarpolicy.value.Value; + +@Getter +public class Offset extends Value { + + private final Duration offsetDuration; + private final DateTime dateTime; + + @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") + public Offset(DateTime dateTime, Duration offsetDuration) throws NullPointerException, IllegalArgumentException { + this.dateTime = dateTime; + this.offsetDuration = offsetDuration; + } + + @Override + public String toCedarExpr() { + return String.format("%s.offset(%s)", this.dateTime.toCedarExpr(), this.offsetDuration.toCedarExpr()); + } +} diff --git a/CedarJava/src/test/java/com/cedarpolicy/OffsetTests.java b/CedarJava/src/test/java/com/cedarpolicy/OffsetTests.java new file mode 100644 index 0000000..0001882 --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/OffsetTests.java @@ -0,0 +1,115 @@ +/* + * Copyright Cedar Contributors + * + * 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 com.cedarpolicy; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import com.cedarpolicy.value.functions.Offset; +import com.cedarpolicy.value.DateTime; +import com.cedarpolicy.value.Duration; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.io.IOException; + +public class OffsetTests { + + @Test + public void testValidJsonSerialization() throws JsonProcessingException { + DateTime dateTime = new DateTime("2023-12-25T10:30:45Z"); + Duration duration = new Duration("2h30m"); + Offset offset = new Offset(dateTime, duration); + + String json = CedarJson.objectWriter().writeValueAsString(offset); + String expectedJson = "{\"__extn\":{\"fn\":\"offset\",\"args\":[" + + "{\"__extn\":{\"fn\":\"datetime\",\"arg\":\"2023-12-25T10:30:45Z\"}}," + + "{\"__extn\":{\"fn\":\"duration\",\"arg\":\"2h30m\"}}]}}"; + + assertEquals(expectedJson, json); + } + + @Test + public void testValidJsonDeserialization() throws IOException { + String json = "{\"__extn\":{\"fn\":\"offset\",\"args\":[" + + "{\"__extn\":{\"fn\":\"datetime\",\"arg\":\"2023-01-01T00:00:00Z\"}}," + + "{\"__extn\":{\"fn\":\"duration\",\"arg\":\"1d5h\"}}]}}"; + + Offset offset = CedarJson.objectReader().readValue(json, Offset.class); + + assertEquals("2023-01-01T00:00:00Z", offset.getDateTime().toString()); + assertEquals("1d5h", offset.getOffsetDuration().toString()); + assertEquals("datetime(\"2023-01-01T00:00:00Z\").offset(duration(\"1d5h\"))", offset.toCedarExpr()); + } + + @Test + public void testInvalidJsonDeserialization() { + // Test that invalid JSON throws appropriate exceptions + String invalidJson = "{\"__extn\":{\"fn\":\"offset\",\"args\":\"invalid-offset\"}}"; + + assertThrows(Exception.class, () -> { + CedarJson.objectReader().readValue(invalidJson, Offset.class); + }); + + // Test with invalid datetime + String invalidDateTimeJson = "{\"__extn\":{\"fn\":\"offset\",\"args\":[" + + "{\"__extn\":{\"fn\":\"datetime\",\"arg\":\"invalid-date\"}}," + + "{\"__extn\":{\"fn\":\"duration\",\"arg\":\"1h\"}}]}}"; + + assertThrows(Exception.class, () -> { + CedarJson.objectReader().readValue(invalidDateTimeJson, Offset.class); + }); + + // Test with invalid duration + String invalidDurationJson = "{\"__extn\":{\"fn\":\"offset\",\"args\":[" + + "{\"__extn\":{\"fn\":\"datetime\",\"arg\":\"2023-01-01T00:00:00Z\"}}," + + "{\"__extn\":{\"fn\":\"duration\",\"arg\":\"invalid-duration\"}}]}}"; + + assertThrows(Exception.class, () -> { + CedarJson.objectReader().readValue(invalidDurationJson, Offset.class); + }); + } + + @Test + public void testJsonRoundTrip() throws IOException { + // Test data: array of [dateTimeString, durationString] pairs + String[][] testOffsets = { + {"2023-12-25T10:30:45Z", "2h30m"}, + {"2023-01-01T00:00:00Z", "1d"}, + {"2023-06-15T14:22:33.123Z", "5m30s"}, + {"2023-03-10T08:45:12+0500", "1h15m45s"}, + {"2023-11-30T23:59:59-0800", "-30m"}, + {"2023-02-28", "24h"}, + {"2023-07-04T12:00:00.999Z", "1ms"} + }; + + for (String[] testOffset : testOffsets) { + DateTime dateTime = new DateTime(testOffset[0]); + Duration duration = new Duration(testOffset[1]); + Offset original = new Offset(dateTime, duration); + + // Serialize to JSON + String json = CedarJson.objectWriter().writeValueAsString(original); + + // Deserialize back from JSON + Offset deserialized = CedarJson.objectReader().readValue(json, Offset.class); + + // Verify they are equal + assertEquals(original.getDateTime().toString(), deserialized.getDateTime().toString()); + assertEquals(original.getOffsetDuration().toString(), deserialized.getOffsetDuration().toString()); + assertEquals(original.toCedarExpr(), deserialized.toCedarExpr()); + } + } +} From 8d0b95a39804f75e370beac7de24ee5cb1bda397 Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Thu, 21 Aug 2025 12:50:03 -0400 Subject: [PATCH 03/13] adds serializer/deserializers for Duration and Offset Signed-off-by: Mudit Chaudhary --- .../serializer/ValueDeserializer.java | 42 +++++++++++++++++++ .../serializer/ValueSerializer.java | 26 ++++++++++++ 2 files changed, 68 insertions(+) diff --git a/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java b/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java index 1172e48..92c1216 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java +++ b/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java @@ -22,6 +22,7 @@ import com.cedarpolicy.value.CedarMap; import com.cedarpolicy.value.DateTime; import com.cedarpolicy.value.Decimal; +import com.cedarpolicy.value.Duration; import com.cedarpolicy.value.EntityIdentifier; import com.cedarpolicy.value.EntityTypeName; import com.cedarpolicy.value.EntityUID; @@ -31,6 +32,7 @@ import com.cedarpolicy.value.PrimString; import com.cedarpolicy.value.Unknown; import com.cedarpolicy.value.Value; +import com.cedarpolicy.value.functions.Offset; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; @@ -116,6 +118,10 @@ public Value deserialize(JsonParser parser, DeserializationContext context) thro throw new InvalidValueDeserializationException(parser, "Not textual node: " + fn.toString(), node.asToken(), Map.class); } + // Handle offset function first since it uses "args" instead of "arg" + if (fn.textValue().equals("offset")) { + return deserializeOffset(val, mapper, parser, node); + } JsonNode arg = val.get("arg"); if (!arg.isTextual()) { throw new InvalidValueDeserializationException(parser, @@ -129,6 +135,8 @@ public Value deserialize(JsonParser parser, DeserializationContext context) thro return new Unknown(arg.textValue()); } else if (fn.textValue().equals("datetime")) { return new DateTime(arg.textValue()); + } else if (fn.textValue().equals("duration")) { + return new Duration(arg.textValue()); } else { throw new InvalidValueDeserializationException(parser, "Invalid function type: " + fn.toString(), node.asToken(), Map.class); @@ -153,4 +161,38 @@ public Value deserialize(JsonParser parser, DeserializationContext context) thro throw new DeserializationRecursionDepthException("Stack overflow while deserializing value. " + e.toString()); } } + + private Offset deserializeOffset(JsonNode val, ObjectMapper mapper, JsonParser parser, JsonNode node) throws IOException { + + JsonNode args = val.get("args"); + if (args == null || !args.isArray() || args.size() != 2) { + String message = args == null ? "Offset missing 'args' field" + : !args.isArray() ? "Offset 'args' must be an array" + : "Offset requires exactly two arguments but got: " + args.size(); + throw new InvalidValueDeserializationException(parser, message, node.asToken(), Offset.class); + } + + try { + Value dateTimeValue = mapper.treeToValue(args.get(0), Value.class); + Value durationValue = mapper.treeToValue(args.get(1), Value.class); + + if (!(dateTimeValue instanceof DateTime)) { + throw new InvalidValueDeserializationException(parser, + "Offset first argument must be DateTime but got: " + dateTimeValue.getClass().getSimpleName(), + node.asToken(), Offset.class); + } + + if (!(durationValue instanceof Duration)) { + throw new InvalidValueDeserializationException(parser, + "Offset second argument must be Duration but got: " + durationValue.getClass().getSimpleName(), + node.asToken(), Offset.class); + } + + return new Offset((DateTime) dateTimeValue, (Duration) durationValue); + + } catch (IOException e) { + throw new InvalidValueDeserializationException(parser, + "Failed to deserialize Offset arguments: " + e.getMessage(), node.asToken(), Offset.class); + } + } } diff --git a/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueSerializer.java b/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueSerializer.java index 301c5a0..1eb3273 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueSerializer.java +++ b/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueSerializer.java @@ -21,6 +21,7 @@ import com.cedarpolicy.value.CedarMap; import com.cedarpolicy.value.DateTime; import com.cedarpolicy.value.Decimal; +import com.cedarpolicy.value.Duration; import com.cedarpolicy.value.EntityUID; import com.cedarpolicy.value.IpAddress; import com.cedarpolicy.value.PrimBool; @@ -28,6 +29,7 @@ import com.cedarpolicy.value.PrimString; import com.cedarpolicy.value.Unknown; import com.cedarpolicy.value.Value; +import com.cedarpolicy.value.functions.Offset; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; @@ -113,6 +115,30 @@ public void serialize( jsonGenerator.writeString(value.toString()); jsonGenerator.writeEndObject(); jsonGenerator.writeEndObject(); + } else if (value instanceof Duration) { + jsonGenerator.writeStartObject(); + jsonGenerator.writeFieldName(EXTENSION_ESCAPE_SEQ); + jsonGenerator.writeStartObject(); + jsonGenerator.writeFieldName("fn"); + jsonGenerator.writeString("duration"); + jsonGenerator.writeFieldName("arg"); + jsonGenerator.writeString(value.toString()); + jsonGenerator.writeEndObject(); + jsonGenerator.writeEndObject(); + } else if (value instanceof Offset) { + Offset offsetValue = (Offset) value; + jsonGenerator.writeStartObject(); + jsonGenerator.writeFieldName(EXTENSION_ESCAPE_SEQ); + jsonGenerator.writeStartObject(); + jsonGenerator.writeFieldName("fn"); + jsonGenerator.writeString("offset"); + jsonGenerator.writeFieldName("args"); + CedarList args = new CedarList(); + args.add(offsetValue.getDateTime()); + args.add(offsetValue.getOffsetDuration()); + jsonGenerator.writeObject(args); + jsonGenerator.writeEndObject(); + jsonGenerator.writeEndObject(); } else { // It is recommended that you extend the Value classes in // main.java.com.cedarpolicy.model.value or that you convert your class to a CedarMap From e4cfdc6e39074a2705e0c8f56344d97411b24a78 Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Thu, 21 Aug 2025 12:51:21 -0400 Subject: [PATCH 04/13] adds lombok dependencies Signed-off-by: Mudit Chaudhary --- CedarJava/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CedarJava/build.gradle b/CedarJava/build.gradle index ab757bb..44d81a5 100644 --- a/CedarJava/build.gradle +++ b/CedarJava/build.gradle @@ -83,6 +83,8 @@ dependencies { implementation 'com.fizzed:jne:4.3.0' implementation 'com.google.guava:guava:33.4.0-jre' compileOnly 'com.github.spotbugs:spotbugs-annotations:4.8.6' + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' testImplementation 'net.jqwik:jqwik:1.9.2' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.4' testImplementation 'org.skyscreamer:jsonassert:2.0-rc1' From 7479d8e9f590b5961eea6da16ba0198194f20289 Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Thu, 21 Aug 2025 12:54:02 -0400 Subject: [PATCH 05/13] adds Duration and Offset integration tests Signed-off-by: Mudit Chaudhary --- .../com/cedarpolicy/pbt/IntegrationTests.java | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/CedarJava/src/test/java/com/cedarpolicy/pbt/IntegrationTests.java b/CedarJava/src/test/java/com/cedarpolicy/pbt/IntegrationTests.java index 7cc3497..04dd86b 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/pbt/IntegrationTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/pbt/IntegrationTests.java @@ -32,11 +32,13 @@ import com.cedarpolicy.model.policy.TemplateLink; import com.cedarpolicy.value.DateTime; import com.cedarpolicy.value.Decimal; +import com.cedarpolicy.value.Duration; import com.cedarpolicy.value.EntityUID; import com.cedarpolicy.value.EntityTypeName; import com.cedarpolicy.value.IpAddress; import com.cedarpolicy.value.PrimString; import com.cedarpolicy.value.Value; +import com.cedarpolicy.value.functions.Offset; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -558,6 +560,108 @@ public void testDateTimeExtensionWithDifferentTimezones() { assertAllowed(request, policySet, entities); } + /** Test Duration extension. */ + @Test + public void testDurationExtension() { + Set entities = new HashSet<>(); + String principalId = "alice"; + Map principalAttributes = new HashMap<>(); + principalAttributes.put("sessionDuration", new Duration("2h30m")); + Set principalParents = new HashSet<>(); + Entity principal = new Entity(new EntityUID(principalType, principalId), principalAttributes, principalParents); + entities.add(principal); + + String actionId = "view"; + Map actionAttributes = new HashMap<>(); + Set actionParents = new HashSet<>(); + Entity action = new Entity(new EntityUID(actionType, actionId), actionAttributes, actionParents); + entities.add(action); + + String resourceId = "photo.jpg"; + Map resourceAttributes = new HashMap<>(); + Set resourceParents = new HashSet<>(); + var resource = new Entity(new EntityUID(resourceType, resourceId), resourceAttributes, resourceParents); + entities.add(resource); + + String p = + "permit(\n" + + "principal==" + + principal.getEUID().toString() + + ",\n" + + "action==" + + action.getEUID().toString() + + ",\n" + + "resource==" + + resource.getEUID().toString() + + "\n" + + ")\n" + + " when {\n" + + "principal.sessionDuration > duration(\"1h\")\n" + + "};"; + final String policyId = "ID0"; + Policy policy = new Policy(p, policyId); + Set policies = new HashSet<>(); + policies.add(policy); + PolicySet policySet = new PolicySet(policies); + Map currentContext = new HashMap<>(); + AuthorizationRequest request = + new AuthorizationRequest( + principal, action, resource, currentContext); + assertAllowed(request, policySet, entities); + } + + /** Test Offset extension. */ + @Test + public void testOffsetExtension() { + Set entities = new HashSet<>(); + String principalId = "alice"; + Map principalAttributes = new HashMap<>(); + DateTime baseDateTime = new DateTime("2023-12-25T10:30:45Z"); + Duration offsetDuration = new Duration("2h"); + principalAttributes.put("appointmentTime", new Offset(baseDateTime, offsetDuration)); + Set principalParents = new HashSet<>(); + Entity principal = new Entity(new EntityUID(principalType, principalId), principalAttributes, principalParents); + entities.add(principal); + + String actionId = "view"; + Map actionAttributes = new HashMap<>(); + Set actionParents = new HashSet<>(); + Entity action = new Entity(new EntityUID(actionType, actionId), actionAttributes, actionParents); + entities.add(action); + + String resourceId = "photo.jpg"; + Map resourceAttributes = new HashMap<>(); + Set resourceParents = new HashSet<>(); + var resource = new Entity(new EntityUID(resourceType, resourceId), resourceAttributes, resourceParents); + entities.add(resource); + + String p = + "permit(\n" + + "principal==" + + principal.getEUID().toString() + + ",\n" + + "action==" + + action.getEUID().toString() + + ",\n" + + "resource==" + + resource.getEUID().toString() + + "\n" + + ")\n" + + " when {\n" + + "principal.appointmentTime == datetime(\"2023-12-25T10:30:45Z\").offset(duration(\"2h\"))\n" + + "};"; + final String policyId = "ID0"; + Policy policy = new Policy(p, policyId); + Set policies = new HashSet<>(); + policies.add(policy); + PolicySet policySet = new PolicySet(policies); + Map currentContext = new HashMap<>(); + AuthorizationRequest request = + new AuthorizationRequest( + principal, action, resource, currentContext); + assertAllowed(request, policySet, entities); + } + /** Test Decimal extension. */ @Test public void testDecimalExtension() { From 335d7b0a6e28c9e773d0df49a78f331fe94ada1f Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Thu, 21 Aug 2025 12:54:20 -0400 Subject: [PATCH 06/13] adds support for negative decimals Signed-off-by: Mudit Chaudhary --- CedarJava/src/main/java/com/cedarpolicy/value/Decimal.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/Decimal.java b/CedarJava/src/main/java/com/cedarpolicy/value/Decimal.java index d8e2550..545118b 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/Decimal.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/Decimal.java @@ -29,7 +29,7 @@ public class Decimal extends Value { private static class DecimalValidator { - private static final Pattern DECIMAL_PATTERN = Pattern.compile("^([0-9])*(\\.)([0-9]{0,4})$"); + private static final Pattern DECIMAL_PATTERN = Pattern.compile("^[-]?([0-9])*(\\.)([0-9]{0,4})$"); public static boolean validDecimal(String d) { if (d == null || d.isEmpty()) { From 4e4c55eae4910fee6f269cfc72bb6cd09548eabc Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Thu, 21 Aug 2025 12:54:36 -0400 Subject: [PATCH 07/13] adds support for CIDR suffix Signed-off-by: Mudit Chaudhary --- .../main/java/com/cedarpolicy/value/IpAddress.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java b/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java index dc537b1..f511fa6 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java @@ -34,14 +34,15 @@ private static class IpAddressValidator { private static final Pattern IPV4_PATTERN = Pattern.compile( "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.)" - + "{3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"); + + "{3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)" + + "(?:/(?:[0-9]|[12][0-9]|3[0-2]))?$"); public static boolean validIPv4(String ip) { if (ip == null || ip.isEmpty()) { return false; } ip = ip.trim(); - if ((ip.length() < 6) || (ip.length() > 15)) { + if ((ip.length() < 6) || (ip.length() > 18)) { return false; } try { @@ -54,21 +55,22 @@ public static boolean validIPv4(String ip) { private static final Pattern IPV6_PATTERN = Pattern.compile( - "^(([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|" + "^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|" + "(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|:))|" + "(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:))|" + "(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|:))|" + "(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|:))|" + "(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|:))|" + "(([0-9A-Fa-f]{1,4}:)(((:[0-9A-Fa-f]{1,4}){1,6})|:))|" - + "((:)(((:[0-9A-Fa-f]{1,4}){1,7})|:))|$"); + + "((:)(((:[0-9A-Fa-f]{1,4}){1,7})|:)))" + + "(?:/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$"); public static boolean validIPv6(String ip) { if (ip == null || ip.isEmpty()) { return false; } ip = ip.trim(); - if ((ip.length() < 3) || (ip.length() > 39)) { + if ((ip.length() < 2) || (ip.length() > 43)) { return false; } try { From 4042f3a7d4c0e0ae2b09289218c59463849a2a3e Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Thu, 21 Aug 2025 12:54:58 -0400 Subject: [PATCH 08/13] improves error logging for integration corpus tests Signed-off-by: Mudit Chaudhary --- .../test/java/com/cedarpolicy/SharedIntegrationTests.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java b/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java index 68cb7cf..ea2265d 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java @@ -240,9 +240,12 @@ public List integrationTestsFromJson() throws InternalExceptio } catch (final IOException e) { // inside the forEach we can't throw checked exceptions, but we // can throw this unchecked exception - throw new UncheckedIOException(e); + throw new UncheckedIOException("Error processing file: " + path.toAbsolutePath().toString(), e); } catch (final InternalException e) { - throw new RuntimeException(e); + throw new RuntimeException("Internal exception processing file: " + path.toAbsolutePath().toString(), e); + } catch (final RuntimeException e) { + // Catch any other runtime exceptions (including those from Jackson) and add file context + throw new RuntimeException("Runtime exception processing file: " + path.toAbsolutePath().toString(), e); } }); } From 2010fbad642e89376cb77e54f6d8351aeec301f0 Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Thu, 21 Aug 2025 13:09:12 -0400 Subject: [PATCH 09/13] fixes nits Signed-off-by: Mudit Chaudhary --- .../java/com/cedarpolicy/value/Duration.java | 53 ++++++++++++------- .../cedarpolicy/value/functions/Offset.java | 45 +++++++++++++++- 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java b/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java index d68e162..00c1ead 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java @@ -22,18 +22,43 @@ import java.util.regex.Pattern; /** - * Represents a Cedar duration extension value. Duration values are encoded as strings in the - * following format: + * Represents a Cedar Duration extension. * - * Duration strings are of the form "1d2h3m4s5ms" where: - d: days - h: hours - m: minutes - s: - * seconds - ms: milliseconds + * Duration values represent time spans and are encoded as strings combining multiple time units. + * They are useful for time-based policy decisions such as session timeouts, expiration periods, + * or time window calculations. * - * Duration strings are required to be ordered from largest unit to smallest unit, and contain one - * quantity per unit. Units with zero quantity may be omitted. Examples of valid duration strings: - * "1h", "-10h", "5d3ms", "3h5m", "2h5ms", "1d2ms". + * Format: Duration strings follow the pattern {@code "XdYhZmAsLms"} where: + *
    + *
  • {@code d} - days
  • + *
  • {@code h} - hours
  • + *
  • {@code m} - minutes (not followed by 's')
  • + *
  • {@code s} - seconds
  • + *
  • {@code ms} - milliseconds
  • + *
+ * + * Rules: + *
    + *
  • Units must appear in order from largest to smallest (days → hours → minutes → seconds → milliseconds)
  • + *
  • Each unit can appear at most once
  • + *
  • Units with zero quantity may be omitted
  • + *
  • Each quantity must be a non-negative integer
  • + *
  • The entire duration can be negative by prefixing with a minus sign
  • + *
+ * + * Examples: + *
    + *
  • {@code "1h"} - one hour
  • + *
  • {@code "-10h"} - negative ten hours
  • + *
  • {@code "5d3ms"} - five days and three milliseconds
  • + *
  • {@code "3h5m"} - three hours and five minutes
  • + *
  • {@code "1d2h3m4s5ms"} - one day, two hours, three minutes, four seconds, and five milliseconds
  • + *
+ * + * Duration objects are immutable and thread-safe. Two Duration instances are considered equal + * if they represent the same time span, regardless of their string representation format. + * For example, {@code "60s"} and {@code "1m"} represent the same duration. * - * The quantity part must be a natural number (positive integer), but the entire duration can be - * negative by prefixing the first component with a minus sign. */ public class Duration extends Value implements Comparable { @@ -57,7 +82,6 @@ private static class DurationValidator { * @return the parsed total milliseconds * @throws IllegalArgumentException if the format is invalid * @throws ArithmeticException if the value would cause overflow - * @throws NumberFormatException if the number format is invalid */ private static long parseToMilliseconds(String durationString) throws IllegalArgumentException, ArithmeticException { @@ -212,13 +236,4 @@ public int compareTo(Duration other) { return Long.compare(this.totalMilliseconds, other.totalMilliseconds); } - /** - * Returns the total duration in milliseconds. This is used internally for datetime offset - * operations. - * - * @return the total duration in milliseconds - */ - public long getTotalMilliseconds() { - return this.totalMilliseconds; - } } diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java b/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java index 646572f..2b5191d 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java @@ -22,20 +22,63 @@ import com.cedarpolicy.value.Duration; import com.cedarpolicy.value.Value; +/** + * Represents a Cedar datetime offset operation that combines a DateTime with a Duration. + * + * The Offset class represents the Cedar expression {@code datetime.offset(duration)}, which applies + * a duration offset to a datetime value. This is useful for temporal calculations in Cedar + * policies, such as scheduling, time windows, or deadline computations. + * + * For example: + *
    + *
  • {@code datetime("2023-01-01T12:00:00Z").offset(duration("2h"))} - adds 2 hours to the + * datetime
  • + *
  • {@code datetime("2023-01-01T12:00:00Z").offset(duration("-30m"))} - subtracts 30 minutes from + * the datetime
  • + *
+ * + * @see DateTime + * @see Duration + * @see Value + */ @Getter public class Offset extends Value { - private final Duration offsetDuration; private final DateTime dateTime; + /** + * Constructs an Offset with the specified datetime and duration. + * + *

+ * This represents the Cedar expression {@code dateTime.offset(offsetDuration)}. The offset duration + * can be positive (future) or negative (past). + * + * @param dateTime the base datetime to offset from, must not be null + * @param offsetDuration the duration to offset by, must not be null + * @throws NullPointerException if dateTime or offsetDuration is null + * @throws IllegalArgumentException if the datetime or duration values are invalid + */ @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") public Offset(DateTime dateTime, Duration offsetDuration) throws NullPointerException, IllegalArgumentException { + if (dateTime == null) { + throw new NullPointerException("dateTime cannot be null"); + } + if (offsetDuration == null) { + throw new NullPointerException("offsetDuration cannot be null"); + } this.dateTime = dateTime; this.offsetDuration = offsetDuration; } + /** Convert Offset to Cedar expr that can be used in a Cedar policy. */ @Override public String toCedarExpr() { return String.format("%s.offset(%s)", this.dateTime.toCedarExpr(), this.offsetDuration.toCedarExpr()); } + + /** As a string. */ + @Override + public String toString() { + return this.toCedarExpr(); + } } From cb747fc405a538f928911ba7678a0ab4061c0251 Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Thu, 21 Aug 2025 13:19:08 -0400 Subject: [PATCH 10/13] fixes nits Signed-off-by: Mudit Chaudhary --- .../main/java/com/cedarpolicy/value/functions/Offset.java | 5 +---- .../test/java/com/cedarpolicy/SharedIntegrationTests.java | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java b/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java index 2b5191d..43f84d6 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java @@ -39,7 +39,6 @@ * * @see DateTime * @see Duration - * @see Value */ @Getter public class Offset extends Value { @@ -49,9 +48,7 @@ public class Offset extends Value { /** * Constructs an Offset with the specified datetime and duration. * - *

- * This represents the Cedar expression {@code dateTime.offset(offsetDuration)}. The offset duration - * can be positive (future) or negative (past). + * This represents the Cedar expression {@code dateTime.offset(offsetDuration)}. * * @param dateTime the base datetime to offset from, must not be null * @param offsetDuration the duration to offset by, must not be null diff --git a/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java b/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java index ea2265d..261fd43 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java @@ -244,7 +244,6 @@ public List integrationTestsFromJson() throws InternalExceptio } catch (final InternalException e) { throw new RuntimeException("Internal exception processing file: " + path.toAbsolutePath().toString(), e); } catch (final RuntimeException e) { - // Catch any other runtime exceptions (including those from Jackson) and add file context throw new RuntimeException("Runtime exception processing file: " + path.toAbsolutePath().toString(), e); } }); From 96cdfcaf5fb40c37966fdd84bb95bd59812d54a4 Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Mon, 25 Aug 2025 09:48:20 -0400 Subject: [PATCH 11/13] fixes nits from feedback Signed-off-by: Mudit Chaudhary --- .../src/main/java/com/cedarpolicy/value/Decimal.java | 2 +- .../src/main/java/com/cedarpolicy/value/IpAddress.java | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/Decimal.java b/CedarJava/src/main/java/com/cedarpolicy/value/Decimal.java index 545118b..320027f 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/Decimal.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/Decimal.java @@ -29,7 +29,7 @@ public class Decimal extends Value { private static class DecimalValidator { - private static final Pattern DECIMAL_PATTERN = Pattern.compile("^[-]?([0-9])*(\\.)([0-9]{0,4})$"); + private static final Pattern DECIMAL_PATTERN = Pattern.compile("^-?([0-9])*(\\.)([0-9]{0,4})$"); public static boolean validDecimal(String d) { if (d == null || d.isEmpty()) { diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java b/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java index f511fa6..0afe4b0 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java @@ -31,6 +31,12 @@ public class IpAddress extends Value { private static class IpAddressValidator { + private static final int MIN_IPV4_LENGTH = 6; + private static final int MAX_IPV4_LENGTH = 18; + + private static final int MIN_IPV6_LENGTH = 2; + private static final int MAX_IPV6_LENGTH = 43; + private static final Pattern IPV4_PATTERN = Pattern.compile( "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.)" @@ -42,7 +48,7 @@ public static boolean validIPv4(String ip) { return false; } ip = ip.trim(); - if ((ip.length() < 6) || (ip.length() > 18)) { + if ((ip.length() < MIN_IPV4_LENGTH) || (ip.length() > MAX_IPV4_LENGTH)) { return false; } try { @@ -70,7 +76,7 @@ public static boolean validIPv6(String ip) { return false; } ip = ip.trim(); - if ((ip.length() < 2) || (ip.length() > 43)) { + if ((ip.length() < MIN_IPV6_LENGTH) || (ip.length() > MAX_IPV6_LENGTH)) { return false; } try { From ef77df4ae6e809fbbbc7d4cc26cf13e3c2d81fba Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Mon, 25 Aug 2025 14:22:00 -0400 Subject: [PATCH 12/13] refactors ValueDeserializer Signed-off-by: Mudit Chaudhary --- .../serializer/ValueDeserializer.java | 91 +++++++++++++------ 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java b/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java index 92c1216..d4df41d 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java +++ b/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java @@ -42,12 +42,22 @@ import java.util.Iterator; import java.util.Map; import java.util.Optional; +import java.util.Set; /** Deserialize Json to Value. This is mostly an implementation detail, but you may need to modify it if you extend the * `Value` class. */ public class ValueDeserializer extends JsonDeserializer { private static final String ENTITY_ESCAPE_SEQ = "__entity"; private static final String EXTENSION_ESCAPE_SEQ = "__extn"; + private static final String FN_OFFSET = "offset"; + private static final String FN_IP = "ip"; + private static final String FN_DECIMAL = "decimal"; + private static final String FN_UNKNOWN = "unknown"; + private static final String FN_DATETIME = "datetime"; + private static final String FN_DURATION = "duration"; + + private static final Set MULTI_ARG_FN = Set.of(FN_OFFSET); + private static final Set SINGLE_ARG_FN = Set.of(FN_IP, FN_DECIMAL, FN_UNKNOWN, FN_DATETIME, FN_DURATION); private enum EscapeType { ENTITY, @@ -118,25 +128,13 @@ public Value deserialize(JsonParser parser, DeserializationContext context) thro throw new InvalidValueDeserializationException(parser, "Not textual node: " + fn.toString(), node.asToken(), Map.class); } - // Handle offset function first since it uses "args" instead of "arg" - if (fn.textValue().equals("offset")) { - return deserializeOffset(val, mapper, parser, node); - } - JsonNode arg = val.get("arg"); - if (!arg.isTextual()) { - throw new InvalidValueDeserializationException(parser, - "Not textual node: " + arg.toString(), node.asToken(), Map.class); - } - if (fn.textValue().equals("ip")) { - return new IpAddress(arg.textValue()); - } else if (fn.textValue().equals("decimal")) { - return new Decimal(arg.textValue()); - } else if (fn.textValue().equals("unknown")) { - return new Unknown(arg.textValue()); - } else if (fn.textValue().equals("datetime")) { - return new DateTime(arg.textValue()); - } else if (fn.textValue().equals("duration")) { - return new Duration(arg.textValue()); + + String fnName = fn.textValue(); + + if (MULTI_ARG_FN.contains(fnName)) { + return deserializeMultiArgFunction(fnName, val, mapper, parser, node); + } else if (SINGLE_ARG_FN.contains(fnName)) { + return deserializeSingleArgFunction(fnName, val, parser, node); } else { throw new InvalidValueDeserializationException(parser, "Invalid function type: " + fn.toString(), node.asToken(), Map.class); @@ -162,14 +160,55 @@ public Value deserialize(JsonParser parser, DeserializationContext context) thro } } - private Offset deserializeOffset(JsonNode val, ObjectMapper mapper, JsonParser parser, JsonNode node) throws IOException { - + private Value deserializeMultiArgFunction(String fnName, JsonNode val, ObjectMapper mapper, JsonParser parser, + JsonNode node) throws IOException { JsonNode args = val.get("args"); - if (args == null || !args.isArray() || args.size() != 2) { - String message = args == null ? "Offset missing 'args' field" - : !args.isArray() ? "Offset 'args' must be an array" - : "Offset requires exactly two arguments but got: " + args.size(); - throw new InvalidValueDeserializationException(parser, message, node.asToken(), Offset.class); + if (args == null || !args.isArray()) { + throw new InvalidValueDeserializationException(parser, + "Expected args to be an array" + (args != null ? ", got: " + args.getNodeType() : ""), + node.asToken(), Map.class); + } + + switch (fnName) { + case FN_OFFSET: + return deserializeOffset(args, mapper, parser, node); + default: + throw new InvalidValueDeserializationException(parser, "Invalid function type: " + fnName, + node.asToken(), Map.class); + } + } + + private Value deserializeSingleArgFunction(String fnName, JsonNode val, JsonParser parser, JsonNode node) + throws IOException { + JsonNode arg = val.get("arg"); + if (arg == null || !arg.isTextual()) { + throw new InvalidValueDeserializationException(parser, "Not textual node: " + fnName, node.asToken(), + Map.class); + } + + String argValue = arg.textValue(); + switch (fnName) { + case FN_IP: + return new IpAddress(argValue); + case FN_DECIMAL: + return new Decimal(argValue); + case FN_UNKNOWN: + return new Unknown(argValue); + case FN_DATETIME: + return new DateTime(argValue); + case FN_DURATION: + return new Duration(argValue); + default: + throw new InvalidValueDeserializationException(parser, + "Invalid function type: " + fnName, node.asToken(), Map.class); + } + } + + private Offset deserializeOffset(JsonNode args, ObjectMapper mapper, JsonParser parser, JsonNode node) + throws IOException { + if (args.size() != 2) { + throw new InvalidValueDeserializationException(parser, + "Offset requires exactly two arguments but got: " + args.size(), node.asToken(), Offset.class); } try { From 70ac66f0a599bddc3086dbb8f4ca4193b321761f Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Mon, 25 Aug 2025 16:58:45 -0400 Subject: [PATCH 13/13] updates Duration to handle edge cases; adds more comprehensive test cases Signed-off-by: Mudit Chaudhary --- .../java/com/cedarpolicy/value/Duration.java | 22 ++--- .../java/com/cedarpolicy/DurationTests.java | 87 +++++++++++++++---- 2 files changed, 81 insertions(+), 28 deletions(-) diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java b/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java index 00c1ead..ce35d08 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java @@ -20,6 +20,7 @@ import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; +import lombok.Getter; /** * Represents a Cedar Duration extension. @@ -89,14 +90,13 @@ private static long parseToMilliseconds(String durationString) throw new IllegalArgumentException("Duration string cannot be null or empty"); } - Matcher matcher = DURATION_PATTERN.matcher(durationString.trim()); + Matcher matcher = DURATION_PATTERN.matcher(durationString); if (!matcher.matches()) { throw new IllegalArgumentException("Invalid duration format"); } // Extract the optional negative sign (group 1) - String signStr = matcher.group(1); - boolean isNegative = "-".equals(signStr); + String sign = matcher.group(1); long totalMs = 0; boolean hasAnyComponent = false; @@ -104,7 +104,7 @@ private static long parseToMilliseconds(String durationString) // Extract days (group 2) String daysStr = matcher.group(2); if (daysStr != null && !daysStr.isEmpty()) { - long days = Long.parseLong(daysStr); + long days = Long.parseLong(sign + daysStr); totalMs = Math.addExact(totalMs, Math.multiplyExact(days, DAYS_TO_MS)); hasAnyComponent = true; } @@ -112,7 +112,7 @@ private static long parseToMilliseconds(String durationString) // Extract hours (group 3) String hoursStr = matcher.group(3); if (hoursStr != null && !hoursStr.isEmpty()) { - long hours = Long.parseLong(hoursStr); + long hours = Long.parseLong(sign + hoursStr); totalMs = Math.addExact(totalMs, Math.multiplyExact(hours, HOURS_TO_MS)); hasAnyComponent = true; } @@ -120,7 +120,7 @@ private static long parseToMilliseconds(String durationString) // Extract minutes (group 4) String minutesStr = matcher.group(4); if (minutesStr != null && !minutesStr.isEmpty()) { - long minutes = Long.parseLong(minutesStr); + long minutes = Long.parseLong(sign + minutesStr); totalMs = Math.addExact(totalMs, Math.multiplyExact(minutes, MINUTES_TO_MS)); hasAnyComponent = true; } @@ -128,7 +128,7 @@ private static long parseToMilliseconds(String durationString) // Extract seconds (group 5) String secondsStr = matcher.group(5); if (secondsStr != null && !secondsStr.isEmpty()) { - long seconds = Long.parseLong(secondsStr); + long seconds = Long.parseLong(sign + secondsStr); totalMs = Math.addExact(totalMs, Math.multiplyExact(seconds, SECONDS_TO_MS)); hasAnyComponent = true; } @@ -136,7 +136,7 @@ private static long parseToMilliseconds(String durationString) // Extract milliseconds (group 6) String millisecondsStr = matcher.group(6); if (millisecondsStr != null && !millisecondsStr.isEmpty()) { - long milliseconds = Long.parseLong(millisecondsStr); + long milliseconds = Long.parseLong(sign + millisecondsStr); totalMs = Math.addExact(totalMs, Math.multiplyExact(milliseconds, MILLISECONDS_TO_MS)); hasAnyComponent = true; } @@ -146,11 +146,6 @@ private static long parseToMilliseconds(String durationString) throw new IllegalArgumentException("Invalid duration format"); } - // Apply negative sign to the total if present - if (isNegative) { - totalMs = Math.negateExact(totalMs); - } - return totalMs; } } @@ -159,6 +154,7 @@ private static long parseToMilliseconds(String durationString) private final String durationString; /** Parsed duration as total milliseconds for semantic comparison. */ + @Getter private final long totalMilliseconds; /** diff --git a/CedarJava/src/test/java/com/cedarpolicy/DurationTests.java b/CedarJava/src/test/java/com/cedarpolicy/DurationTests.java index 9892806..d6d651b 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/DurationTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/DurationTests.java @@ -27,20 +27,47 @@ public class DurationTests { @Test public void testValidDurations() { - // Test basic valid formats - assertDoesNotThrow(() -> new Duration("1d2h3m4s5ms")); - assertDoesNotThrow(() -> new Duration("2h5ms")); - assertDoesNotThrow(() -> new Duration("2h")); - assertDoesNotThrow(() -> new Duration("1d2ms")); - assertDoesNotThrow(() -> new Duration("3h5m")); - assertDoesNotThrow(() -> new Duration("-10h")); - assertDoesNotThrow(() -> new Duration("1h")); - assertDoesNotThrow(() -> new Duration("5d3ms")); - - // Test edge cases - assertDoesNotThrow(() -> new Duration("0d")); - assertDoesNotThrow(() -> new Duration("1ms")); - assertDoesNotThrow(() -> new Duration("999s")); + // Test zero values + assertEquals(0, new Duration("0ms").getTotalMilliseconds()); + assertEquals(0, new Duration("0d0s").getTotalMilliseconds()); + assertEquals(0, new Duration("0d0h0m0s0ms").getTotalMilliseconds()); + + // Test single unit calculations + assertEquals(1, new Duration("1ms").getTotalMilliseconds()); + assertEquals(1000, new Duration("1s").getTotalMilliseconds()); + assertEquals(60000, new Duration("1m").getTotalMilliseconds()); + assertEquals(3600000, new Duration("1h").getTotalMilliseconds()); + assertEquals(86400000, new Duration("1d").getTotalMilliseconds()); + + // Test compound calculations + assertEquals(12340, new Duration("12s340ms").getTotalMilliseconds()); + assertEquals(1234, new Duration("1s234ms").getTotalMilliseconds()); + + // Test negative values + assertEquals(-1, new Duration("-1ms").getTotalMilliseconds()); + assertEquals(-1000, new Duration("-1s").getTotalMilliseconds()); + assertEquals(-4200, new Duration("-4s200ms").getTotalMilliseconds()); + assertEquals(-9876, new Duration("-9s876ms").getTotalMilliseconds()); + + // Test large values + assertEquals(9223372036854L, new Duration("106751d23h47m16s854ms").getTotalMilliseconds()); + assertEquals(-9223372036854L, new Duration("-106751d23h47m16s854ms").getTotalMilliseconds()); + + // Test boundary values (Long.MIN_VALUE and Long.MAX_VALUE) + assertEquals(-9223372036854775808L, new Duration("-9223372036854775808ms").getTotalMilliseconds()); + assertEquals(9223372036854775807L, new Duration("9223372036854775807ms").getTotalMilliseconds()); + + // Test complex compound durations + assertEquals(93784005, new Duration("1d2h3m4s5ms").getTotalMilliseconds()); + assertEquals(216000000, new Duration("2d12h").getTotalMilliseconds()); + assertEquals(210000, new Duration("3m30s").getTotalMilliseconds()); + assertEquals(5445000, new Duration("1h30m45s").getTotalMilliseconds()); + assertEquals(192000000, new Duration("2d5h20m").getTotalMilliseconds()); + assertEquals(-129600000, new Duration("-1d12h").getTotalMilliseconds()); + assertEquals(-13500000, new Duration("-3h45m").getTotalMilliseconds()); + assertEquals(86400001, new Duration("1d1ms").getTotalMilliseconds()); + assertEquals(3599999, new Duration("59m59s999ms").getTotalMilliseconds()); + assertEquals(86399999, new Duration("23h59m59s999ms").getTotalMilliseconds()); } @Test @@ -78,6 +105,36 @@ public void testInvalidDurations() { // Test signs on individual components (should be invalid) assertThrows(IllegalArgumentException.class, () -> new Duration("+2h")); assertThrows(IllegalArgumentException.class, () -> new Duration("2d-3m")); + + // Test missing quantity for different units + assertThrows(IllegalArgumentException.class, () -> new Duration("d")); + + // Test trailing numbers + assertThrows(IllegalArgumentException.class, () -> new Duration("1d2h3m4s5ms6")); + + // Test invalid units in different positions + assertThrows(IllegalArgumentException.class, () -> new Duration("1x2m3s")); + + // Test non-integral amounts + assertThrows(IllegalArgumentException.class, () -> new Duration("1.23s")); + + // Test different wrong orders + assertThrows(IllegalArgumentException.class, () -> new Duration("1s1d")); + + // Test different repeated units + assertThrows(IllegalArgumentException.class, () -> new Duration("1s1s")); + + // Test leading and trailing spaces (should be handled by trim, but test to be sure) + assertThrows(IllegalArgumentException.class, () -> new Duration("1d2h3m4s5ms ")); + assertThrows(IllegalArgumentException.class, () -> new Duration(" 1d2h3m4s5ms")); + + // Test overflow cases + assertThrows(IllegalArgumentException.class, () -> new Duration("1d9223372036854775807ms")); + assertThrows(IllegalArgumentException.class, () -> new Duration("1d92233720368547758071ms")); + assertThrows(IllegalArgumentException.class, () -> new Duration("9223372036854776s1ms")); + assertThrows(IllegalArgumentException.class, () -> new Duration("-12142442932071h")); + assertThrows(IllegalArgumentException.class, () -> new Duration("-9223372036854775809ms")); + assertThrows(IllegalArgumentException.class, () -> new Duration("9223372036854775808ms")); } @Test @@ -158,7 +215,7 @@ public void testInvalidJsonDeserialization() { @Test public void testJsonRoundTrip() throws IOException { - String[] testDurations = {"1d", "2h30m", "5m15s", "1d2h3m4s5ms", "30s", "999ms", "-2h", "0d"}; + String[] testDurations = {"1d", "2h30m", "5m15s", "1d2h3m4s5ms", "30s", "999ms", "-2h", "0d", "-9223372036854775808ms"}; for (String durationString : testDurations) { Duration original = new Duration(durationString);