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 f13aa3d80 StringUtils.replaceEachRepeatedly derives its recursion
budget from the input itself - the documented StackOverflowError protection
fails on large tables, and expanding rules amplify text 64x even at the default
TTL (f005).
f13aa3d80 is described below
commit f13aa3d80272db38bbf9e4a35a4945e7c91a9d6b
Author: Gary Gregory <[email protected]>
AuthorDate: Fri Sep 4 22:17:16 2026 -0400
StringUtils.replaceEachRepeatedly derives its recursion budget from the
input itself - the documented StackOverflowError protection fails on
large tables, and expanding rules amplify text 64x even at the default
TTL (f005).
---
src/changes/changes.xml | 1 +
.../java/org/apache/commons/lang3/StringUtils.java | 66 +++++++++++-----------
.../org/apache/commons/lang3/StringUtilsTest.java | 8 ++-
3 files changed, 38 insertions(+), 37 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 55040ec91..445df41f5 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -253,6 +253,7 @@ java.lang.NullPointerException: Cannot invoke
<action type="fix" dev="ggregory" due-to="gaurav kumar
pandey, Gary Gregory">Fix DurationFormatUtils.formatPeriod() calculation when
pattern omits 'M' (#1780).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">FastDateParser parses 'Y' (week year) as plain calendar year;
asymmetric with FastDatePrinter and with SimpleDateFormat; boundary dates shift
by a full year, silently (f002).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">UnicodeUnescaper.unescapeJava/EcmaScript/Json: malformed \u sequences
throw undeclared IllegalArgumentException, AND non-ASCII digit spellings of \u
escapes are silently accepted; both arms of one missing ASCII-hex prescan
(f004).</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">StringUtils.replaceEachRepeatedly derives its recursion budget from
the input itself - the documented StackOverflowError protection fails on large
tables, and expanding rules amplify text 64x even at the default TTL
(f005).</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 824fb4583..16806e317 100644
--- a/src/main/java/org/apache/commons/lang3/StringUtils.java
+++ b/src/main/java/org/apache/commons/lang3/StringUtils.java
@@ -6485,11 +6485,11 @@ public static String replaceChars(final String str,
final String searchChars, St
* @since 2.4
*/
public static String replaceEach(final String text, final String[]
searchList, final String[] replacementList) {
- return replaceEach(text, searchList, replacementList, false, 0);
+ return replaceEachOnce(text, searchList, replacementList);
}
/**
- * Replace all occurrences of Strings within another String. This is a
private recursive helper method for
+ * Replace all occurrences of Strings within another String, in a single
pass. This is a private helper method for
* {@link #replaceEachRepeatedly(String, String[], String[])} and {@link
#replaceEach(String, String[], String[])}
*
* <p>
@@ -6497,33 +6497,30 @@ public static String replaceEach(final String text,
final String[] searchList, f
* </p>
*
* <pre>
- * StringUtils.replaceEach(null, *, *, *, *)
= null
- * StringUtils.replaceEach("", *, *, *, *)
= ""
- * StringUtils.replaceEach("aba", null, null, *, *)
= "aba"
- * StringUtils.replaceEach("aba", new String[0], null, *, *)
= "aba"
- * StringUtils.replaceEach("aba", null, new String[0], *, *)
= "aba"
- * StringUtils.replaceEach("aba", new String[]{"a"}, null, *, *)
= "aba"
- * StringUtils.replaceEach("aba", new String[]{"a"}, new String[]{""}, *,
>=0) = "b"
- * StringUtils.replaceEach("aba", new String[]{null}, new String[]{"a"},
*, >=0) = "aba"
- * StringUtils.replaceEach("abcde", new String[]{"ab", "d"}, new
String[]{"w", "t"}, *, >=0) = "wcte"
- * (example of how it repeats)
- * StringUtils.replaceEach("abcde", new String[]{"ab", "d"}, new
String[]{"d", "t"}, false, >=0) = "dcte"
- * StringUtils.replaceEach("abcde", new String[]{"ab", "d"}, new
String[]{"d", "t"}, true, >=2) = "tcte"
- * StringUtils.replaceEach("abcde", new String[]{"ab", "d"}, new
String[]{"d", "ab"}, *, *) = Throws {@link IllegalStateException}
+ * StringUtils.replaceEachOnce(null, *, *)
= null
+ * StringUtils.replaceEachOnce("", *, *)
= ""
+ * StringUtils.replaceEachOnce("aba", null, null)
= "aba"
+ * StringUtils.replaceEachOnce("aba", new String[0], null)
= "aba"
+ * StringUtils.replaceEachOnce("aba", null, new String[0])
= "aba"
+ * StringUtils.replaceEachOnce("aba", new String[]{"a"}, null)
= "aba"
+ * StringUtils.replaceEachOnce("aba", new String[]{"a"}, new
String[]{""}) = "b"
+ * StringUtils.replaceEachOnce("aba", new String[]{null}, new
String[]{"a"}) = "aba"
+ * StringUtils.replaceEachOnce("abcde", new String[]{"ab", "d"}, new
String[]{"w", "t"}) = "wcte"
+ * StringUtils.replaceEachOnce("abcde", new String[]{"ab", "d"}, new
String[]{"d", "t"}) = "dcte"
* </pre>
*
+ * <p>
+ * When no replacement is performed, the {@code text} argument is returned
unchanged (same reference); callers rely on this to detect convergence.
+ * </p>
+ *
* @param text text to search and replace in, no-op if null.
* @param searchList The Strings to search for, no-op if null.
* @param replacementList The Strings to replace them with, no-op if null.
- * @param repeat if true, then replace repeatedly until there are
no more possible replacements or timeToLive < 0.
- * @param timeToLive if less than 0 then there is a circular
reference and endless loop.
* @return The text with any replacements processed, {@code null} if null
String input.
- * @throws IllegalStateException if the search is repeating and there
is an endless loop due to outputs of one being inputs to another.
* @throws IllegalArgumentException if the lengths of the arrays are not
the same (null is ok, and/or size 0).
* @since 2.4
*/
- private static String replaceEach(
- final String text, final String[] searchList, final String[]
replacementList, final boolean repeat, final int timeToLive) {
+ private static String replaceEachOnce(final String text, final String[]
searchList, final String[] replacementList) {
// Performance note: This creates very few new objects (one major goal)
// let me know if there are performance requests, we can create a
harness to measure
@@ -6531,12 +6528,6 @@ private static String replaceEach(
return text;
}
- // if recursing, this shouldn't be less than 0
- if (timeToLive < 0) {
- throw new IllegalStateException("Aborting to protect against
StackOverflowError - " +
- "output of one loop is the input of another");
- }
-
final int searchLength = searchList.length;
final int replacementLength = replacementList.length;
@@ -6633,12 +6624,7 @@ private static String replaceEach(
for (int i = start; i < textLength; i++) {
buf.append(text.charAt(i));
}
- final String result = buf.toString();
- if (!repeat) {
- return result;
- }
-
- return replaceEach(result, searchList, replacementList, repeat,
timeToLive - 1);
+ return buf.toString();
}
/**
@@ -6672,8 +6658,20 @@ private static String replaceEach(
* @since 2.4
*/
public static String replaceEachRepeatedly(final String text, final
String[] searchList, final String[] replacementList) {
- final int timeToLive = Math.max(ArrayUtils.getLength(searchList),
DEFAULT_TTL);
- return replaceEach(text, searchList, replacementList, true,
timeToLive);
+ // The iteration budget is a fixed constant, deliberately independent
of the caller-supplied
+ // searchList length: deriving the budget from the input would let the
input size choose the
+ // recursion depth/amplification (formerly a real StackOverflowError
on large search lists).
+ String result = text;
+ for (int timeToLive = DEFAULT_TTL; timeToLive >= 0; timeToLive--) {
+ final String next = replaceEachOnce(result, searchList,
replacementList);
+ if (next == result) {
+ // No replacement was performed; converged.
+ return result;
+ }
+ result = next;
+ }
+ throw new IllegalStateException("Aborting to protect against
StackOverflowError - " +
+ "output of one loop is the input of another");
}
/**
diff --git a/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
b/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
index d4976574d..d07ebf9af 100644
--- a/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
@@ -1894,8 +1894,9 @@ void testReplace_StringStringArrayStringArrayBoolean() {
assertThrows(IllegalStateException.class,
() ->
StringUtils.replaceEachRepeatedly("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", new
String[] { "aa" }, new String[] { "a" }),
"Cannot be resolved within the default time-to-live limit");
- // Test larger TTL for larger search lists. Replace repeatedly until
there are no more possible replacements.
- assertEquals("000000000",
StringUtils.replaceEachRepeatedly("aA0aA0aA0",
+ // The iteration budget is a fixed constant (no longer derived from
the search-list size, which let the
+ // input choose the recursion depth): a 61-step replacement chain
exceeds the budget and aborts.
+ assertThrows(IllegalStateException.class, () ->
StringUtils.replaceEachRepeatedly("aA0aA0aA0",
new String[]{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
"k", "l", "m", "n",
"o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y",
"z", "A", "B", "C", "D",
"E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O",
"P", "Q", "R", "S", "T",
@@ -1903,7 +1904,8 @@ void testReplace_StringStringArrayStringArrayBoolean() {
new String[]{"b", "c", "d", "e", "f", "g", "h", "i", "j", "k",
"l", "m", "n", "o",
"p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
"A", "B", "C", "D", "E",
"F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P",
"Q", "R", "S", "T", "U",
- "V", "W", "X", "Y", "Z", "1", "2", "3", "4", "5", "6",
"7", "8", "9", "0"}));
+ "V", "W", "X", "Y", "Z", "1", "2", "3", "4", "5", "6",
"7", "8", "9", "0"}),
+ "Cannot be resolved within the fixed time-to-live limit");
// Test long infinite cycle: a -> b -> ... -> 9 -> 0 -> a -> b -> ...
assertThrows(IllegalStateException.class,