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 ac026ed6e Use structured stack frames for compact root cause traces
ac026ed6e is described below
commit ac026ed6e8892ae7ad93514cdbb47b8c2e3ad799
Author: Gary Gregory <[email protected]>
AuthorDate: Sun Oct 25 09:35:31 2026 -0400
Use structured stack frames for compact root cause traces
Use StackTraceElement objects to build compact root cause stack traces,
avoiding text parsing and preventing exception message text from being
mistaken for stack frames.
Rewrite ExceptionUtils.getRootCauseStackTraceList(Throwable) to read
StackTraceElement arrays directly from Throwable.getStackTrace().
Compare
elements to remove each cause's shared trailing frames, then format only
the retained frames.
This avoids repeatedly printing and parsing complete exception traces.
Frame-shaped exception message text can no longer be mistaken for frames
or suppress real frames. Structured comparisons also preserve distinct
frames whose string representations happen to match.
Preserve root-cause-first ordering, wrapped exception markers, and all
public and protected signatures. Exception messages remain in headers,
but custom printStackTrace() output is no longer used.
Remove the unused getStackFrameList(Throwable) parser and its tests.
Update the related Javadoc to describe structured frame extraction.
Main methods removed (private):
- ExceptionUtils.getStackFrameList(Throwable)
- ExceptionUtils.isStackFrame(String)
Test methods removed:
- ExceptionUtilsTest.testGetStackFrameListAcceptsAllRealFrameShapes()
- ExceptionUtilsTest.testGetStackFrameListRejectsForgedMessageLines()
---
src/changes/changes.xml | 1 +
.../commons/lang3/exception/ExceptionUtils.java | 129 ++++-----------------
.../lang3/exception/ExceptionUtilsTest.java | 128 ++++++++++----------
3 files changed, 87 insertions(+), 171 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 84a4f428a..f56b09564 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -74,6 +74,7 @@ The <action> type attribute can be add,update,fix,remove.
<action type="fix" dev="ggregory" due-to="alhuda, Gary
Gregory">Fix FastDateParser normalization of out-of-range ISO day-of-week
values (#1839).</action>
<action type="fix" dev="ggregory" due-to="Aminark, Gary
Gregory">[javadoc] Correct StringUtils example methods and overloads
(#1836).</action>
<action type="fix" dev="ggregory" due-to="alhuda, Gary
Gregory">Stop ExceptionUtils.removeCommonFrames at the first differing frame
(#1831).</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">Use StackTraceElement objects to build compact root cause stack
traces, avoiding text parsing and preventing exception message text from being
mistaken for stack frames.</action>
<!-- ADD -->
<!-- UPDATE -->
<action type="update" dev="ggregory" due-to="Gary Gregory">Bump
org.apache.commons:commons-parent from 105 to 106.</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 5eedb916a..76554439e 100644
--- a/src/main/java/org/apache/commons/lang3/exception/ExceptionUtils.java
+++ b/src/main/java/org/apache/commons/lang3/exception/ExceptionUtils.java
@@ -27,7 +27,6 @@
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;
@@ -357,11 +356,8 @@ public static String getRootCauseMessage(final Throwable
throwable) {
* </p>
*
* <p>
- * <strong>Note:</strong> the frames are recovered by re-parsing the text
produced by {@link Throwable#printStackTrace()}, they are not read from
- * {@link Throwable#getStackTrace()}. A line inside an exception
<em>message</em> that mimics a stack frame (leading whitespace, then {@code "at
"},
- * then a class/method reference with {@code '('}) is indistinguishable
from a real frame: untrusted message content can therefore inject fabricated
- * frames into this output and cause the real frames that follow to be
dropped. Do not treat this output as forensic evidence when exception messages
- * may contain untrusted input; read {@link Throwable#getStackTrace()} for
structured frames that cannot be forged by message content.
+ * Frames are read from {@link Throwable#getStackTrace()} and compared as
{@link StackTraceElement} objects before formatting. Exception messages
+ * remain part of the exception headers, but are not parsed as stack
frames. Custom {@code printStackTrace} output is not used.
* </p>
*
* @param throwable The throwable to examine, may be null.
@@ -381,11 +377,8 @@ public static String[] getRootCauseStackTrace(final
Throwable throwable) {
* </p>
*
* <p>
- * <strong>Note:</strong> the frames are recovered by re-parsing the text
produced by {@link Throwable#printStackTrace()}, they are not read from
- * {@link Throwable#getStackTrace()}. A line inside an exception
<em>message</em> that mimics a stack frame (leading whitespace, then {@code "at
"},
- * then a class/method reference with {@code '('}) is indistinguishable
from a real frame: untrusted message content can therefore inject fabricated
- * frames into this output and cause the real frames that follow to be
dropped. Do not treat this output as forensic evidence when exception messages
- * may contain untrusted input; read {@link Throwable#getStackTrace()} for
structured frames that cannot be forged by message content.
+ * Frames are read from {@link Throwable#getStackTrace()} and compared as
{@link StackTraceElement} objects before formatting. Exception messages
+ * remain part of the exception headers, but are not parsed as stack
frames. Custom {@code printStackTrace} output is not used.
* </p>
*
* @param throwable The throwable to examine, may be null.
@@ -399,51 +392,25 @@ public static List<String>
getRootCauseStackTraceList(final Throwable throwable)
final Throwable[] throwables = getThrowables(throwable);
final int count = throwables.length;
final List<String> frames = new ArrayList<>();
- List<String> nextTrace = getStackFrameList(throwables[count - 1]);
+ StackTraceElement[] nextTrace = throwables[count - 1].getStackTrace();
for (int i = count; --i >= 0;) {
- final List<String> trace = nextTrace;
+ final StackTraceElement[] trace = nextTrace;
+ int frameCount = trace.length;
if (i != 0) {
- nextTrace = getStackFrameList(throwables[i - 1]);
- removeCommonFrames(trace, nextTrace);
+ nextTrace = throwables[i - 1].getStackTrace();
+ int wrapperFrameIndex = nextTrace.length - 1;
+ while (frameCount > 0 && wrapperFrameIndex >= 0 &&
trace[frameCount - 1].equals(nextTrace[wrapperFrameIndex])) {
+ frameCount--;
+ wrapperFrameIndex--;
+ }
}
final String string = throwables[i].toString();
frames.add(i == count - 1 ? string : WRAPPED_MARKER + string);
- frames.addAll(trace);
- }
- return frames;
- }
-
- /**
- * Gets a {@link List} of stack frames, the message
- * is not included. Only the trace of the specified exception is
- * returned, any caused by trace is stripped.
- *
- * <p>
- * This works by re-parsing the text produced by {@link
Throwable#printStackTrace()}: a line is treated as a frame if, after leading
- * whitespace, it starts with {@code "at "} followed by a class/method
reference and {@code '('} (see {@link #isStackFrame(String)}). It
- * will mis-parse if the exception message contains a line of exactly that
shape: such a line is counted as a frame and the real frames
- * that follow the remaining message lines are dropped.
- * </p>
- *
- * @param throwable is any throwable.
- * @return List of stack frames.
- */
- static List<String> getStackFrameList(final Throwable throwable) {
- final String stackTrace = getStackTrace(throwable);
- final String linebreak = System.lineSeparator();
- final StringTokenizer frames = new StringTokenizer(stackTrace,
linebreak);
- final List<String> list = new ArrayList<>();
- boolean traceStarted = false;
- while (frames.hasMoreTokens()) {
- final String token = frames.nextToken();
- if (isStackFrame(token)) {
- traceStarted = true;
- list.add(token);
- } else if (traceStarted) {
- break;
+ for (int j = 0; j < frameCount; j++) {
+ frames.add("\tat " + trace[j]);
}
}
- return list;
+ return frames;
}
/**
@@ -732,49 +699,6 @@ public static boolean isChecked(final Throwable throwable)
{
return throwable != null && !(throwable instanceof Error) &&
!(throwable instanceof RuntimeException);
}
- /**
- * Tests whether a line from {@link #getStackTrace(Throwable)} output
looks like a stack frame, mirroring the syntax emitted by
- * {@link Throwable#printStackTrace()}: leading whitespace, then {@code
"at "}, then a class/method reference containing no whitespace,
- * then {@code '('}, for example {@code "\tat
com.example.Foo.bar(Foo.java:42)"}. The reference is matched as any non-empty
run of
- * non-whitespace characters, because {@link StackTraceElement#toString()}
never emits whitespace before the opening parenthesis: this
- * accepts classic frames as well as class loader or module prefixes
({@code "app//"}, {@code "java.base/"}), module versions
- * ({@code "[email protected]/"}), lambda and hidden-class names ({@code
"$$Lambda$17/0x..."}), {@code <init>}/{@code <clinit>} and
- * JVM-language name mangling, without maintaining a character whitelist
that could reject a legitimate frame (and thereby suppress
- * it and every frame below it).
- *
- * <p>
- * This is deliberately stricter than matching any line whose first
non-whitespace characters are {@code "at"}, so that ordinary
- * message text such as {@code " attack detected"} or {@code "at your
request"} is not mistaken for a frame; a message line crafted to
- * match the full frame syntax is still indistinguishable from a real
frame.
- * </p>
- *
- * @param token one line of printed stack trace text.
- * @return whether the line has the syntax of a printed stack frame.
- */
- private static boolean isStackFrame(final String token) {
- int i = 0;
- final int len = token.length();
- while (i < len && Character.isWhitespace(token.charAt(i))) {
- i++;
- }
- // Frames printed by Throwable are indented: require leading
whitespace, then "at ".
- if (i == 0 || !token.startsWith("at ", i)) {
- return false;
- }
- i += 3;
- final int paren = token.indexOf('(', i);
- if (paren <= i) {
- return false;
- }
- // StackTraceElement.toString() never emits whitespace between "at "
and '(': any whitespace there means message text.
- for (int j = i; j < paren; j++) {
- if (Character.isWhitespace(token.charAt(j))) {
- return false;
- }
- }
- return true;
- }
-
/**
* Tests whether a throwable represents an unchecked exception.
*
@@ -805,11 +729,8 @@ public static boolean isUnchecked(final Throwable
throwable) {
* </p>
*
* <p>
- * <strong>Note:</strong> the frames are recovered by re-parsing the text
produced by {@link Throwable#printStackTrace()}, they are not read from
- * {@link Throwable#getStackTrace()}. A line inside an exception
<em>message</em> that mimics a stack frame (leading whitespace, then {@code "at
"},
- * then a class/method reference with {@code '('}) is indistinguishable
from a real frame: untrusted message content can therefore inject fabricated
- * frames into this output and cause the real frames that follow to be
dropped. Do not treat this output as forensic evidence when exception messages
- * may contain untrusted input; read {@link Throwable#getStackTrace()} for
structured frames that cannot be forged by message content.
+ * Frames are read from {@link Throwable#getStackTrace()} and compared as
{@link StackTraceElement} objects before formatting. Exception messages
+ * remain part of the exception headers, but are not parsed as stack
frames. Custom {@code printStackTrace} output is not used.
* </p>
*
* @param throwable The throwable to output.
@@ -840,11 +761,8 @@ public static void printRootCauseStackTrace(final
Throwable throwable) {
* </p>
*
* <p>
- * <strong>Note:</strong> the frames are recovered by re-parsing the text
produced by {@link Throwable#printStackTrace()}, they are not read from
- * {@link Throwable#getStackTrace()}. A line inside an exception
<em>message</em> that mimics a stack frame (leading whitespace, then {@code "at
"},
- * then a class/method reference with {@code '('}) is indistinguishable
from a real frame: untrusted message content can therefore inject fabricated
- * frames into this output and cause the real frames that follow to be
dropped. Do not treat this output as forensic evidence when exception messages
- * may contain untrusted input; read {@link Throwable#getStackTrace()} for
structured frames that cannot be forged by message content.
+ * Frames are read from {@link Throwable#getStackTrace()} and compared as
{@link StackTraceElement} objects before formatting. Exception messages
+ * remain part of the exception headers, but are not parsed as stack
frames. Custom {@code printStackTrace} output is not used.
* </p>
*
* @param throwable The throwable to output, may be null.
@@ -883,11 +801,8 @@ public static void printRootCauseStackTrace(final
Throwable throwable, final Pri
* </p>
*
* <p>
- * <strong>Note:</strong> the frames are recovered by re-parsing the text
produced by {@link Throwable#printStackTrace()}, they are not read from
- * {@link Throwable#getStackTrace()}. A line inside an exception
<em>message</em> that mimics a stack frame (leading whitespace, then {@code "at
"},
- * then a class/method reference with {@code '('}) is indistinguishable
from a real frame: untrusted message content can therefore inject fabricated
- * frames into this output and cause the real frames that follow to be
dropped. Do not treat this output as forensic evidence when exception messages
- * may contain untrusted input; read {@link Throwable#getStackTrace()} for
structured frames that cannot be forged by message content.
+ * Frames are read from {@link Throwable#getStackTrace()} and compared as
{@link StackTraceElement} objects before formatting. Exception messages
+ * remain part of the exception headers, but are not parsed as stack
frames. Custom {@code printStackTrace} output is not used.
* </p>
*
* @param throwable The throwable to output, may be null.
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 12d9dbf26..4a210a708 100644
--- a/src/test/java/org/apache/commons/lang3/exception/ExceptionUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/exception/ExceptionUtilsTest.java
@@ -20,6 +20,7 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -411,6 +412,69 @@ void testGetRootCauseStackTrace_Throwable() {
assertFalse(match);
}
+ @Test
+ void testGetRootCauseStackTraceListComparesElements() {
+ final StackTraceElement causeFrame = new StackTraceElement("Type",
"call", "Type.java", -1);
+ final StackTraceElement wrapperFrame = new StackTraceElement("Type",
"call", "Type.java", -3);
+ assertEquals(causeFrame.toString(), wrapperFrame.toString());
+ assertNotEquals(causeFrame, wrapperFrame);
+ final Throwable cause = new Throwable("cause");
+ final Throwable wrapper = new Throwable("wrapper", cause);
+ cause.setStackTrace(new StackTraceElement[] {causeFrame});
+ wrapper.setStackTrace(new StackTraceElement[] {wrapperFrame});
+ assertEquals(Arrays.asList(cause.toString(), "\tat " + causeFrame,
ExceptionUtils.WRAPPED_MARKER + wrapper, "\tat " + wrapperFrame),
+ ExceptionUtils.getRootCauseStackTraceList(wrapper));
+ }
+
+ @Test
+ void testGetRootCauseStackTraceListEmptyFrames() {
+ final Throwable cause = new Throwable("cause");
+ final Throwable wrapper = new Throwable("wrapper", cause);
+ cause.setStackTrace(new StackTraceElement[0]);
+ wrapper.setStackTrace(new StackTraceElement[0]);
+ assertEquals(Arrays.asList(cause.toString(),
ExceptionUtils.WRAPPED_MARKER + wrapper),
+ ExceptionUtils.getRootCauseStackTraceList(wrapper));
+ }
+
+ @Test
+ void testGetRootCauseStackTraceListFrameShapedMessage() {
+ final Throwable throwable = new Throwable("denied\n\tat
forged.Type.call(Type.java:42)\nmore text");
+ final StackTraceElement frame = new StackTraceElement("real.Type",
"call", "Type.java", 7);
+ throwable.setStackTrace(new StackTraceElement[] {frame});
+ assertEquals(Arrays.asList(throwable.toString(), "\tat " + frame),
ExceptionUtils.getRootCauseStackTraceList(throwable));
+ }
+
+ @Test
+ void testGetRootCauseStackTraceListIgnoresCustomPrintStackTrace() {
+ final Throwable throwable = new Throwable("message") {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void printStackTrace(final PrintWriter writer) {
+ throw new AssertionError("Must use structured stack frames");
+ }
+ };
+ final StackTraceElement frame = new StackTraceElement("real.Type",
"call", null, -2);
+ throwable.setStackTrace(new StackTraceElement[] {frame});
+ assertEquals(Arrays.asList(throwable.toString(), "\tat " + frame),
ExceptionUtils.getRootCauseStackTraceList(throwable));
+ }
+
+ @Test
+ void testGetRootCauseStackTraceListSharedSuffix() {
+ final StackTraceElement shared = new StackTraceElement("Shared",
"call", "Shared.java", 1);
+ final StackTraceElement causeOnly = new StackTraceElement("Cause",
"call", "Cause.java", 2);
+ final StackTraceElement wrapperOnly = new StackTraceElement("Wrapper",
"call", "Wrapper.java", 3);
+ final Throwable cause = new Throwable("cause");
+ final Throwable wrapper = new Throwable("wrapper", cause);
+ cause.setStackTrace(new StackTraceElement[] {shared, causeOnly,
shared});
+ wrapper.setStackTrace(new StackTraceElement[] {shared, wrapperOnly,
shared});
+ assertEquals(Arrays.asList(cause.toString(), "\tat " + shared, "\tat "
+ causeOnly,
+ ExceptionUtils.WRAPPED_MARKER + wrapper, "\tat " + shared,
"\tat " + wrapperOnly, "\tat " + shared),
+ ExceptionUtils.getRootCauseStackTraceList(wrapper));
+ assertEquals(3, cause.getStackTrace().length);
+ assertEquals(3, wrapper.getStackTrace().length);
+ }
+
@Test
void testGetRootCauseStackTraceList_Throwable() {
assertEquals(0,
ExceptionUtils.getRootCauseStackTraceList(null).size());
@@ -462,70 +526,6 @@ void
testGetRootCauseStackTraceMessageLinesNotMistakenForFrames() {
assertTrue(sawRealFrame, "real frames were suppressed");
}
- /**
- * Tests that every frame shape {@code StackTraceElement.toString()} can
emit is accepted by the tightened frame matcher,
- * in particular JDK 9+ module-versioned frames ({@code
mod@version/pkg.Class}), which a character whitelist without
- * {@code '@'} would reject — silently dropping that frame and every real
frame below it. Uses a throwable that prints a
- * fixed trace so the shapes are deterministic without constructing
module-versioned {@link StackTraceElement}s.
- */
- @Test
- void testGetStackFrameListAcceptsAllRealFrameShapes() {
- final String[] frames = {
- "\tat com.example.Foo.bar(Foo.java:42)",
// classic
- "\tat app//com.foo.Main.main(Main.java:10)",
// class loader prefix
- "\tat [email protected]/com.foo.Helper.help(Helper.java:7)",
// module name @ version
- "\tat java.base/java.lang.Thread.run(Thread.java:833)",
// module, no version
- "\tat com.foo.Main$$Lambda$17/0x0000000800c02a48.run(Unknown
Source)", // lambda / hidden class
- "\tat com.example.Foo.<init>(Foo.java:5)",
// constructor
- "\tat java.base/java.lang.Object.wait(Native Method)"
// native
- };
- final StringBuilder text = new
StringBuilder("java.lang.RuntimeException:
boom").append(System.lineSeparator());
- for (final String frame : frames) {
- text.append(frame).append(System.lineSeparator());
- }
- final Throwable fixed = new RuntimeException("boom") {
- private static final long serialVersionUID = 1L;
-
- @Override
- public void printStackTrace(final PrintWriter writer) {
- writer.print(text);
- }
- };
- final List<String> list = ExceptionUtils.getStackFrameList(fixed);
- assertEquals(Arrays.asList(frames), list, "a legitimate frame shape
was rejected (and frames below it dropped)");
- }
-
- /**
- * Tests that message text is still rejected by the frame matcher: forged
lines lacking the no-whitespace-before-'('
- * frame syntax must not start or extend the frame list.
- */
- @Test
- void testGetStackFrameListRejectsForgedMessageLines() {
- final String[] forged = {
- " attack detected", // "at" not
followed by space-delimited reference
- "at your request, more text", // no leading
whitespace
- "\tat your request, more text", // no '(' at all
- "\tat forged frame entry(Evil.java:1)", // whitespace
between "at " and '('
- "\tat (Evil.java:1)" // empty reference
- };
- final StringBuilder text = new
StringBuilder("java.lang.RuntimeException:
boom").append(System.lineSeparator());
- for (final String line : forged) {
- text.append(line).append(System.lineSeparator());
- }
- text.append("\tat
com.example.Foo.bar(Foo.java:42)").append(System.lineSeparator());
- final Throwable fixed = new RuntimeException("boom") {
- private static final long serialVersionUID = 1L;
-
- @Override
- public void printStackTrace(final PrintWriter writer) {
- writer.print(text);
- }
- };
- final List<String> list = ExceptionUtils.getStackFrameList(fixed);
- assertEquals(Arrays.asList("\tat com.example.Foo.bar(Foo.java:42)"),
list,
- "forged message text was classified as a stack frame");
- }
-
@Test
/** getStackFrames returns empty string array when the argument is null */
void testgetStackFramesHappyPath() {