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));