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