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

Reply via email to