Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -149,15 +143,16 @@ 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]);
out.write(chars[1]);
} else {
out.write(entityValue);
}

return 2 + end - start + (isHex ? 1 : 0) + (semiNext ? 1 : 0);
}
return 0;
Expand Down
37 changes: 20 additions & 17 deletions src/main/java/org/apache/commons/lang3/time/FastTimeZone.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
* <em>(GMT)? hh?(:?mm?)?</em>, 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 <em>(GMT)? hh?(:?mm?)?</em>, where h and m are digits representing
* hours and minutes.
*
* <p>
* 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}.
* </p>
*
* @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));
Expand All @@ -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. <em>[GMT] (+|-) Hours [[:] Minutes]</em>
* 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. <em>[GMT] (+|-) Hours [[:] Minutes]</em>
*
* @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) {
Expand Down
5 changes: 4 additions & 1 deletion src/main/java/org/apache/commons/lang3/time/GmtTimeZone.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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("ADF&#71;&#90;", result, "Failed to escape numeric entities via the above method");
Expand All @@ -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("&#65;&#68;FGZ", result, "Failed to escape numeric entities via the below method");
Expand All @@ -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("AD&#70;&#71;Z", result, "Failed to escape numeric entities via the between method");
Expand All @@ -61,10 +60,32 @@ void testSupplementary() {
final NumericEntityEscaper nee = new NumericEntityEscaper();
final String input = "\uD803\uDC22";
final String expected = "&#68642;";

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("&#1234567890;", 0, sw);
assertEquals(0, consumed);
assertEquals("", sw.toString());
consumed = u.translate("---&#1234567890;---", 0, sw);
assertEquals(0, consumed);
assertEquals("", sw.toString());
}

@Test
void testValidCodePoint() throws Exception {
// Negative control: '&#65;' = 'A' must translate successfully.
final NumericEntityUnescaper u = new NumericEntityUnescaper();
final StringWriter sw = new StringWriter();
final int consumed = u.translate("&#65;", 0, sw);
assertEquals("A", sw.toString());
assertEquals(5, consumed);
}
}
60 changes: 56 additions & 4 deletions src/test/java/org/apache/commons/lang3/time/FastTimeZoneTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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());
Expand All @@ -95,5 +148,4 @@ void testZeroOffsetsReturnSingleton() {
assertEquals(FastTimeZone.getGmtTimeZone(), FastTimeZone.getTimeZone("+0"));
assertEquals(FastTimeZone.getGmtTimeZone(), FastTimeZone.getTimeZone("-0"));
}

}
Loading