You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by gg...@apache.org on 2026/10/16 14:48:00 UTC
(commons-lang) branch master updated: Prevent StringUtils padding from splitting surrogate pairs
This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new 4276f1ff3 Prevent StringUtils padding from splitting surrogate pairs
4276f1ff3 is described below
commit 4276f1ff387b2e05ef6e8c78e64868dd0621f2d6
Author: Gary Gregory <ga...@gmail.com>
AuthorDate: Fri Oct 16 10:47:53 2026 -0400
Prevent StringUtils padding from splitting surrogate pairs
Adjust leftPad and rightPad padding boundaries to preserve surrogate
pairs,
also protecting center through delegation. Document the possible
one-char
shortfall and use Unicode escapes in Javadoc examples.
Add tests for mixed padding, repeated patterns, centering, output
lengths,
and surrogate validity.
Applied PR #1813 locally and implemented the follow-up changes:
- Replaced Javadoc emoji literals with Unicode escapes.
- Narrowed the Javadoc guarantee to avoiding newly split surrogate
pairs.
- Added exact-output tests for mixed padding and centering.
- Added length and input-preservation assertions.
---
src/changes/changes.xml | 1 +
.../java/org/apache/commons/lang3/StringUtils.java | 35 +++++++-
.../org/apache/commons/lang3/StringUtilsTest.java | 94 ++++++++++++++++++++++
3 files changed, 126 insertions(+), 4 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 3731e7c6d..6bcb7e3b2 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -62,6 +62,7 @@ The <action> type attribute can be add,update,fix,remove.
<action type="fix" dev="ggregory" due-to="alhuda, Gary Gregory">Escape CharSequence values in ToStringStyle.JSON_STYLE (#1821).</action>
<action issue="LANG-796" type="fix" dev="ggregory" due-to="gaurav kumar pandey, Gary Gregory">Clarify DateUtils calendar arithmetic and daylight saving time behavior.</action>
<action type="fix" dev="ggregory" due-to="alhuda, Gary Gregory">Treat an unmatched quote as closed in ExtendedMessageFormat (#1819).</action>
+ <action issue="LANG-796" type="fix" dev="ggregory" due-to="gaurav kumar pandey, Gary Gregory">Prevent StringUtils padding from splitting surrogate pairs (#1813).</action>
<!-- ADD -->
<!-- UPDATE -->
</release>
diff --git a/src/main/java/org/apache/commons/lang3/StringUtils.java b/src/main/java/org/apache/commons/lang3/StringUtils.java
index 9d85ebad8..aaf30c3cc 100644
--- a/src/main/java/org/apache/commons/lang3/StringUtils.java
+++ b/src/main/java/org/apache/commons/lang3/StringUtils.java
@@ -649,6 +649,9 @@ public static String center(String str, final int size, final char padChar) {
*
* <p>
* If the size is less than the String length, the String is returned. A {@code null} String returns {@code null}. A negative size is treated as zero.
+ * If the requested padding length on either side would split a Unicode surrogate pair in {@code padStr}, the padding is shortened by one {@code char}
+ * (via delegation to {@link #leftPad(String, int, String)} and {@link #rightPad(String, int, String)}) to avoid introducing an unpaired
+ * surrogate by splitting the pair. In that case, the returned String may be shorter than {@code size}, but by no more than one {@code char}.
* </p>
*
* <pre>
@@ -661,6 +664,8 @@ public static String center(String str, final int size, final char padChar) {
* StringUtils.center("a", 4, "yz") = "yayz"
* StringUtils.center("abc", 7, null) = " abc "
* StringUtils.center("abc", 7, "") = " abc "
+ * StringUtils.center("a", 2, "\uD83D\uDE00") = "a"
+ * StringUtils.center("a", 3, "\uD83D\uDE00") = "a\uD83D\uDE00"
* </pre>
*
* @param str The String to center, may be null.
@@ -5246,7 +5251,9 @@ public static String leftPad(final String str, final int size, final char padCha
* Left pad a String with a specified String.
*
* <p>
- * Pad to a size of {@code size}.
+ * Pad to a size of {@code size}. If the requested padding length would split a Unicode surrogate pair in {@code padStr},
+ * the padding is shortened by one {@code char} to avoid introducing an unpaired surrogate by splitting the pair. In that case, the
+ * returned String has a length of {@code size - 1}.
* </p>
*
* <pre>
@@ -5259,6 +5266,8 @@ public static String leftPad(final String str, final int size, final char padCha
* StringUtils.leftPad("bat", -1, "yz") = "bat"
* StringUtils.leftPad("bat", 5, null) = " bat"
* StringUtils.leftPad("bat", 5, "") = " bat"
+ * StringUtils.leftPad("a", 2, "\uD83D\uDE00") = "a"
+ * StringUtils.leftPad("a", 3, "\uD83D\uDE00") = "\uD83D\uDE00a"
* </pre>
*
* @param str The String to pad out, may be null.
@@ -5275,13 +5284,20 @@ public static String leftPad(final String str, final int size, String padStr) {
}
final int padLen = padStr.length();
final int strLen = str.length();
- final int pads = size - strLen;
+ int pads = size - strLen;
if (pads <= 0) {
return str; // returns original String when possible
}
if (padLen == 1 && pads <= PAD_LIMIT) {
return leftPad(str, size, padStr.charAt(0));
}
+ // Keep the padding boundary off the middle of a surrogate pair to avoid introducing a lone surrogate.
+ if (splitsSurrogatePair(padStr, pads % padLen)) {
+ pads--;
+ }
+ if (pads <= 0) {
+ return str;
+ }
if (pads == padLen) {
return padStr.concat(str);
}
@@ -7060,7 +7076,9 @@ public static String rightPad(final String str, final int size, final char padCh
* Right pad a String with a specified String.
*
* <p>
- * The String is padded to the size of {@code size}.
+ * The String is padded to the size of {@code size}. If the requested padding length would split a Unicode surrogate pair
+ * in {@code padStr}, the padding is shortened by one {@code char} to avoid introducing an unpaired surrogate by splitting the pair.
+ * In that case, the returned String has a length of {@code size - 1}.
* </p>
*
* <pre>
@@ -7073,6 +7091,8 @@ public static String rightPad(final String str, final int size, final char padCh
* StringUtils.rightPad("bat", -1, "yz") = "bat"
* StringUtils.rightPad("bat", 5, null) = "bat "
* StringUtils.rightPad("bat", 5, "") = "bat "
+ * StringUtils.rightPad("a", 2, "\uD83D\uDE00") = "a"
+ * StringUtils.rightPad("a", 3, "\uD83D\uDE00") = "a\uD83D\uDE00"
* </pre>
*
* @param str The String to pad out, may be null.
@@ -7089,13 +7109,20 @@ public static String rightPad(final String str, final int size, String padStr) {
}
final int padLen = padStr.length();
final int strLen = str.length();
- final int pads = size - strLen;
+ int pads = size - strLen;
if (pads <= 0) {
return str; // returns original String when possible
}
if (padLen == 1 && pads <= PAD_LIMIT) {
return rightPad(str, size, padStr.charAt(0));
}
+ // Keep the padding boundary off the middle of a surrogate pair to avoid introducing a lone surrogate.
+ if (splitsSurrogatePair(padStr, pads % padLen)) {
+ pads--;
+ }
+ if (pads <= 0) {
+ return str;
+ }
if (pads == padLen) {
return str.concat(padStr);
}
diff --git a/src/test/java/org/apache/commons/lang3/StringUtilsTest.java b/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
index 5e45c55c9..df4bae511 100644
--- a/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
@@ -41,14 +41,17 @@
import java.util.Locale;
import java.util.function.Supplier;
import java.util.regex.PatternSyntaxException;
+import java.util.stream.Stream;
import org.apache.commons.lang3.function.Suppliers;
+import org.apache.commons.lang3.function.TriFunction;
import org.apache.commons.lang3.mutable.MutableInt;
import org.apache.commons.lang3.text.WordUtils;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junitpioneer.jupiter.DefaultLocale;
import org.junitpioneer.jupiter.ReadsDefaultLocale;
@@ -223,6 +226,19 @@ private void innerTestSplitPreserveAllTokens(final char separator, final String
assertEquals(str.substring(2), res[1], msg);
}
+ private static void assertNoLoneSurrogates(final String result) {
+ for (int i = 0; i < result.length(); i++) {
+ final char ch = result.charAt(i);
+ if (Character.isHighSurrogate(ch)) {
+ assertTrue(i + 1 < result.length() && Character.isLowSurrogate(result.charAt(i + 1)),
+ "lone high surrogate in: " + result);
+ i++; // skip the paired low surrogate
+ } else {
+ assertFalse(Character.isLowSurrogate(ch), "lone low surrogate in: " + result);
+ }
+ }
+ }
+
/**
* Tests {@code appendIfMissing}.
*/
@@ -340,6 +356,17 @@ void testCenter_StringIntString() {
assertEquals(" abc ", StringUtils.center("abc", 7, ""));
}
+ @Test
+ void testCenterSurrogatePair() {
+ final String grin = "\uD83D\uDE00";
+ assertEquals("a", StringUtils.center("a", 2, grin));
+ assertEquals("a" + grin, StringUtils.center("a", 3, grin));
+ assertEquals("a" + grin, StringUtils.center("a", 4, grin));
+ assertEquals(grin + "a" + grin, StringUtils.center("a", 5, grin));
+ assertEquals(grin + "a" + grin, StringUtils.center("a", 6, grin));
+ assertEquals(grin + "a" + grin + grin, StringUtils.center("a", 7, grin));
+ }
+
@Test
void testChomp() {
@@ -1359,6 +1386,15 @@ void testLeftPad_StringIntString() {
assertEquals("abc", StringUtils.leftPad("abc", Integer.MIN_VALUE, " "));
}
+ @Test
+ void testLeftPadSurrogatePair() {
+ final String grin = "\uD83D\uDE00";
+ assertEquals("a", StringUtils.leftPad("a", 2, grin));
+ assertEquals(grin + "a", StringUtils.leftPad("a", 3, grin));
+ assertEquals(grin + "a", StringUtils.leftPad("a", 4, grin));
+ assertEquals(grin + grin + "a", StringUtils.leftPad("a", 5, grin));
+ }
+
@Test
void testLength_CharBuffer() {
assertEquals(0, StringUtils.length(CharBuffer.wrap("")));
@@ -1486,6 +1522,55 @@ void testOverlaySurrogatePair() {
}
}
+ @Test
+ void testPadMixedSurrogatePair() {
+ final String grin = "\uD83D\uDE00";
+ final String pad = "a" + grin + "b";
+ // A cut inside the first occurrence of the pad string.
+ assertEquals("ax", StringUtils.leftPad("x", 3, pad));
+ assertEquals("xa", StringUtils.rightPad("x", 3, pad));
+ // A cut inside a repeated occurrence of the pad string.
+ assertEquals("a" + grin + "bax", StringUtils.leftPad("x", 7, pad));
+ assertEquals("xa" + grin + "ba", StringUtils.rightPad("x", 7, pad));
+ // Preserve complete pairs before and after a complete repetition.
+ assertEquals("a" + grin + "x", StringUtils.leftPad("x", 4, pad));
+ assertEquals("xa" + grin + "ba" + grin, StringUtils.rightPad("x", 8, pad));
+ // Right padding can either leave the result short or compensate for a shortened left pad.
+ assertEquals("axa", StringUtils.center("x", 4, pad));
+ assertEquals("axa" + grin + "b", StringUtils.center("x", 6, pad));
+ assertEquals("a" + grin + "xa" + grin, StringUtils.center("x", 7, pad));
+ }
+
+ static Stream<TriFunction<String, Integer, String, String>> testPadSurrogatePair() {
+ return Stream.of(
+ StringUtils::center,
+ StringUtils::leftPad,
+ StringUtils::rightPad);
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void testPadSurrogatePair(final TriFunction<String, Integer, String, String> padFunction) {
+ final String grin = "\uD83D\uDE00";
+ final String[] testStrings = {"", "a", "hello", grin, "a" + grin + "b"};
+ final String[] padStrings = {grin, "a" + grin, grin + "b", "a" + grin + "b", grin + "\uD83C\uDF89"};
+ for (final String str : testStrings) {
+ for (final String padStr : padStrings) {
+ for (int size = 0; size <= 15; size++) {
+ final String result = padFunction.apply(str, size, padStr);
+ assertNoLoneSurrogates(result);
+ if (size <= str.length()) {
+ assertEquals(str, result);
+ } else {
+ assertTrue(result.length() == size || result.length() == size - 1,
+ "Unexpected result length " + result.length() + " for requested size " + size);
+ assertTrue(result.contains(str));
+ }
+ }
+ }
+ }
+ }
+
/**
* Tests {@code prependIfMissing}.
*/
@@ -2268,6 +2353,15 @@ void testRightPad_StringIntString() {
assertEquals("abc", StringUtils.rightPad("abc", Integer.MIN_VALUE, " "));
}
+ @Test
+ void testRightPadSurrogatePair() {
+ final String grin = "\uD83D\uDE00";
+ assertEquals("a", StringUtils.rightPad("a", 2, grin));
+ assertEquals("a" + grin, StringUtils.rightPad("a", 3, grin));
+ assertEquals("a" + grin, StringUtils.rightPad("a", 4, grin));
+ assertEquals("a" + grin + grin, StringUtils.rightPad("a", 5, grin));
+ }
+
@Test
void testRotate_StringInt() {
assertNull(StringUtils.rotate(null, 1));