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 73dadf009 Keep StringUtils left, right, mid, and overlay off surrogate
pair boundaries (#1776)
73dadf009 is described below
commit 73dadf0094cd3557149312b85c88b7b3d20018fc
Author: gaurav kumar pandey <[email protected]>
AuthorDate: Sun Aug 23 01:21:46 2026 +0530
Keep StringUtils left, right, mid, and overlay off surrogate pair
boundaries (#1776)
---
src/changes/changes.xml | 1 +
.../java/org/apache/commons/lang3/StringUtils.java | 35 +++++++-
.../commons/lang3/StringUtilsSubstringTest.java | 92 ++++++++++++++++++++++
.../org/apache/commons/lang3/StringUtilsTest.java | 25 ++++++
4 files changed, 149 insertions(+), 4 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index f964c2704..021fab1b8 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -248,6 +248,7 @@ java.lang.NullPointerException: Cannot invoke
<action type="fix" dev="ggregory" due-to="alhuda, Gary
Gregory">Fix CharRange.contains(CharRange) for negated argument ranges
(#1775).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">Fix SpotBugs USO_UNSAFE_METHOD_SYNCHRONIZATION in CharSet.</action>
<action type="fix" dev="ggregory" due-to="alhuda, Gary
Gregory">Keep StopWatch.formatSplitTime from clamping splits to int millis
(#1777).</action>
+ <action type="fix" dev="ggregory" due-to="gaurav kumar
pandey, Gary Gregory">Keep StringUtils left, right, mid, and overlay off
surrogate pair boundaries (#1776).</action>
<!-- ADD -->
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add JavaVersion.JAVA_27.</action>
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add SystemUtils.IS_JAVA_27.</action>
diff --git a/src/main/java/org/apache/commons/lang3/StringUtils.java
b/src/main/java/org/apache/commons/lang3/StringUtils.java
index 6d06ff1ae..824fb4583 100644
--- a/src/main/java/org/apache/commons/lang3/StringUtils.java
+++ b/src/main/java/org/apache/commons/lang3/StringUtils.java
@@ -5161,7 +5161,12 @@ public static String left(final String str, final int
len) {
if (str.length() <= len) {
return str;
}
- return str.substring(0, len);
+ int cut = len;
+ // keep the cut off the middle of a surrogate pair so the result is
never left holding a lone surrogate
+ if (splitsSurrogatePair(str, cut)) {
+ cut--;
+ }
+ return str.substring(0, cut);
}
/**
@@ -5433,10 +5438,20 @@ public static String mid(final String str, int pos,
final int len) {
if (pos < 0) {
pos = 0;
}
+ int start = pos;
+ // keep the start off the middle of a surrogate pair so the result is
never left holding a lone surrogate
+ if (splitsSurrogatePair(str, start)) {
+ start++;
+ }
if (str.length() - pos <= len) {
- return str.substring(pos);
+ return str.substring(start);
}
- return str.substring(pos, pos + len);
+ int end = pos + len;
+ // keep both cuts off the middle of a surrogate pair so the result is
never left holding a lone surrogate
+ if (splitsSurrogatePair(str, end)) {
+ end--;
+ }
+ return str.substring(start, Math.max(start, end));
}
/**
@@ -5661,6 +5676,13 @@ public static String overlay(final String str, String
overlay, int start, int en
start = end;
end = temp;
}
+ // keep both cuts off the middle of a surrogate pair so the result is
never left holding a lone surrogate
+ if (splitsSurrogatePair(str, start)) {
+ start--;
+ }
+ if (splitsSurrogatePair(str, end)) {
+ end++;
+ }
return str.substring(0, start) + overlay + str.substring(end);
}
@@ -6955,7 +6977,12 @@ public static String right(final String str, final int
len) {
if (str.length() <= len) {
return str;
}
- return str.substring(str.length() - len);
+ int start = str.length() - len;
+ // keep the cut off the middle of a surrogate pair so the result is
never left holding a lone surrogate
+ if (splitsSurrogatePair(str, start)) {
+ start++;
+ }
+ return str.substring(start);
}
/**
diff --git
a/src/test/java/org/apache/commons/lang3/StringUtilsSubstringTest.java
b/src/test/java/org/apache/commons/lang3/StringUtilsSubstringTest.java
index 9c7d18620..605ff8bc6 100644
--- a/src/test/java/org/apache/commons/lang3/StringUtilsSubstringTest.java
+++ b/src/test/java/org/apache/commons/lang3/StringUtilsSubstringTest.java
@@ -17,8 +17,10 @@
package org.apache.commons.lang3;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
@@ -399,4 +401,94 @@ void testSubstringsBetween_StringStringString() {
assertEquals(0, results.length);
}
+ @Test
+ void testLeftSurrogatePair() {
+ // U+1F600 GRINNING FACE is a supplementary code point stored as a
surrogate pair
+ final String grin = "😀";
+ assertEquals("", StringUtils.left(grin, 0));
+ assertEquals("", StringUtils.left(grin, 1));
+ assertEquals(grin, StringUtils.left(grin, 2));
+ assertEquals(grin, StringUtils.left(grin, 3));
+
+ assertEquals("a", StringUtils.left("a" + grin, 1));
+ assertEquals("a", StringUtils.left("a" + grin, 2));
+ assertEquals("a" + grin, StringUtils.left("a" + grin, 3));
+
+ final String source = "a" + grin + "b" + grin + "cd" + grin + "ef";
+ for (int len = 0; len <= source.length(); len++) {
+ final String result = StringUtils.left(source, len);
+ assertTrue(result.length() <= len, () -> "result longer than len:
" + 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);
+ }
+ }
+ }
+ }
+
+ @Test
+ void testMidSurrogatePair() {
+ // U+1F600 GRINNING FACE is a supplementary code point stored as a
surrogate pair
+ final String grin = "😀";
+ assertEquals("", StringUtils.mid(grin, 0, 0));
+ assertEquals("", StringUtils.mid(grin, 0, 1));
+ assertEquals(grin, StringUtils.mid(grin, 0, 2));
+ assertEquals(grin, StringUtils.mid(grin, 0, 3));
+ assertEquals("", StringUtils.mid(grin, 1, 1));
+ assertEquals("", StringUtils.mid(grin, 1, 2));
+
+ assertEquals("a", StringUtils.mid("a" + grin + "b", 0, 2));
+ assertEquals(grin, StringUtils.mid("a" + grin + "b", 1, 2));
+ assertEquals("b", StringUtils.mid("a" + grin + "b", 2, 2));
+
+ final String source = "a" + grin + "b" + grin + "cd" + grin + "ef";
+ for (int pos = 0; pos <= source.length(); pos++) {
+ for (int len = 0; len <= source.length(); len++) {
+ final String result = StringUtils.mid(source, pos, len);
+ 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);
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ void testRightSurrogatePair() {
+ // U+1F600 GRINNING FACE is a supplementary code point stored as a
surrogate pair
+ final String grin = "😀";
+ assertEquals("", StringUtils.right(grin, 0));
+ assertEquals("", StringUtils.right(grin, 1));
+ assertEquals(grin, StringUtils.right(grin, 2));
+ assertEquals(grin, StringUtils.right(grin, 3));
+
+ assertEquals("a", StringUtils.right(grin + "a", 1));
+ assertEquals("a", StringUtils.right(grin + "a", 2));
+ assertEquals(grin + "a", StringUtils.right(grin + "a", 3));
+
+ final String source = "a" + grin + "b" + grin + "cd" + grin + "ef";
+ for (int len = 0; len <= source.length(); len++) {
+ final String result = StringUtils.right(source, len);
+ assertTrue(result.length() <= len, () -> "result longer than len:
" + 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);
+ }
+ }
+ }
+ }
+
}
diff --git a/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
b/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
index e27c9f90e..d4976574d 100644
--- a/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
@@ -1461,6 +1461,31 @@ void testOverlay_StringStringIntInt() {
assertEquals("abcdefzzzz", StringUtils.overlay("abcdef", "zzzz", 10,
8));
}
+ @Test
+ void testOverlaySurrogatePair() {
+ final String grin = "😀";
+ // overlaying across surrogate pair boundary backs off start and
advances end
+ assertEquals("X", StringUtils.overlay(grin, "X", 1, 1));
+ assertEquals("aXb", StringUtils.overlay("a" + grin + "b", "X", 1, 3));
+ assertEquals("aXb", StringUtils.overlay("a" + grin + "b", "X", 2, 2));
+
+ final String source = "a" + grin + "b" + grin + "cd" + grin + "ef";
+ for (int start = 0; start <= source.length(); start++) {
+ for (int end = 0; end <= source.length(); end++) {
+ final String result = StringUtils.overlay(source, "X", start,
end);
+ 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 prependIfMissing}.
*/