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 c79a10240 ClassUtils.getShortClassName(String) can throw
NoClassDefFoundError on malformed inner classes (#1665)
c79a10240 is described below
commit c79a10240bd7ddcb956c3f6853f956e0251fb745
Author: Gary Gregory <[email protected]>
AuthorDate: Wed May 20 06:36:48 2026 -0400
ClassUtils.getShortClassName(String) can throw NoClassDefFoundError on
malformed inner classes (#1665)
---
.../java/org/apache/commons/lang3/ClassUtils.java | 29 ++--
.../lang3/ClassUtilsGetShortClassNameTest.java | 153 +++++++++++++++++++++
2 files changed, 171 insertions(+), 11 deletions(-)
diff --git a/src/main/java/org/apache/commons/lang3/ClassUtils.java
b/src/main/java/org/apache/commons/lang3/ClassUtils.java
index ca9894b79..1e7a3b5e3 100644
--- a/src/main/java/org/apache/commons/lang3/ClassUtils.java
+++ b/src/main/java/org/apache/commons/lang3/ClassUtils.java
@@ -1027,18 +1027,25 @@ public static String getShortClassName(final Class<?>
cls) {
dim++;
c = c.getComponentType();
}
- final String base;
- // Preserve legacy behavior for anonymous/local classes (keeps
compiler ordinals: $13, $10Named, etc.)
- if (c.isAnonymousClass() || c.isLocalClass()) {
- base = getShortClassName(c.getName());
- } else {
- final Deque<String> parts = new ArrayDeque<>();
- Class<?> x = c;
- while (x != null) {
- parts.push(x.getSimpleName());
- x = x.getDeclaringClass();
+ String base;
+ // c.isAnonymousClass() / isLocalClass() and the getDeclaringClass()
chain
+ // can both throw NoClassDefFoundError when the enclosing class is
+ // missing from the classpath, so the try/catch wraps the whole block.
+ try {
+ // Preserve legacy behavior for anonymous/local classes (keeps
compiler ordinals: $13, $10Named, etc.)
+ if (c.isAnonymousClass() || c.isLocalClass()) {
+ base = getShortClassName(c.getName());
+ } else {
+ final Deque<String> parts = new ArrayDeque<>();
+ Class<?> x = c;
+ while (x != null) {
+ parts.push(x.getSimpleName());
+ x = x.getDeclaringClass();
+ }
+ base = String.join(".", parts);
}
- base = String.join(".", parts);
+ } catch (final NoClassDefFoundError ignored) {
+ base = getShortClassName(c.getName());
}
return base + StringUtils.repeat("[]", dim);
}
diff --git
a/src/test/java/org/apache/commons/lang3/ClassUtilsGetShortClassNameTest.java
b/src/test/java/org/apache/commons/lang3/ClassUtilsGetShortClassNameTest.java
new file mode 100644
index 000000000..fe943379e
--- /dev/null
+++
b/src/test/java/org/apache/commons/lang3/ClassUtilsGetShortClassNameTest.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.lang3;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.IOException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+import javax.tools.JavaCompiler;
+import javax.tools.ToolProvider;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * {@link ClassUtils#getShortClassName(Class)} can throw {@link
NoClassDefFoundError} when the supplied class is an inner class whose enclosing
(outer) class
+ * has been removed from the classpath.
+ *
+ * <p>
+ * The code path lives in the {@code while (x != null)} loop of {@code
getShortClassName(Class)}: it calls {@code x.getSimpleName()} and
+ * {@code x.getDeclaringClass()}. Both of those JDK methods resolve the {@code
InnerClasses} attribute of the inner class' bytecode, and when the enclosing
+ * class is missing the JVM throws {@link NoClassDefFoundError} from inside
{@code Class#getDeclaringClass0}/{@code getSimpleBinaryName}.
+ * </p>
+ * <p>
+ * The test compiles two classes ({@code F030Outer} and {@code
F030Outer$Inner}) with the in-process {@code javax.tools.JavaCompiler}, deletes
the outer class
+ * file, loads the inner class via a fresh {@link URLClassLoader}, and then
invokes {@link ClassUtils#getShortClassName(Class)}.
+ * </p>
+ *
+ * <p>
+ * At baseline (commit {@code 8538458e7}) the call propagates {@link
NoClassDefFoundError}. After the fix the {@code if/else} block around
+ * {@code isAnonymousClass}/{@code isLocalClass} is wrapped in a {@code try /
catch (NoClassDefFoundError)} that falls back to
+ * {@link ClassUtils#getShortClassName(String)} on the binary name.
+ * </p>
+ */
+public class ClassUtilsGetShortClassNameTest {
+
+ // @formatter:off
+ private static final String OUTER_SRC = ""
+ + "package f030;\n"
+ + "public class F030Outer {\n"
+ + " public static class Inner {\n"
+ + " public int value;\n"
+ + " }\n"
+ + "}\n";
+ // @formatter:on
+
+ /**
+ * Compiles {@code f030.F030Outer} (and its nested {@code Inner}) into the
supplied directory using the in-process Java compiler.
+ *
+ * @return {@code true} on success, {@code false} if the compiler is not
available (running on a JRE rather than a JDK).
+ */
+ private static boolean compileOuterWithInner(final Path classesDir) throws
IOException {
+ final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+ assertNotNull("JDK compiler missing");
+ final Path srcDir =
classesDir.resolve("..").resolve("src").normalize();
+ Files.createDirectories(srcDir);
+ final Path outerJava = srcDir.resolve("F030Outer.java");
+ Files.write(outerJava, OUTER_SRC.getBytes(StandardCharsets.UTF_8));
+ Files.createDirectories(classesDir);
+ return compiler.run(null, null, null, "-d", classesDir.toString(),
outerJava.toString()) == 0;
+ }
+
+ /**
+ * Creates a child {@link URLClassLoader} that can resolve only the inner
class file, the outer class file is deliberately omitted from the directory it
+ * points at, simulating a torn deployment / shaded JAR.
+ */
+ private static URLClassLoader innerOnlyLoader(final Path innerOnlyDir)
throws IOException {
+ final URL url = innerOnlyDir.toUri().toURL();
+ return new URLClassLoader(new URL[] { url },
ClassLoader.getSystemClassLoader().getParent());
+ }
+
+ /**
+ * Anonymous and local classes follow a separate code path that calls
{@code getShortClassName(c.getName())} directly. The compiler-generated ordinal
(fpr
+ * example, {@code $1}) is preserved by the legacy contract, so the short
name ends with {@code ".<digits>"}.
+ */
+ @Test
+ public void testAnonymousAndLocalClassesUseSeparatePath() {
+ final Runnable anon = new Runnable() {
+
+ @Override
+ public void run() {
+ /* no-op */
+ }
+ };
+ final String shortName = ClassUtils.getShortClassName(anon.getClass());
+ assertNotNull(shortName);
+ assertTrue(shortName.matches(".*\\.[0-9].*"), "Anonymous class short
name should preserve the compiler ordinal: " + shortName);
+ }
+
+ @Test
+ public void testGetShortClassNameDoesNotThrowOnNormalClasses() {
+ assertEquals("String", ClassUtils.getShortClassName(String.class));
+ assertEquals("Map.Entry",
ClassUtils.getShortClassName(Map.Entry.class));
+ assertEquals("int[]", ClassUtils.getShortClassName(int[].class));
+ }
+
+ @Test
+ public void testGetShortClassNameOnInnerClassWithMissingOuter(@TempDir
final Path tempDir) throws Exception {
+ final Path classesDir = tempDir.resolve("classes");
+ assumeTrue(compileOuterWithInner(classesDir));
+ final Path outerClass =
classesDir.resolve("f030").resolve("F030Outer.class");
+ final Path innerClass =
classesDir.resolve("f030").resolve("F030Outer$Inner.class");
+ assumeTrue(Files.exists(outerClass) && Files.exists(innerClass),
"Expected compiled class files to exist");
+ final Path innerOnly = tempDir.resolve("inner-only");
+ Files.createDirectories(innerOnly.resolve("f030"));
+ Files.copy(innerClass,
innerOnly.resolve("f030").resolve("F030Outer$Inner.class"));
+ try (URLClassLoader cl = innerOnlyLoader(innerOnly)) {
+ final Class<?> inner = Class.forName("f030.F030Outer$Inner",
false, cl);
+ assertEquals("f030.F030Outer$Inner", inner.getName());
+ // Sanity: at the JDK layer, both getSimpleName and
getDeclaringClass
+ // throw NoClassDefFoundError for this inner class on the current
JVM.
+ assertThrows(NoClassDefFoundError.class, inner::getSimpleName);
+ assertThrows(NoClassDefFoundError.class, inner::getDeclaringClass);
+ // Post-fix: ClassUtils.getShortClassName(Class) catches
NoClassDefFoundError
+ // thrown anywhere inside the if (isAnonymous || isLocal){...}
else {...} block
+ // and falls back to string-parsing the binary name
"f030.F030Outer$Inner"
+ // which yields "F030Outer.Inner" (per the documented contract of
+ // getShortClassName(String): the '$' separator is replaced with
'.').
+ assertEquals("F030Outer.Inner",
ClassUtils.getShortClassName(inner));
+ }
+ }
+
+ @Test
+ public void testGetShortClassNameReturnsNonNullForStandardClasses() {
+ assertNotNull(ClassUtils.getShortClassName(String.class));
+ assertNotNull(ClassUtils.getShortClassName(Integer.class));
+ }
+}