1 /*
2 * Licensed to the Apache Software Foundation (ASF) under one or more
3 * contributor license agreements. See the NOTICE file distributed with
4 * this work for additional information regarding copyright ownership.
5 * The ASF licenses this file to You under the Apache License, Version 2.0
6 * (the "License"); you may not use this file except in compliance with
7 * the License. You may obtain a copy of the License at
8 *
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18 package org.apache.commons.lang3;
19
20 import static org.junit.jupiter.api.Assertions.assertEquals;
21 import static org.junit.jupiter.api.Assertions.assertNotNull;
22 import static org.junit.jupiter.api.Assertions.assertThrows;
23 import static org.junit.jupiter.api.Assertions.assertTrue;
24 import static org.junit.jupiter.api.Assumptions.assumeTrue;
25
26 import java.io.IOException;
27 import java.net.URL;
28 import java.net.URLClassLoader;
29 import java.nio.charset.StandardCharsets;
30 import java.nio.file.Files;
31 import java.nio.file.Path;
32 import java.util.Map;
33
34 import javax.tools.JavaCompiler;
35 import javax.tools.ToolProvider;
36
37 import org.junit.jupiter.api.Test;
38 import org.junit.jupiter.api.io.TempDir;
39
40 /**
41 * {@link ClassUtils#getShortClassName(Class)} can throw {@link NoClassDefFoundError} when the supplied class is an inner class whose enclosing (outer) class
42 * has been removed from the classpath.
43 *
44 * <p>
45 * The code path lives in the {@code while (x != null)} loop of {@code getShortClassName(Class)}: it calls {@code x.getSimpleName()} and
46 * {@code x.getDeclaringClass()}. Both of those JDK methods resolve the {@code InnerClasses} attribute of the inner class' bytecode, and when the enclosing
47 * class is missing the JVM throws {@link NoClassDefFoundError} from inside {@code Class#getDeclaringClass0}/{@code getSimpleBinaryName}.
48 * </p>
49 * <p>
50 * The test compiles two classes ({@code F030Outer} and {@code F030Outer$Inner}) with the in-process {@code javax.tools.JavaCompiler}, deletes the outer class
51 * file, loads the inner class via a fresh {@link URLClassLoader}, and then invokes {@link ClassUtils#getShortClassName(Class)}.
52 * </p>
53 *
54 * <p>
55 * At baseline (commit {@code 8538458e7}) the call propagates {@link NoClassDefFoundError}. After the fix the {@code if/else} block around
56 * {@code isAnonymousClass}/{@code isLocalClass} is wrapped in a {@code try / catch (NoClassDefFoundError)} that falls back to
57 * {@link ClassUtils#getShortClassName(String)} on the binary name.
58 * </p>
59 */
60 public class ClassUtilsGetShortClassNameTest {
61
62 // @formatter:off
63 private static final String OUTER_SRC = ""
64 + "package f030;\n"
65 + "public class F030Outer {\n"
66 + " public static class Inner {\n"
67 + " public int value;\n"
68 + " }\n"
69 + "}\n";
70 // @formatter:on
71
72 /**
73 * Compiles {@code f030.F030Outer} (and its nested {@code Inner}) into the supplied directory using the in-process Java compiler.
74 *
75 * @return {@code true} on success, {@code false} if the compiler is not available (running on a JRE rather than a JDK).
76 */
77 private static boolean compileOuterWithInner(final Path classesDir) throws IOException {
78 final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
79 assertNotNull("JDK compiler missing");
80 final Path srcDir = classesDir.resolve("..").resolve("src").normalize();
81 Files.createDirectories(srcDir);
82 final Path outerJava = srcDir.resolve("F030Outer.java");
83 Files.write(outerJava, OUTER_SRC.getBytes(StandardCharsets.UTF_8));
84 Files.createDirectories(classesDir);
85 return compiler.run(null, null, null, "-d", classesDir.toString(), outerJava.toString()) == 0;
86 }
87
88 /**
89 * Creates a child {@link URLClassLoader} that can resolve only the inner class file, the outer class file is deliberately omitted from the directory it
90 * points at, simulating a torn deployment / shaded JAR.
91 */
92 private static URLClassLoader innerOnlyLoader(final Path innerOnlyDir) throws IOException {
93 final URL url = innerOnlyDir.toUri().toURL();
94 return new URLClassLoader(new URL[] { url }, ClassLoader.getSystemClassLoader().getParent());
95 }
96
97 /**
98 * Anonymous and local classes follow a separate code path that calls {@code getShortClassName(c.getName())} directly. The compiler-generated ordinal (for
99 * example, {@code $1}) is preserved by the legacy contract, so the short name ends with {@code ".<digits>"}.
100 */
101 @Test
102 public void testAnonymousAndLocalClassesUseSeparatePath() {
103 final Runnable anon = new Runnable() {
104
105 @Override
106 public void run() {
107 /* no-op */
108 }
109 };
110 final String shortName = ClassUtils.getShortClassName(anon.getClass());
111 assertNotNull(shortName);
112 assertTrue(shortName.matches(".*\\.[0-9].*"), "Anonymous class short name should preserve the compiler ordinal: " + shortName);
113 }
114
115 @Test
116 public void testGetShortClassNameDoesNotThrowOnNormalClasses() {
117 assertEquals("String", ClassUtils.getShortClassName(String.class));
118 assertEquals("Map.Entry", ClassUtils.getShortClassName(Map.Entry.class));
119 assertEquals("int[]", ClassUtils.getShortClassName(int[].class));
120 }
121
122 @Test
123 public void testGetShortClassNameOnInnerClassWithMissingOuter(@TempDir final Path tempDir) throws Exception {
124 final Path classesDir = tempDir.resolve("classes");
125 assumeTrue(compileOuterWithInner(classesDir));
126 final Path outerClass = classesDir.resolve("f030").resolve("F030Outer.class");
127 final Path innerClass = classesDir.resolve("f030").resolve("F030Outer$Inner.class");
128 assumeTrue(Files.exists(outerClass) && Files.exists(innerClass), "Expected compiled class files to exist");
129 final Path innerOnly = tempDir.resolve("inner-only");
130 Files.createDirectories(innerOnly.resolve("f030"));
131 Files.copy(innerClass, innerOnly.resolve("f030").resolve("F030Outer$Inner.class"));
132 try (URLClassLoader cl = innerOnlyLoader(innerOnly)) {
133 final Class<?> inner = Class.forName("f030.F030Outer$Inner", false, cl);
134 assertEquals("f030.F030Outer$Inner", inner.getName());
135 // Sanity: at the JDK layer, both getSimpleName and getDeclaringClass
136 // throw NoClassDefFoundError for this inner class on the current JVM.
137 assertThrows(NoClassDefFoundError.class, inner::getSimpleName);
138 assertThrows(NoClassDefFoundError.class, inner::getDeclaringClass);
139 // Post-fix: ClassUtils.getShortClassName(Class) catches NoClassDefFoundError
140 // thrown anywhere inside the if (isAnonymous || isLocal){...} else {...} block
141 // and falls back to string-parsing the binary name "f030.F030Outer$Inner"
142 // which yields "F030Outer.Inner" (per the documented contract of
143 // getShortClassName(String): the '$' separator is replaced with '.').
144 assertEquals("F030Outer.Inner", ClassUtils.getShortClassName(inner));
145 }
146 }
147
148 @Test
149 public void testGetShortClassNameReturnsNonNullForStandardClasses() {
150 assertNotNull(ClassUtils.getShortClassName(String.class));
151 assertNotNull(ClassUtils.getShortClassName(Integer.class));
152 }
153 }