Skip to content

Commit cf48db3

Browse files
Simplify DurationValidator - use programmatic approach without regex
Co-authored-by: thomasturrell <1552612+thomasturrell@users.noreply.github.com>
1 parent 335150c commit cf48db3

1 file changed

Lines changed: 52 additions & 155 deletions

File tree

xapi-model/src/main/java/dev/learning/xapi/model/validation/internal/validators/DurationValidator.java

Lines changed: 52 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -7,208 +7,105 @@
77
import dev.learning.xapi.model.validation.constraints.ValidDuration;
88
import jakarta.validation.ConstraintValidator;
99
import jakarta.validation.ConstraintValidatorContext;
10-
import java.util.regex.Pattern;
1110

1211
/**
1312
* Validates ISO 8601:2004 duration format strings.
1413
*
15-
* <p>This validator uses a programmatic approach to avoid complex regex patterns that could be
16-
* flagged as code smells. The validation is broken down into logical parts:
17-
*
18-
* <ul>
19-
* <li>Week format: P[n]W (e.g., P1W, P52W)
20-
* <li>Date/time format: P[n]Y[n]M[n]DT[n]H[n]M[n]S or P[n]Y[n]M[n]D
21-
* </ul>
22-
*
23-
* <p>Uses possessive quantifiers (++) to prevent ReDoS attacks.
14+
* <p>Supports formats: P[n]W, P[n]Y[n]M[n]DT[n]H[n]M[n]S and variations.
2415
*
2516
* @author Berry Cloud
2617
*/
2718
public class DurationValidator implements ConstraintValidator<ValidDuration, String> {
2819

29-
// Simple patterns using possessive quantifiers to prevent ReDoS
30-
private static final Pattern WEEK_PATTERN =
31-
Pattern.compile("^P\\d++W$", Pattern.CASE_INSENSITIVE);
32-
private static final Pattern DIGITS_PATTERN = Pattern.compile("^\\d++$");
33-
private static final Pattern DECIMAL_PATTERN = Pattern.compile("^\\d++\\.\\d++$");
34-
3520
@Override
3621
public boolean isValid(String value, ConstraintValidatorContext context) {
3722
if (value == null) {
3823
return true;
3924
}
4025

41-
// Check for week format first (P[n]W)
42-
if (WEEK_PATTERN.matcher(value).matches()) {
43-
return true;
44-
}
26+
String upper = value.toUpperCase();
4527

4628
// Must start with P
47-
if (!value.toUpperCase().startsWith("P")) {
29+
if (!upper.startsWith("P") || upper.length() < 2) {
4830
return false;
4931
}
5032

51-
// Remove P prefix for processing
52-
String remaining = value.substring(1);
33+
String rest = upper.substring(1);
5334

54-
// Empty after P is invalid
55-
if (remaining.isEmpty()) {
56-
return false;
35+
// Week format: P[n]W
36+
if (rest.endsWith("W") && rest.length() > 1) {
37+
return isDigits(rest.substring(0, rest.length() - 1));
5738
}
5839

59-
// Check if there's a time component (T separator)
60-
int timeIndex = remaining.toUpperCase().indexOf('T');
40+
// Split by T to get date and time parts
41+
int tpos = rest.indexOf('T');
42+
String datePart = tpos >= 0 ? rest.substring(0, tpos) : rest;
43+
String timePart = tpos >= 0 ? rest.substring(tpos + 1) : "";
6144

62-
String datePart;
63-
String timePart;
64-
65-
if (timeIndex >= 0) {
66-
datePart = remaining.substring(0, timeIndex);
67-
timePart = remaining.substring(timeIndex + 1);
68-
69-
// T must be followed by time components
70-
if (timePart.isEmpty()) {
71-
return false;
72-
}
73-
} else {
74-
datePart = remaining;
75-
timePart = null;
45+
// Must have at least one component
46+
if (datePart.isEmpty() && timePart.isEmpty()) {
47+
return false;
7648
}
7749

78-
// Validate date part (Y, M, D components)
79-
if (!datePart.isEmpty() && !isValidDatePart(datePart.toUpperCase())) {
50+
// Validate date part (Y, M, D in order)
51+
if (!datePart.isEmpty() && !isValidPart(datePart, "YMD", false)) {
8052
return false;
8153
}
8254

83-
// Validate time part (H, M, S components)
84-
if (timePart != null && !isValidTimePart(timePart.toUpperCase())) {
55+
// Validate time part (H, M, S in order, S can be decimal)
56+
if (!timePart.isEmpty() && !isValidPart(timePart, "HMS", true)) {
8557
return false;
8658
}
8759

88-
// Must have at least one component
89-
return !datePart.isEmpty() || timePart != null;
60+
return true;
9061
}
9162

92-
/**
93-
* Validates the date part of the duration (Y, M, D components).
94-
*
95-
* <p>Components must appear in order: Years, Months, Days.
96-
*/
97-
private boolean isValidDatePart(String datePart) {
98-
if (datePart.isEmpty()) {
99-
return true;
100-
}
101-
63+
private boolean isValidPart(String part, String designators, boolean allowDecimalLast) {
10264
int pos = 0;
103-
104-
// Check for Years
105-
int yearIndex = datePart.indexOf('Y');
106-
if (yearIndex >= 0) {
107-
if (yearIndex == 0) {
108-
return false; // No digits before Y
109-
}
110-
String digits = datePart.substring(pos, yearIndex);
111-
if (!isValidDigits(digits)) {
112-
return false;
113-
}
114-
pos = yearIndex + 1;
115-
}
116-
117-
// Check for Months
118-
int monthIndex = datePart.indexOf('M', pos);
119-
if (monthIndex >= 0) {
120-
if (monthIndex == pos) {
121-
return false; // No digits before M
122-
}
123-
String digits = datePart.substring(pos, monthIndex);
124-
if (!isValidDigits(digits)) {
125-
return false;
65+
boolean found = false;
66+
67+
for (int i = 0; i < designators.length(); i++) {
68+
char designator = designators.charAt(i);
69+
int idx = part.indexOf(designator, pos);
70+
if (idx > pos) {
71+
String digits = part.substring(pos, idx);
72+
boolean isLast = i == designators.length() - 1;
73+
if (!(isLast && allowDecimalLast ? isDigitsOrDecimal(digits) : isDigits(digits))) {
74+
return false;
75+
}
76+
pos = idx + 1;
77+
found = true;
78+
} else if (idx == pos) {
79+
return false; // Designator without preceding digits
12680
}
127-
pos = monthIndex + 1;
12881
}
12982

130-
// Check for Days
131-
int dayIndex = datePart.indexOf('D', pos);
132-
if (dayIndex >= 0) {
133-
if (dayIndex == pos) {
134-
return false; // No digits before D
135-
}
136-
String digits = datePart.substring(pos, dayIndex);
137-
if (!isValidDigits(digits)) {
138-
return false;
139-
}
140-
pos = dayIndex + 1;
141-
}
142-
143-
// Nothing should remain after processing
144-
return pos == datePart.length();
83+
return found && pos == part.length();
14584
}
14685

147-
/**
148-
* Validates the time part of the duration (H, M, S components).
149-
*
150-
* <p>Components must appear in order: Hours, Minutes, Seconds. Only seconds can have decimals.
151-
*/
152-
private boolean isValidTimePart(String timePart) {
153-
if (timePart.isEmpty()) {
154-
return false; // T must be followed by components
155-
}
156-
157-
int pos = 0;
158-
159-
// Check for Hours
160-
int hourIndex = timePart.indexOf('H');
161-
if (hourIndex >= 0) {
162-
if (hourIndex == 0) {
163-
return false; // No digits before H
164-
}
165-
String digits = timePart.substring(pos, hourIndex);
166-
if (!isValidDigits(digits)) {
167-
return false;
168-
}
169-
pos = hourIndex + 1;
170-
}
171-
172-
// Check for Minutes
173-
int minuteIndex = timePart.indexOf('M', pos);
174-
if (minuteIndex >= 0) {
175-
if (minuteIndex == pos) {
176-
return false; // No digits before M
177-
}
178-
String digits = timePart.substring(pos, minuteIndex);
179-
if (!isValidDigits(digits)) {
180-
return false;
181-
}
182-
pos = minuteIndex + 1;
86+
private boolean isDigits(String s) {
87+
if (s.isEmpty()) {
88+
return false;
18389
}
184-
185-
// Check for Seconds (can be decimal)
186-
int secondIndex = timePart.indexOf('S', pos);
187-
if (secondIndex >= 0) {
188-
if (secondIndex == pos) {
189-
return false; // No digits before S
190-
}
191-
String digits = timePart.substring(pos, secondIndex);
192-
if (!isValidDigitsOrDecimal(digits)) {
90+
for (int i = 0; i < s.length(); i++) {
91+
if (!Character.isDigit(s.charAt(i))) {
19392
return false;
19493
}
195-
pos = secondIndex + 1;
19694
}
197-
198-
// Nothing should remain after processing and at least one component must be present
199-
return pos == timePart.length() && timePart.length() > 0;
95+
return true;
20096
}
20197

202-
/** Checks if the string contains only digits. */
203-
private boolean isValidDigits(String value) {
204-
return !value.isEmpty() && DIGITS_PATTERN.matcher(value).matches();
205-
}
206-
207-
/** Checks if the string contains digits or a decimal number. */
208-
private boolean isValidDigitsOrDecimal(String value) {
209-
if (value.isEmpty()) {
98+
private boolean isDigitsOrDecimal(String s) {
99+
if (s.isEmpty()) {
210100
return false;
211101
}
212-
return DIGITS_PATTERN.matcher(value).matches() || DECIMAL_PATTERN.matcher(value).matches();
102+
int dotPos = s.indexOf('.');
103+
if (dotPos < 0) {
104+
return isDigits(s);
105+
}
106+
return dotPos > 0
107+
&& dotPos < s.length() - 1
108+
&& isDigits(s.substring(0, dotPos))
109+
&& isDigits(s.substring(dotPos + 1));
213110
}
214111
}

0 commit comments

Comments
 (0)