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,

Reply via email to