This is an automated email from the ASF dual-hosted git repository.

voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 7b4ad9d6c962 fix(common): scan jar entries in 
ReflectionUtils#getTopLevelClassesInClasspath (#19624)
7b4ad9d6c962 is described below

commit 7b4ad9d6c96284399bf9ed6d1ebdf4dccb9cc7e6
Author: deepakpanda93 <[email protected]>
AuthorDate: Wed Sep 9 21:25:11 2026 +0530

    fix(common): scan jar entries in 
ReflectionUtils#getTopLevelClassesInClasspath (#19624)
    
    Follow-up to #19784 (HUDI-736), which simplified the method but did
    not add jar-entry scanning.
    
    A jar: classpath entry is non-hierarchical, so toDirectory logs the
    IllegalArgumentException and drops it, and a scan run from inside a
    shaded jar returns nothing. Every caller is a packaging bundle Main
    class, which runs exactly that way; before #19784 the same case threw.
    Class#getPackage is also null for arrays and primitives, which was
    dereferenced without a check.
    
    classNamesIn now dispatches on protocol. A jar: entry is read through
    JarURLConnection with setUseCaches(false), so the scan opens its own
    JarFile instead of closing one shared through the JVM-wide cache, and
    the names are collected before the jar is closed because the returned
    stream outlives the method. The entry prefix stays derived from the
    package name: anchoring on getEntryName() would make the result JDK
    dependent on a multi-release jar (the loader resolves the package to
    META-INF/versions/N/<pkg>/ on JDK 9-23 but to <pkg>/ on 8 and 24+) and
    would drop classes that exist only in the base directory. A connection
    that is not a JarURLConnection is skipped, a class with no package
    yields an empty stream, and findClasses skips a directory whose
    listFiles() is null instead of throwing.
    
    Only hudi-cli-bundle ships slf4j, so the other bundle Main classes
    still fail in ReflectionUtils.<clinit> when run from the bare jar; the
    scan works wherever slf4j is on the classpath.
    
    TestReflectionUtils is 13 tests. Each guard is pinned by a test that
    fails without it: the jar dispatch by an empty result, the null-package
    guard and the listFiles guard by NullPointerException, the instanceof
    check by ClassCastException, and setUseCaches(false) by
    "IllegalStateException: zip file closed" on a JarFile another reader
    holds open through the cache.
---
 .../hudi/common/util/TestReflectionUtils.java      | 236 +++++++++++++++++++--
 .../apache/hudi/common/util/ReflectionUtils.java   |  92 +++++++-
 2 files changed, 307 insertions(+), 21 deletions(-)

diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/util/TestReflectionUtils.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/util/TestReflectionUtils.java
index 7640435ae4c5..be43ee3b2fa3 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/util/TestReflectionUtils.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/util/TestReflectionUtils.java
@@ -27,17 +27,34 @@ import org.apache.hudi.storage.StoragePath;
 import org.apache.hudi.storage.StoragePathFilter;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
+import java.io.File;
 import java.io.IOException;
+import java.net.JarURLConnection;
 import java.net.URL;
+import java.net.URLClassLoader;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Enumeration;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import java.util.stream.Collectors;
 
 import static org.apache.hudi.common.util.ReflectionUtils.getMethod;
+import static 
org.apache.hudi.common.util.ReflectionUtils.getTopLevelClassesInClasspath;
 import static org.apache.hudi.common.util.ReflectionUtils.isSubClass;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -67,36 +84,229 @@ public class TestReflectionUtils {
   }
 
   @ParameterizedTest
-  @ValueSource(strings = {"jar:file:/unused.jar!/org/apache/hudi/common/util", 
"file:/invalid path"})
+  @ValueSource(strings = {"jar:file:/unused.jar!/org/apache/hudi/common/util", 
"file:/invalid path",
+      "http://example.invalid/org/apache/hudi/common/util"})
   void testGetTopLevelClassesInClasspathSkipsInvalidResources(String 
invalidResource) {
-    ClassLoader original = Thread.currentThread().getContextClassLoader();
-    ClassLoader loader = new ClassLoader(original) {
+    ClassLoader loader = new 
ClassLoader(Thread.currentThread().getContextClassLoader()) {
       @Override
       public Enumeration<URL> getResources(String name) throws IOException {
         return Collections.enumeration(Arrays.asList(new URL(invalidResource), 
TestReflectionUtils.class.getResource("")));
       }
     };
-    try {
-      Thread.currentThread().setContextClassLoader(loader);
-      
assertTrue(ReflectionUtils.getTopLevelClassesInClasspath(TestReflectionUtils.class)
-          .anyMatch(TestReflectionUtils.class.getName()::equals));
-    } finally {
-      Thread.currentThread().setContextClassLoader(original);
-    }
+    assertTrue(withContextClassLoader(loader, () ->
+        
ReflectionUtils.getTopLevelClassesInClasspath(TestReflectionUtils.class)
+            .anyMatch(TestReflectionUtils.class.getName()::equals)));
   }
 
   @Test
   void testGetTopLevelClassesInClasspathHandlesIOException() {
-    ClassLoader original = Thread.currentThread().getContextClassLoader();
-    ClassLoader loader = new ClassLoader(original) {
+    ClassLoader loader = new 
ClassLoader(Thread.currentThread().getContextClassLoader()) {
       @Override
       public Enumeration<URL> getResources(String name) throws IOException {
         throw new IOException("Simulated failure enumerating resources");
       }
     };
+    assertFalse(withContextClassLoader(loader, () ->
+        
ReflectionUtils.getTopLevelClassesInClasspath(TestReflectionUtils.class).findAny().isPresent()));
+  }
+
+  /**
+   * An exploded directory on the classpath, reached over the "file" protocol. 
Built explicitly
+   * rather than scanning the test classpath, so the expected set is exact and 
independent of
+   * whatever else surefire puts on the classpath.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathFromDirectory(@TempDir Path tempDir) 
throws Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    Path root = tempDir.resolve("classes");
+    Path pkgDir = root.resolve(scanned.replace('.', File.separatorChar));
+    Files.createDirectories(pkgDir.resolve("nested"));
+    Files.write(pkgDir.resolve("Alpha.class"), new byte[] {1});
+    Files.write(pkgDir.resolve("Beta.class"), new byte[] {1});
+    Files.write(pkgDir.resolve("nested").resolve("Gamma.class"), new byte[] 
{1});
+    Files.write(pkgDir.resolve("notaclass.txt"), new byte[] {1});
+
+    List<String> classes = withContextClassLoaderOver(() ->
+        
getTopLevelClassesInClasspath(ReflectionUtils.class).collect(Collectors.toList()),
 root);
+
+    assertEquals(
+        Arrays.asList(scanned + ".Alpha", scanned + ".Beta", scanned + 
".nested.Gamma"),
+        classes.stream().sorted().collect(Collectors.toList()),
+        "a directory entry must yield the classes of the package and its 
subpackages, and nothing else");
+  }
+
+  /**
+   * Classes packaged in a jar are reached over the "jar" protocol, whose URLs 
are not hierarchical
+   * and so cannot be turned into a {@link File}. Every caller of this method 
is a bundle
+   * Main class, which is exactly the packaged case.
+   * <p>
+   * The jar is built under the scanned class's own package, and the loader is 
given no parent, so
+   * the only resource found for that package is the one written here.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathFromJar(@TempDir Path tempDir) throws 
Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    String dir = scanned.replace('.', '/') + "/";
+    Path jar = tempDir.resolve("classes.jar");
+    writeJar(jar,
+        dir,
+        dir + "Alpha.class",
+        dir + "Beta.class",
+        dir + "nested/",
+        dir + "nested/Gamma.class",
+        dir + "notaclass.txt",
+        "org/example/other/Delta.class");
+
+    List<String> classes = withContextClassLoaderOver(() ->
+        
getTopLevelClassesInClasspath(ReflectionUtils.class).collect(Collectors.toList()),
 jar);
+
+    assertEquals(
+        Arrays.asList(scanned + ".Alpha", scanned + ".Beta", scanned + 
".nested.Gamma"),
+        classes.stream().sorted().collect(Collectors.toList()),
+        "a jar entry must yield the classes of the package and its 
subpackages, and nothing else");
+  }
+
+  @Test
+  void testGetTopLevelClassesInClasspathForClassesWithoutAPackage() {
+    // Arrays and primitives have no package, which used to dereference null.
+    assertEquals(0, getTopLevelClassesInClasspath(String[].class).count());
+    assertEquals(0, getTopLevelClassesInClasspath(int.class).count());
+  }
+
+  /**
+   * A jar: URL whose stream handler returns a plain URLConnection rather than 
a JarURLConnection.
+   * Without the type check the cast throws ClassCastException, which the 
IOException catch does not
+   * cover, so a single such entry would fail the whole scan.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathSkipsAJarUrlThatIsNotAJarConnection() 
throws Exception {
+    URLStreamHandler plainHandler = new URLStreamHandler() {
+      @Override
+      protected URLConnection openConnection(URL url) {
+        return new URLConnection(url) {
+          @Override
+          public void connect() {
+            // Never connected: getTopLevelClassesInClasspath only inspects 
the connection's type.
+          }
+        };
+      }
+    };
+    URL notAJarConnection =
+        new URL(null, "jar:file:/unused.jar!/org/apache/hudi/common/util", 
plainHandler);
+    ClassLoader loader = new 
ClassLoader(Thread.currentThread().getContextClassLoader()) {
+      @Override
+      public Enumeration<URL> getResources(String name) {
+        return Collections.enumeration(Arrays.asList(notAJarConnection, 
TestReflectionUtils.class.getResource("")));
+      }
+    };
+
+    assertTrue(withContextClassLoader(loader, () ->
+            
ReflectionUtils.getTopLevelClassesInClasspath(TestReflectionUtils.class)
+                .anyMatch(TestReflectionUtils.class.getName()::equals)),
+        "an entry whose connection is not a JarURLConnection must be skipped, 
not fail the scan");
+  }
+
+  /**
+   * The scan must leave a jar it did not open alone. Without 
setUseCaches(false) it closes the
+   * JVM-wide cached JarFile, and the next read by whoever opened it first 
fails with
+   * "IllegalStateException: zip file closed".
+   */
+  @Test
+  void 
testGetTopLevelClassesInClasspathFromJarLeavesASharedJarFileUsable(@TempDir 
Path tempDir) throws Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    String dir = scanned.replace('.', '/') + "/";
+    Path jar = tempDir.resolve("shared.jar");
+    writeJar(jar, dir, dir + "Alpha.class");
+
+    // Open it through the shared cache first, the way another reader on the 
same JVM would.
+    URL entry = new URL("jar:" + jar.toUri().toURL() + "!/" + dir);
+    JarURLConnection shared = (JarURLConnection) entry.openConnection();
+    shared.setUseCaches(true);
+    JarFile sharedJar = shared.getJarFile();
+    try {
+      withContextClassLoaderOver(() -> 
getTopLevelClassesInClasspath(ReflectionUtils.class).count(), jar);
+      assertDoesNotThrow(() -> sharedJar.getEntry(dir + "Alpha.class"),
+          "the scan must not close a JarFile held open through the JVM-wide 
cache");
+    } finally {
+      sharedJar.close();
+    }
+  }
+
+  /**
+   * A package path that is a regular file rather than a directory. 
File#listFiles returns null
+   * there, which used to escape as a NullPointerException.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathWhenThePackagePathIsAFile(@TempDir 
Path tempDir) throws Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    Path root = tempDir.resolve("classes");
+    Path pkgPath = root.resolve(scanned.replace('.', File.separatorChar));
+    Files.createDirectories(pkgPath.getParent());
+    Files.write(pkgPath, new byte[] {1});
+
+    long count = withContextClassLoaderOver(() -> 
getTopLevelClassesInClasspath(ReflectionUtils.class).count(), root);
+    assertEquals(0, count, "an unreadable package entry must be skipped, not 
throw");
+  }
+
+  /**
+   * The package resolving to more than one classpath entry, which is the 
shape surefire produces.
+   * Every entry has to contribute, whichever protocol it uses.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathUnionsEveryClasspathEntry(@TempDir 
Path tempDir) throws Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    String dir = scanned.replace('.', '/') + "/";
+    Path jar = tempDir.resolve("classes.jar");
+    writeJar(jar, dir, dir + "FromJar.class");
+    Path root = tempDir.resolve("classes");
+    Path pkgDir = root.resolve(scanned.replace('.', File.separatorChar));
+    Files.createDirectories(pkgDir);
+    Files.write(pkgDir.resolve("FromDirectory.class"), new byte[] {1});
+
+    List<String> classes = withContextClassLoaderOver(() ->
+        
getTopLevelClassesInClasspath(ReflectionUtils.class).collect(Collectors.toList()),
 jar, root);
+
+    assertEquals(
+        Arrays.asList(scanned + ".FromDirectory", scanned + ".FromJar"),
+        classes.stream().sorted().collect(Collectors.toList()),
+        "a jar entry and a directory entry for the same package must both 
contribute");
+  }
+
+  /** Writes a jar holding the given entry names; names ending in "/" become 
directory entries. */
+  private static void writeJar(Path jar, String... entryNames) throws 
IOException {
+    try (JarOutputStream out = new 
JarOutputStream(Files.newOutputStream(jar))) {
+      for (String entryName : entryNames) {
+        out.putNextEntry(new JarEntry(entryName));
+        if (!entryName.endsWith("/")) {
+          // Content is irrelevant: the scan reads entry names, never the 
bytecode.
+          out.write(new byte[] {1, 2, 3});
+        }
+        out.closeEntry();
+      }
+    }
+  }
+
+  /**
+   * Runs the supplier with the thread context class loader reading only from 
the given classpath
+   * roots, each of which may be a jar or an exploded directory. The loader is 
given no parent so
+   * the scan sees nothing else.
+   */
+  private static <T> T withContextClassLoaderOver(Supplier<T> supplier, 
Path... roots) throws IOException {
+    URL[] urls = new URL[roots.length];
+    for (int i = 0; i < roots.length; i++) {
+      urls[i] = roots[i].toUri().toURL();
+    }
+    try (URLClassLoader loader = new URLClassLoader(urls, null)) {
+      return withContextClassLoader(loader, supplier);
+    }
+  }
+
+  /** Runs the supplier with the given thread context class loader, restoring 
the previous one after. */
+  private static <T> T withContextClassLoader(ClassLoader loader, Supplier<T> 
supplier) {
+    ClassLoader original = Thread.currentThread().getContextClassLoader();
     try {
       Thread.currentThread().setContextClassLoader(loader);
-      
assertFalse(ReflectionUtils.getTopLevelClassesInClasspath(TestReflectionUtils.class).findAny().isPresent());
+      return supplier.get();
     } finally {
       Thread.currentThread().setContextClassLoader(original);
     }
diff --git 
a/hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java 
b/hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java
index 819394207ba2..9b873c2420ea 100644
--- a/hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java
+++ b/hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java
@@ -26,15 +26,19 @@ import java.io.File;
 import java.io.IOException;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
+import java.net.JarURLConnection;
 import java.net.URISyntaxException;
 import java.net.URL;
+import java.net.URLConnection;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 /**
@@ -44,6 +48,7 @@ import java.util.stream.Stream;
 public class ReflectionUtils {
 
   private static final Map<String, Class<?>> CLAZZ_CACHE = new 
ConcurrentHashMap<>();
+  private static final String CLASS_FILE_SUFFIX = ".class";
 
   public static Class<?> getClass(String clazzName) {
     return CLAZZ_CACHE.computeIfAbsent(clazzName, c -> {
@@ -130,19 +135,83 @@ public class ReflectionUtils {
    */
   public static Stream<String> getTopLevelClassesInClasspath(Class<?> clazz) {
     ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
-    String packageName = clazz.getPackage().getName();
+    // Arrays and primitives have no package, and Class#getPackage is also 
null when the class was
+    // loaded by a loader that defines no package for it.
+    Package pkg = clazz.getPackage();
+    if (pkg == null) {
+      return Stream.empty();
+    }
+    String packageName = pkg.getName();
     String path = packageName.replace('.', '/');
     try {
       return Collections.list(classLoader.getResources(path)).stream()
-          .map(ReflectionUtils::toDirectory)
-          .filter(Objects::nonNull)
-          .flatMap(directory -> findClasses(directory, packageName).stream());
+          .flatMap(resource -> classNamesIn(resource, packageName));
     } catch (IOException e) {
       log.error("Unable to fetch Resources in package {}", packageName, e);
       return Stream.empty();
     }
   }
 
+  /**
+   * Class names under a single classpath entry for the package, whether that 
entry is an exploded
+   * directory or a jar.
+   *
+   * <p>A jar entry cannot go through {@link #toDirectory}: a {@code jar:} URL 
is non-hierarchical,
+   * so {@code new File(uri)} throws and the entry would be dropped. Every 
bundle {@code Main} class
+   * runs from inside a shaded jar, so that path has to be read through the 
jar connection instead.
+   *
+   * @param resource    a classpath entry holding the package
+   * @param packageName the package being scanned
+   * @return class names found under that entry, empty if it cannot be read
+   */
+  private static Stream<String> classNamesIn(URL resource, String packageName) 
{
+    if ("jar".equals(resource.getProtocol())) {
+      return classNamesInJar(resource, packageName);
+    }
+    File directory = toDirectory(resource);
+    return directory == null ? Stream.empty() : findClasses(directory, 
packageName).stream();
+  }
+
+  /**
+   * Class names under the package inside a jar, read through the jar 
connection.
+   *
+   * @param resource    a {@code jar:} classpath entry holding the package
+   * @param packageName the package being scanned
+   * @return class names found in that jar, empty if the jar cannot be read
+   */
+  private static Stream<String> classNamesInJar(URL resource, String 
packageName) {
+    try {
+      URLConnection connection = resource.openConnection();
+      if (!(connection instanceof JarURLConnection)) {
+        // A jar: URL served by a non-JDK stream handler. Skip it rather than 
let the cast throw,
+        // since this method exists to stop such an entry from failing the 
whole scan.
+        log.warn("Skipping classpath entry {}, {} is not a JarURLConnection", 
resource, connection.getClass());
+        return Stream.empty();
+      }
+      JarURLConnection jarConnection = (JarURLConnection) connection;
+      // Without this the JarFile is cached and shared JVM-wide, and closing 
it below would leave
+      // any reader that opened the same jar first with 
"IllegalStateException: zip file closed".
+      jarConnection.setUseCaches(false);
+      // Derived from the package rather than from 
JarURLConnection#getEntryName. On a multi-release
+      // jar the loader resolves the package to META-INF/versions/N/<pkg>/ on 
JDK 9-23 but to <pkg>/
+      // on 8 and 24+, so anchoring to the entry name would make the result 
JDK dependent and would
+      // drop classes that exist only in the base directory.
+      String entryPrefix = packageName.replace('.', '/') + '/';
+      try (JarFile jar = jarConnection.getJarFile()) {
+        // Collected before the jar is closed, since the returned stream 
outlives this method.
+        return jar.stream()
+            .map(JarEntry::getName)
+            .filter(name -> name.startsWith(entryPrefix) && 
name.endsWith(CLASS_FILE_SUFFIX))
+            .map(name -> name.substring(0, name.length() - 
CLASS_FILE_SUFFIX.length()).replace('/', '.'))
+            .collect(Collectors.toList())
+            .stream();
+      }
+    } catch (IOException e) {
+      log.error("Unable to read jar for {}", resource, e);
+      return Stream.empty();
+    }
+  }
+
   /**
    * Converts a package resource {@link URL} to a {@link File} directory, or 
{@code null} if the URI is malformed or does not represent a file.
    *
@@ -171,11 +240,18 @@ public class ReflectionUtils {
       return classes;
     }
     File[] files = directory.listFiles();
-    for (File file : Objects.requireNonNull(files)) {
+    if (files == null) {
+      // Null for an unreadable directory, or for a package path that is a 
regular file. Skipping it
+      // keeps one bad classpath entry from failing the whole scan, as the jar 
branch above does.
+      log.warn("Unable to list {}, skipping it", directory);
+      return classes;
+    }
+    for (File file : files) {
       if (file.isDirectory()) {
         classes.addAll(findClasses(file, packageName + "." + file.getName()));
-      } else if (file.getName().endsWith(".class")) {
-        classes.add(packageName + '.' + file.getName().substring(0, 
file.getName().length() - 6));
+      } else if (file.getName().endsWith(CLASS_FILE_SUFFIX)) {
+        classes.add(packageName + '.'
+            + file.getName().substring(0, file.getName().length() - 
CLASS_FILE_SUFFIX.length()));
       }
     }
     return classes;

Reply via email to