diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/Result.java b/xapi-model/src/main/java/dev/learning/xapi/model/Result.java index d6336624..6da6efc2 100644 --- a/xapi-model/src/main/java/dev/learning/xapi/model/Result.java +++ b/xapi-model/src/main/java/dev/learning/xapi/model/Result.java @@ -8,8 +8,8 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; import dev.learning.xapi.model.validation.constraints.HasScheme; import dev.learning.xapi.model.validation.constraints.VaildScore; +import dev.learning.xapi.model.validation.constraints.ValidDuration; import jakarta.validation.Valid; -import jakarta.validation.constraints.Pattern; import java.net.URI; import java.util.LinkedHashMap; import java.util.function.Consumer; @@ -41,14 +41,7 @@ public class Result { private String response; /** Period of time over which the Statement occurred. */ - // Java Duration does not store ISO 8601:2004 durations. - @Pattern( - regexp = - "^(P\\d+W)?$|^P(?!$)(\\d+Y)?(\\d+M)?" // NOSONAR - + "(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d*\\.?\\d+S)?)?$", // NOSONAR - flags = Pattern.Flag.CASE_INSENSITIVE, - message = "Must be a valid ISO 8601:2004 duration format.") - private String duration; + @ValidDuration private String duration; private LinkedHashMap<@HasScheme URI, Object> extensions; diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/validation/constraints/ValidDuration.java b/xapi-model/src/main/java/dev/learning/xapi/model/validation/constraints/ValidDuration.java new file mode 100644 index 00000000..f3b3a3fc --- /dev/null +++ b/xapi-model/src/main/java/dev/learning/xapi/model/validation/constraints/ValidDuration.java @@ -0,0 +1,64 @@ +/* + * Copyright 2016-2025 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.model.validation.constraints; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.CONSTRUCTOR; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE_USE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import dev.learning.xapi.model.validation.internal.validators.DurationValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * The annotated element must be a valid ISO 8601:2004 duration format. + * + *

Accepts formats like: + * + *

+ * + * @author Berry Cloud + * @see xAPI + * Result + */ +@Documented +@Constraint(validatedBy = {DurationValidator.class}) +@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}) +@Retention(RUNTIME) +public @interface ValidDuration { + + /** + * Error Message. + * + * @return the error message + */ + String message() default "Must be a valid ISO 8601:2004 duration format."; + + /** + * Groups. + * + * @return the validation groups + */ + Class[] groups() default {}; + + /** + * Payload. + * + * @return the payload + */ + Class[] payload() default {}; +} diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/validation/internal/validators/DurationValidator.java b/xapi-model/src/main/java/dev/learning/xapi/model/validation/internal/validators/DurationValidator.java new file mode 100644 index 00000000..81ce2cb5 --- /dev/null +++ b/xapi-model/src/main/java/dev/learning/xapi/model/validation/internal/validators/DurationValidator.java @@ -0,0 +1,71 @@ +/* + * Copyright 2016-2025 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.model.validation.internal.validators; + +import dev.learning.xapi.model.validation.constraints.ValidDuration; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Validates ISO 8601:2004 duration format strings. + * + *

Supports formats: P[n]W, P[n]Y[n]M[n]DT[n]H[n]M[n]S and variations. + * + * @author Berry Cloud + */ +public class DurationValidator implements ConstraintValidator { + + // Simple patterns - each validates a single component type + private static final Pattern WEEK = Pattern.compile("^\\d+W$", Pattern.CASE_INSENSITIVE); + private static final Pattern DATE = + Pattern.compile("^(\\d+Y)?(\\d+M)?(\\d+D)?$", Pattern.CASE_INSENSITIVE); + private static final Pattern TIME = + Pattern.compile("^(\\d+H)?(\\d+M)?((\\d+\\.\\d+|\\d+)S)?$", Pattern.CASE_INSENSITIVE); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + if (!value.toUpperCase().startsWith("P") || value.length() < 2) { + return false; + } + + String rest = value.substring(1); + + if (WEEK.matcher(rest).matches()) { + return true; + } + + int tpos = rest.toUpperCase().indexOf('T'); + String datePart = tpos >= 0 ? rest.substring(0, tpos) : rest; + String timePart = tpos >= 0 ? rest.substring(tpos + 1) : ""; + + if (datePart.isEmpty() && timePart.isEmpty()) { + return false; + } + + return isValidDatePart(datePart) && isValidTimePart(timePart); + } + + private boolean isValidDatePart(String datePart) { + if (datePart.isEmpty()) { + return true; + } + Matcher m = DATE.matcher(datePart); + return m.matches() && (m.group(1) != null || m.group(2) != null || m.group(3) != null); + } + + private boolean isValidTimePart(String timePart) { + if (timePart.isEmpty()) { + return true; + } + Matcher m = TIME.matcher(timePart); + return m.matches() && (m.group(1) != null || m.group(2) != null || m.group(3) != null); + } +} diff --git a/xapi-model/src/test/java/dev/learning/xapi/model/ResultTests.java b/xapi-model/src/test/java/dev/learning/xapi/model/ResultTests.java index e5f79c7c..fca6dcc3 100644 --- a/xapi-model/src/test/java/dev/learning/xapi/model/ResultTests.java +++ b/xapi-model/src/test/java/dev/learning/xapi/model/ResultTests.java @@ -5,13 +5,22 @@ package dev.learning.xapi.model; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; import java.io.IOException; +import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.util.ResourceUtils; /** @@ -25,6 +34,8 @@ class ResultTests { private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + @Test void whenDeserializingResultThenResultIsInstanceOfResult() throws Exception { @@ -139,4 +150,184 @@ void whenCallingToStringThenResultIsExpected() { "Result(score=Score(scaled=1.0, raw=1.0, min=0.0, max=5.0), success=true, completion=true, " + "response=test, duration=P1D, extensions=null)")); } + + // Duration Validation Tests + + @ParameterizedTest + @ValueSource( + strings = { + // Week format + "P1W", + "P52W", + "P104W", + // Day format + "P1D", + "P365D", + // Time format + "PT1H", + "PT30M", + "PT45S", + "PT1.5S", + "PT0.5S", + // Combined date format + "P1Y", + "P1M", + "P1Y2M", + "P1Y2M3D", + // Combined date and time format + "P1DT1H", + "P1DT1H30M", + "P1DT1H30M45S", + "P1Y2M3DT4H5M6S", + "P1Y2M3DT4H5M6.7S", + // Minimal valid formats + "PT0S", + "PT1S", + "P0D", + // Real-world examples + "PT8H", + "P90D", + "P2Y", + "PT15M30S" + }) + @DisplayName("When Duration Is Valid Then Validation Passes") + void whenDurationIsValidThenValidationPasses(String duration) { + + // Given Result With Valid Duration + final var result = Result.builder().duration(duration).build(); + + // When Validating Result + final Set> violations = validator.validate(result); + + // Then Validation Passes + assertThat(violations, empty()); + } + + @ParameterizedTest + @ValueSource( + strings = { + // Invalid formats + "", + "T1H", + "1D", + "PD", + "PT", + "P1", + "1Y2M", + // Invalid time without T + "P1H", + "P1M30S", + // Invalid mixing weeks with other units + "P1W1D", + "P1W1Y", + "P1WT1H", + // Invalid decimal placement + "P1.5D", + "P1.5Y", + "PT1.5H", + "PT1.5M", + // Missing P prefix + "1Y2M3D", + "T1H30M", + // Invalid order + "P1D1Y", + "PT1S1M", + "PT1M1H", + // Double separators + "P1Y2M3DTT1H", + "PP1D", + // Negative values + "P-1D", + "PT-1H" + }) + @DisplayName("When Duration Is Invalid Then Validation Fails") + void whenDurationIsInvalidThenValidationFails(String duration) { + + // Given Result With Invalid Duration + final var result = Result.builder().duration(duration).build(); + + // When Validating Result + final Set> violations = validator.validate(result); + + // Then Validation Fails + assertThat(violations, not(empty())); + assertThat(violations, hasSize(1)); + } + + @Test + @DisplayName("When Duration Is Null Then Validation Passes") + void whenDurationIsNullThenValidationPasses() { + + // Given Result With Null Duration + final var result = Result.builder().duration(null).build(); + + // When Validating Result + final Set> violations = validator.validate(result); + + // Then Validation Passes + assertThat(violations, empty()); + } + + @Test + @DisplayName("When Duration Has Many Digits Then Validation Completes Quickly") + void whenDurationHasManyDigitsThenValidationCompletesQuickly() { + + // Given Result With Long But Valid Duration + final var result = Result.builder().duration("P99999Y99999M99999DT99999H99999M99999S").build(); + + // When Validating Result + final long startTime = System.nanoTime(); + final Set> violations = validator.validate(result); + final long endTime = System.nanoTime(); + final long durationMs = (endTime - startTime) / 1_000_000; + + // Then Validation Passes And Completes In Reasonable Time + assertThat(violations, empty()); + // Validation should complete quickly - 500ms is generous for a regex match + assertThat("Validation should complete in less than 500ms", durationMs < 500); + } + + @Test + @DisplayName("When Duration Is Adversarial Input Then Validation Completes Quickly Without ReDoS") + void whenDurationIsAdversarialInputThenValidationCompletesQuicklyWithoutReDoS() { + + // Given Result With Adversarial Input That Could Cause ReDoS + // This input is designed to trigger exponential backtracking in vulnerable regex patterns + final var adversarialInput = "P" + "9".repeat(50) + "!"; + final var result = Result.builder().duration(adversarialInput).build(); + + // When Validating Result + final long startTime = System.nanoTime(); + final Set> violations = validator.validate(result); + final long endTime = System.nanoTime(); + final long durationMs = (endTime - startTime) / 1_000_000; + + // Then Validation Fails Quickly (not vulnerable to ReDoS) + assertThat(violations, not(empty())); + // Validation should complete quickly even with adversarial input - 500ms is generous + assertThat( + "Validation should complete in less than 500ms even with adversarial input", + durationMs < 500); + } + + @Test + @DisplayName("When Duration Has Multiple Optional Groups Then Validation Completes Quickly") + void whenDurationHasMultipleOptionalGroupsThenValidationCompletesQuickly() { + + // Given Result With Input That Tests Multiple Optional Groups + // This tests the pattern with input that doesn't match but exercises optional groups + final var testInput = "PYMDTHMS"; + final var result = Result.builder().duration(testInput).build(); + + // When Validating Result + final long startTime = System.nanoTime(); + final Set> violations = validator.validate(result); + final long endTime = System.nanoTime(); + final long durationMs = (endTime - startTime) / 1_000_000; + + // Then Validation Fails Quickly Without Excessive Backtracking + assertThat(violations, not(empty())); + // Validation should complete quickly - 500ms is generous for a regex match + assertThat("Validation should complete in less than 500ms", durationMs < 500); + } }