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' diff --git a/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java b/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java index 1172e48..d4df41d 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; @@ -40,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, @@ -116,19 +128,13 @@ public Value deserialize(JsonParser parser, DeserializationContext context) thro throw new InvalidValueDeserializationException(parser, "Not textual node: " + fn.toString(), node.asToken(), Map.class); } - 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()); + + 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); @@ -153,4 +159,79 @@ public Value deserialize(JsonParser parser, DeserializationContext context) thro throw new DeserializationRecursionDepthException("Stack overflow while deserializing value. " + e.toString()); } } + + 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()) { + 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 { + 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 diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/Decimal.java b/CedarJava/src/main/java/com/cedarpolicy/value/Decimal.java index d8e2550..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/Duration.java b/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java new file mode 100644 index 0000000..ce35d08 --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/value/Duration.java @@ -0,0 +1,235 @@ +/* + * 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; +import lombok.Getter; + +/** + * Represents a Cedar Duration extension. + * + * 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. + * + * 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. + * + */ +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 + */ + 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); + if (!matcher.matches()) { + throw new IllegalArgumentException("Invalid duration format"); + } + + // Extract the optional negative sign (group 1) + String sign = matcher.group(1); + + long totalMs = 0; + boolean hasAnyComponent = false; + + // Extract days (group 2) + String daysStr = matcher.group(2); + if (daysStr != null && !daysStr.isEmpty()) { + long days = Long.parseLong(sign + 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(sign + 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(sign + 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(sign + 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(sign + 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"); + } + + return totalMs; + } + } + + /** Duration as a string. */ + private final String durationString; + + /** Parsed duration as total milliseconds for semantic comparison. */ + @Getter + 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); + } + +} diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java b/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java index dc537b1..0afe4b0 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/IpAddress.java @@ -31,17 +31,24 @@ 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]?)\\.)" - + "{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() < MIN_IPV4_LENGTH) || (ip.length() > MAX_IPV4_LENGTH)) { return false; } try { @@ -54,21 +61,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() < MIN_IPV6_LENGTH) || (ip.length() > MAX_IPV6_LENGTH)) { return false; } try { 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..43f84d6 --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/value/functions/Offset.java @@ -0,0 +1,81 @@ +/* + * 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; + +/** + * 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 + */ +@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)}. + * + * @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(); + } +} 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..d6d651b --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/DurationTests.java @@ -0,0 +1,235 @@ +/* + * 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 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 + 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 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 + 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", "-9223372036854775808ms"}; + + 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()); + } + } +} 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()); + } + } +} diff --git a/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java b/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java index 68cb7cf..261fd43 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/SharedIntegrationTests.java @@ -240,9 +240,11 @@ 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) { + throw new RuntimeException("Runtime exception processing file: " + path.toAbsolutePath().toString(), e); } }); } 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() {