|
7 | 7 | import dev.learning.xapi.model.validation.constraints.ValidDuration; |
8 | 8 | import jakarta.validation.ConstraintValidator; |
9 | 9 | import jakarta.validation.ConstraintValidatorContext; |
10 | | -import java.util.regex.Pattern; |
11 | 10 |
|
12 | 11 | /** |
13 | 12 | * Validates ISO 8601:2004 duration format strings. |
14 | 13 | * |
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. |
24 | 15 | * |
25 | 16 | * @author Berry Cloud |
26 | 17 | */ |
27 | 18 | public class DurationValidator implements ConstraintValidator<ValidDuration, String> { |
28 | 19 |
|
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 | | - |
35 | 20 | @Override |
36 | 21 | public boolean isValid(String value, ConstraintValidatorContext context) { |
37 | 22 | if (value == null) { |
38 | 23 | return true; |
39 | 24 | } |
40 | 25 |
|
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(); |
45 | 27 |
|
46 | 28 | // Must start with P |
47 | | - if (!value.toUpperCase().startsWith("P")) { |
| 29 | + if (!upper.startsWith("P") || upper.length() < 2) { |
48 | 30 | return false; |
49 | 31 | } |
50 | 32 |
|
51 | | - // Remove P prefix for processing |
52 | | - String remaining = value.substring(1); |
| 33 | + String rest = upper.substring(1); |
53 | 34 |
|
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)); |
57 | 38 | } |
58 | 39 |
|
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) : ""; |
61 | 44 |
|
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; |
76 | 48 | } |
77 | 49 |
|
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)) { |
80 | 52 | return false; |
81 | 53 | } |
82 | 54 |
|
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)) { |
85 | 57 | return false; |
86 | 58 | } |
87 | 59 |
|
88 | | - // Must have at least one component |
89 | | - return !datePart.isEmpty() || timePart != null; |
| 60 | + return true; |
90 | 61 | } |
91 | 62 |
|
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) { |
102 | 64 | 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 |
126 | 80 | } |
127 | | - pos = monthIndex + 1; |
128 | 81 | } |
129 | 82 |
|
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(); |
145 | 84 | } |
146 | 85 |
|
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; |
183 | 89 | } |
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))) { |
193 | 92 | return false; |
194 | 93 | } |
195 | | - pos = secondIndex + 1; |
196 | 94 | } |
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; |
200 | 96 | } |
201 | 97 |
|
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()) { |
210 | 100 | return false; |
211 | 101 | } |
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)); |
213 | 110 | } |
214 | 111 | } |
0 commit comments