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 99a29e5fc ExceptionUtils.getThrowableList() cycle check is O(n^2) over 
deep cause chains; all chain-walking consumers inherit it (f031).
99a29e5fc is described below

commit 99a29e5fceadc058ccae92a838f72da0501c10ff
Author: Gary Gregory <[email protected]>
AuthorDate: Sun Sep 6 15:38:59 2026 -0400

    ExceptionUtils.getThrowableList() cycle check is O(n^2) over deep cause
    chains; all chain-walking consumers inherit it (f031).
---
 src/changes/changes.xml                            |  1 +
 .../commons/lang3/exception/ExceptionUtils.java    |  7 +-
 .../lang3/exception/ExceptionUtilsTest.java        | 93 ++++++++++++++++++++++
 3 files changed, 99 insertions(+), 2 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index be119519d..710f73ca3 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -277,6 +277,7 @@ java.lang.NullPointerException: Cannot invoke
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">Attacker-controlled exception message forges frames in 
getRootCauseStackTrace output AND suppresses all real frames, beyond cosmetic 
log spoofing (f025).</action>
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">StrBuilder.indexOf materializes the whole builder as a String per 
call; deleteAll/replaceAll multiply it into ~750 GB churn on a 1 MB builder 
(f026).</action>
     <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">ThresholdCircuitBreaker accepts negative increments and overflows its 
accumulator, silently keeping the breaker closed (f027).</action>
+    <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">ExceptionUtils.getThrowableList() cycle check is O(n^2) over deep 
cause chains; all chain-walking consumers inherit it (f031).</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/exception/ExceptionUtils.java 
b/src/main/java/org/apache/commons/lang3/exception/ExceptionUtils.java
index 064f951b9..237ffbfda 100644
--- a/src/main/java/org/apache/commons/lang3/exception/ExceptionUtils.java
+++ b/src/main/java/org/apache/commons/lang3/exception/ExceptionUtils.java
@@ -23,8 +23,10 @@
 import java.lang.reflect.UndeclaredThrowableException;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Objects;
+import java.util.Set;
 import java.util.StringTokenizer;
 import java.util.function.Consumer;
 import java.util.stream.Stream;
@@ -521,7 +523,7 @@ public static int getThrowableCount(final Throwable 
throwable) {
      * <p>This method handles recursive cause chains that might
      * otherwise cause infinite loops. The cause chain is processed until
      * the end, or until the next item in the chain is already
-     * in the result list.</p>
+     * in the result list, compared by identity.</p>
      *
      * @param throwable  The throwable to inspect, may be null.
      * @return The list of throwables, never null.
@@ -529,7 +531,8 @@ public static int getThrowableCount(final Throwable 
throwable) {
      */
     public static List<Throwable> getThrowableList(Throwable throwable) {
         final List<Throwable> list = new ArrayList<>();
-        while (throwable != null && !list.contains(throwable)) {
+        final Set<Throwable> seen = Collections.newSetFromMap(new 
IdentityHashMap<>());
+        while (throwable != null && seen.add(throwable)) {
             list.add(throwable);
             throwable = throwable.getCause();
         }
diff --git 
a/src/test/java/org/apache/commons/lang3/exception/ExceptionUtilsTest.java 
b/src/test/java/org/apache/commons/lang3/exception/ExceptionUtilsTest.java
index 47626fcf6..6668b6856 100644
--- a/src/test/java/org/apache/commons/lang3/exception/ExceptionUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/exception/ExceptionUtilsTest.java
@@ -51,6 +51,46 @@
  */
 class ExceptionUtilsTest extends AbstractLangTest {
 
+    private static final class CountingException extends Exception {
+        private static final long serialVersionUID = 1L;
+
+        private int causeCalls;
+
+        CountingException(final Throwable cause) {
+            super(null, cause, false, false);
+        }
+
+        @Override
+        public boolean equals(final Object obj) {
+            throw new AssertionError("Chain walking must not invoke equals");
+        }
+
+        @Override
+        public synchronized Throwable getCause() {
+            causeCalls++;
+            return super.getCause();
+        }
+
+        @Override
+        public int hashCode() {
+            throw new AssertionError("Chain walking must not invoke hashCode");
+        }
+    }
+
+    private static final class EqualException extends Exception {
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        public boolean equals(final Object obj) {
+            return obj instanceof EqualException;
+        }
+
+        @Override
+        public int hashCode() {
+            return 1;
+        }
+    }
+
     /**
      * Provides a method with a well known chained/nested exception
      * name which matches the full signature (e.g. has a return value
@@ -525,6 +565,59 @@ void testGetThrowableCount_Throwable() {
         assertEquals(3, ExceptionUtils.getThrowableCount(cyclicCause));
     }
 
+    @Test
+    void testGetThrowableListDeepChain() {
+        final CountingException[] chain = new CountingException[10_000];
+        for (int i = chain.length - 1; i >= 0; i--) {
+            chain[i] = new CountingException(i + 1 < chain.length ? chain[i + 
1] : null);
+        }
+        final List<Throwable> throwables = 
ExceptionUtils.getThrowableList(chain[0]);
+        assertEquals(chain.length, throwables.size());
+        for (int i = 0; i < chain.length; i++) {
+            assertSame(chain[i], throwables.get(i));
+            assertEquals(1, chain[i].causeCalls);
+        }
+        assertEquals(chain.length, ExceptionUtils.getThrowableCount(chain[0]));
+        assertSame(chain[chain.length - 1], 
ExceptionUtils.getRootCause(chain[0]));
+        final Throwable[] array = ExceptionUtils.getThrowables(chain[0]);
+        final Throwable[] stream = 
ExceptionUtils.stream(chain[0]).toArray(Throwable[]::new);
+        assertEquals(chain.length, array.length);
+        assertEquals(chain.length, stream.length);
+        for (int i = 0; i < chain.length; i++) {
+            assertSame(chain[i], array[i]);
+            assertSame(chain[i], stream[i]);
+        }
+    }
+
+    @Test
+    void testGetThrowableListEqualExceptions() {
+        final EqualException first = new EqualException();
+        final EqualException second = new EqualException();
+        final EqualException third = new EqualException();
+        first.initCause(second);
+        second.initCause(third);
+        final List<Throwable> throwables = 
ExceptionUtils.getThrowableList(first);
+        assertEquals(3, throwables.size());
+        assertSame(first, throwables.get(0));
+        assertSame(second, throwables.get(1));
+        assertSame(third, throwables.get(2));
+        third.initCause(second);
+        final List<Throwable> cyclic = ExceptionUtils.getThrowableList(first);
+        assertEquals(3, cyclic.size());
+        assertSame(first, cyclic.get(0));
+        assertSame(second, cyclic.get(1));
+        assertSame(third, cyclic.get(2));
+    }
+
+    @Test
+    void testGetThrowableListSelfCause() {
+        final ExceptionWithCause exception = new ExceptionWithCause(null);
+        exception.setCause(exception);
+        final List<Throwable> throwables = 
ExceptionUtils.getThrowableList(exception);
+        assertEquals(1, throwables.size());
+        assertSame(exception, throwables.get(0));
+    }
+
     @Test
     void testGetThrowableList_Throwable_jdkNoCause() {
         final List<?> throwables = ExceptionUtils.getThrowableList(jdkNoCause);

Reply via email to