diff --git a/src/main/java/org/apache/commons/lang3/text/translate/NumericEntityUnescaper.java b/src/main/java/org/apache/commons/lang3/text/translate/NumericEntityUnescaper.java index 805ceef4927..00e94a024ff 100644 --- a/src/main/java/org/apache/commons/lang3/text/translate/NumericEntityUnescaper.java +++ b/src/main/java/org/apache/commons/lang3/text/translate/NumericEntityUnescaper.java @@ -110,26 +110,21 @@ public int translate(final CharSequence input, final int index, final Writer out if (input.charAt(index) == '&' && index < seqEnd - 2 && input.charAt(index + 1) == '#') { int start = index + 2; boolean isHex = false; - final char firstChar = input.charAt(start); if (firstChar == 'x' || firstChar == 'X') { start++; isHex = true; - // Check there's more than just an x after the if (start == seqEnd) { return 0; } } - int end = start; // Note that this supports character codes without a ; on the end while (end < seqEnd && CharUtils.isHex(input.charAt(end))) { end++; } - final boolean semiNext = end != seqEnd && input.charAt(end) == ';'; - if (!semiNext) { if (isSet(OPTION.semiColonRequired)) { return 0; @@ -138,7 +133,6 @@ public int translate(final CharSequence input, final int index, final Writer out throw new IllegalArgumentException("Semi-colon required at end of numeric entity"); } } - final int entityValue; try { if (isHex) { @@ -149,7 +143,9 @@ public int translate(final CharSequence input, final int index, final Writer out } catch (final NumberFormatException nfe) { return 0; } - + if (entityValue < Character.MIN_CODE_POINT || entityValue > Character.MAX_CODE_POINT) { + return 0; + } if (entityValue > 0xFFFF) { final char[] chars = Character.toChars(entityValue); out.write(chars[0]); @@ -157,7 +153,6 @@ public int translate(final CharSequence input, final int index, final Writer out } else { out.write(entityValue); } - return 2 + end - start + (isHex ? 1 : 0) + (semiNext ? 1 : 0); } return 0; diff --git a/src/main/java/org/apache/commons/lang3/time/FastTimeZone.java b/src/main/java/org/apache/commons/lang3/time/FastTimeZone.java index 63e85c3fb26..57fc447ae62 100644 --- a/src/main/java/org/apache/commons/lang3/time/FastTimeZone.java +++ b/src/main/java/org/apache/commons/lang3/time/FastTimeZone.java @@ -14,6 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package org.apache.commons.lang3.time; import java.util.TimeZone; @@ -41,17 +42,24 @@ public static TimeZone getGmtTimeZone() { } /** - * Gets a TimeZone with GMT offsets. A GMT offset must be either 'Z', or 'UTC', or match - * (GMT)? hh?(:?mm?)?, where h and m are digits representing hours and minutes. + * Gets a TimeZone with GMT offsets. A GMT offset must be either 'Z', or 'UTC', or match (GMT)? hh?(:?mm?)?, where h and m are digits representing + * hours and minutes. + * + *
+ * Note: the underlying regex is lenient — every capture group (sign, hours, minutes, and the {@code GMT} prefix) is optional. Inputs that lack any digit + * group, such as the empty string, {@code "+"}, {@code "-"}, or {@code "GMT"} alone, still match and the method returns the GMT TimeZone with a raw offset + * of zero (mirroring {@link TimeZone#getTimeZone(String)} JDK-parity for unrecognized ids). Only inputs that fail the regex outright return + * {@code null}. + *
* - * @param pattern The GMT offset - * @return A TimeZone with offset from GMT or null, if pattern does not match. + * @param pattern The GMT offset. + * @return a TimeZone matching the (possibly partial or empty) GMT offset pattern, defaulting to GMT for an unrecognized but parseable input, or + * {@code null} if the pattern fails the regex. */ public static TimeZone getGmtTimeZone(final String pattern) { if ("Z".equals(pattern) || "UTC".equals(pattern)) { return GREENWICH; } - final Matcher m = GMT_PATTERN.matcher(pattern); if (m.matches()) { final int hours = parseInt(m.group(2)); @@ -65,24 +73,19 @@ public static TimeZone getGmtTimeZone(final String pattern) { } /** - * Gets a TimeZone, looking first for GMT custom ids, then falling back to Olson ids. - * A GMT custom id can be 'Z', or 'UTC', or has an optional prefix of GMT, - * followed by sign, hours digit(s), optional colon(':'), and optional minutes digits. - * i.e. [GMT] (+|-) Hours [[:] Minutes] + * Gets a TimeZone, looking first for GMT custom ids, then falling back to Olson ids. A GMT custom id can be 'Z', or 'UTC', or has an optional prefix of + * GMT, followed by sign, hours digit(s), optional colon(':'), and optional minutes digits. i.e. [GMT] (+|-) Hours [[:] Minutes] * - * @param id A GMT custom id (or Olson id - * @return A time zone + * @param id A GMT custom id or Olson id. + * @return A time zone. */ public static TimeZone getTimeZone(final String id) { final TimeZone tz = getGmtTimeZone(id); - if (tz != null) { - return tz; - } - return TimeZones.getTimeZone(id); + return tz != null ? tz : TimeZones.getTimeZone(id); } - private static int parseInt(final String group) { - return group != null ? Integer.parseInt(group) : 0; + private static int parseInt(final String s) { + return s != null ? Integer.parseInt(s) : 0; } private static boolean parseSign(final String group) { diff --git a/src/main/java/org/apache/commons/lang3/time/GmtTimeZone.java b/src/main/java/org/apache/commons/lang3/time/GmtTimeZone.java index 4db3bf09c7c..ca3c6103696 100644 --- a/src/main/java/org/apache/commons/lang3/time/GmtTimeZone.java +++ b/src/main/java/org/apache/commons/lang3/time/GmtTimeZone.java @@ -96,9 +96,12 @@ public boolean inDaylightTime(final Date date) { return false; } + /** + * Always throws {@link UnsupportedOperationException}. + */ @Override public void setRawOffset(final int offsetMillis) { - throw new UnsupportedOperationException(); + throw new UnsupportedOperationException("GmtTimeZone.setRawOffset(int)"); } @Override diff --git a/src/test/java/org/apache/commons/lang3/text/translate/NumericEntityEscaperTest.java b/src/test/java/org/apache/commons/lang3/text/translate/NumericEntityEscaperTest.java index 22c5639c760..dab4c884a93 100644 --- a/src/test/java/org/apache/commons/lang3/text/translate/NumericEntityEscaperTest.java +++ b/src/test/java/org/apache/commons/lang3/text/translate/NumericEntityEscaperTest.java @@ -19,6 +19,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import java.io.StringWriter; + import org.apache.commons.lang3.AbstractLangTest; import org.junit.jupiter.api.Test; @@ -31,7 +33,6 @@ class NumericEntityEscaperTest extends AbstractLangTest { @Test void testAbove() { final NumericEntityEscaper nee = NumericEntityEscaper.above('F'); - final String input = "ADFGZ"; final String result = nee.translate(input); assertEquals("ADFGZ", result, "Failed to escape numeric entities via the above method"); @@ -40,7 +41,6 @@ void testAbove() { @Test void testBelow() { final NumericEntityEscaper nee = NumericEntityEscaper.below('F'); - final String input = "ADFGZ"; final String result = nee.translate(input); assertEquals("ADFGZ", result, "Failed to escape numeric entities via the below method"); @@ -49,7 +49,6 @@ void testBelow() { @Test void testBetween() { final NumericEntityEscaper nee = NumericEntityEscaper.between('F', 'L'); - final String input = "ADFGZ"; final String result = nee.translate(input); assertEquals("ADFGZ", result, "Failed to escape numeric entities via the between method"); @@ -61,10 +60,32 @@ void testSupplementary() { final NumericEntityEscaper nee = new NumericEntityEscaper(); final String input = "\uD803\uDC22"; final String expected = "𐰢"; - final String result = nee.translate(input); assertEquals(expected, result, "Failed to escape numeric entities supplementary characters"); + } + @Test + void testNumericEntityOverflow() throws Exception { + // cp = 1234567890 > Character.MAX_CODE_POINT (0x10FFFF = 1114111). + // Pre-patch: IAE escapes from Character.toChars. + // Post-patch: return 0, no write, no exception. + final NumericEntityUnescaper u = new NumericEntityUnescaper(); + final StringWriter sw = new StringWriter(); + int consumed = u.translate("", 0, sw); + assertEquals(0, consumed); + assertEquals("", sw.toString()); + consumed = u.translate("------", 0, sw); + assertEquals(0, consumed); + assertEquals("", sw.toString()); } + @Test + void testValidCodePoint() throws Exception { + // Negative control: 'A' = 'A' must translate successfully. + final NumericEntityUnescaper u = new NumericEntityUnescaper(); + final StringWriter sw = new StringWriter(); + final int consumed = u.translate("A", 0, sw); + assertEquals("A", sw.toString()); + assertEquals(5, consumed); + } } diff --git a/src/test/java/org/apache/commons/lang3/time/FastTimeZoneTest.java b/src/test/java/org/apache/commons/lang3/time/FastTimeZoneTest.java index 6135cebfaa6..185d1f3f2df 100644 --- a/src/test/java/org/apache/commons/lang3/time/FastTimeZoneTest.java +++ b/src/test/java/org/apache/commons/lang3/time/FastTimeZoneTest.java @@ -14,33 +14,60 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package org.apache.commons.lang3.time; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.TimeZone; import org.apache.commons.lang3.AbstractLangTest; import org.junit.jupiter.api.Test; /** - * Tests for FastTimeZone + * Tests {@link FastTimeZone}. */ class FastTimeZoneTest extends AbstractLangTest { - private static final int HOURS_23 = 23 * 60 * 60 * 1000; private static final int HOURS_2 = 2 * 60 * 60 * 1000; - private static final int MINUTES_59 = 59 * 60 * 1000; + private static final int HOURS_23 = 23 * 60 * 60 * 1000; private static final int MINUTES_5 = 5 * 60 * 1000; + private static final int MINUTES_59 = 59 * 60 * 1000; @Test void testBareGmt() { assertEquals(FastTimeZone.getGmtTimeZone(), FastTimeZone.getTimeZone(TimeZones.GMT_ID)); } + @Test + void testEmptyStringReturnsNonNullDespiteJavadoc() { + // Javadoc claims null when pattern does not match. Empty string matches + // the over-permissive regex and returns the GMT zone instead. + final TimeZone tz = FastTimeZone.getGmtTimeZone(""); + assertNotNull(tz); + assertEquals(0, tz.getRawOffset()); + } + @Test void testGetGmtTimeZone() { assertEquals(0, FastTimeZone.getGmtTimeZone().getRawOffset()); } + @Test + void testGmtOnlyReturnsNonNull() { + // The literal "GMT" prefix on its own matches; both digit groups absent. + final TimeZone tz = FastTimeZone.getGmtTimeZone("GMT"); + assertNotNull(tz); + assertEquals(0, tz.getRawOffset()); + } + @Test void testGmtPrefix() { assertEquals(HOURS_23, FastTimeZone.getGmtTimeZone("GMT+23:00").getRawOffset()); @@ -67,11 +94,37 @@ void testHoursMinutes() { assertEquals(HOURS_2 + MINUTES_5, FastTimeZone.getGmtTimeZone("0205").getRawOffset()); } + @Test + void testInvalidStringReturnsNull() { + // Negative control: a string that genuinely cannot match the regex returns null. + assertNull(FastTimeZone.getGmtTimeZone("XYZ"), "non-matching input returns null"); + } + + /** + * Patched-source check. After the doc-only patch lands, the Javadoc on {@code getGmtTimeZone(String)} no longer promises "null if pattern does not match" + * for the empty / sign-only / GMT-only inputs. We string-search the source file for the corrected wording so reverting the Javadoc (mutation control) flips + * this assertion to FAIL. + */ + @Test + void testJavadocReflectsLenientBehavior() throws Exception { + final Path src = Paths.get("src/main/java/org/apache/commons/lang3/time/FastTimeZone.java"); + final String body = new String(Files.readAllBytes(src), StandardCharsets.UTF_8); + assertTrue(body.contains("defaulting to GMT for an unrecognized but parseable input")); + } + @Test void testOlson() { assertEquals(TimeZones.getTimeZone("America/New_York"), FastTimeZone.getTimeZone("America/New_York")); } + @Test + void testPlusOnlyReturnsNonNull() { + // Sign-only input still matches the regex; hours and minutes default to 0. + final TimeZone tz = FastTimeZone.getGmtTimeZone("+"); + assertNotNull(tz); + assertEquals(0, tz.getRawOffset()); + } + @Test void testSign() { assertEquals(HOURS_23, FastTimeZone.getGmtTimeZone("+23:00").getRawOffset()); @@ -95,5 +148,4 @@ void testZeroOffsetsReturnSingleton() { assertEquals(FastTimeZone.getGmtTimeZone(), FastTimeZone.getTimeZone("+0")); assertEquals(FastTimeZone.getGmtTimeZone(), FastTimeZone.getTimeZone("-0")); } - }