From 01135f795fe2b47248139262379efafad912650c Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Thu, 6 Aug 2026 11:13:37 +0800 Subject: [PATCH 1/3] Pipe: Reject plugin jars that conflict with parent ClassLoader bytecode Restore standard parent-delegation for PipePluginClassLoader and fail fast when a plugin ships the same class name with different bytecode, without defining classes into the parent during the check. --- .../iotdb/commons/i18n/PipeMessages.java | 7 + .../iotdb/commons/i18n/PipeMessages.java | 7 + .../plugin/service/PipePluginClassLoader.java | 201 +++++++++++++----- .../service/PipePluginClassLoaderTest.java | 150 +++++++++---- 4 files changed, 276 insertions(+), 89 deletions(-) diff --git a/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/PipeMessages.java b/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/PipeMessages.java index 010cf08f196c1..a67e2cbcab799 100644 --- a/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/PipeMessages.java +++ b/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/PipeMessages.java @@ -966,5 +966,12 @@ private PipeMessages() {} public static final String MESSAGE_DATAPARTITIONTABLE_GENERATION_COMPLETED_SUCCESSFULLY_E076E3B2 = "DataPartitionTable generation completed successfully"; public static final String MESSAGE_DATAPARTITIONTABLE_GENERATION_FAILED_D85CD23A = "DataPartitionTable generation failed: "; public static final String MESSAGE_UNKNOWN_TASK_STATUS_E05D98F0 = "Unknown task status: "; + public static final String + EXCEPTION_FAILED_TO_LOAD_PIPE_PLUGIN_FROM_ARG_BECAUSE_THE_FOLLOWING_CLASSES_CONFLICT_WITH_THE_PARENT_CLASSLOADER_SAME_FULLY_QUALIFIED_NAME_BUT_DIFFERENT_BYTECODE_ARG_0647E8F3 = + "Failed to load pipe plugin from %s, because the following classes conflict with the parent ClassLoader (same fully-qualified name but different bytecode): %s"; + public static final String EXCEPTION_LIBROOT_CANNOT_BE_NULL_C22EAC78 = "libRoot cannot be null"; + public static final String + EXCEPTION_FAILED_TO_LOAD_PIPE_PLUGIN_FROM_ARG_BECAUSE_THE_PATH_DOES_NOT_EXIST_1AD125AD = + "Failed to load pipe plugin from %s, because the path does not exist"; } diff --git a/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/PipeMessages.java b/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/PipeMessages.java index 8ea19a43f89b8..c8199ddd38384 100644 --- a/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/PipeMessages.java +++ b/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/PipeMessages.java @@ -932,5 +932,12 @@ private PipeMessages() {} public static final String MESSAGE_DATAPARTITIONTABLE_GENERATION_COMPLETED_SUCCESSFULLY_E076E3B2 = "DataPartitionTable 生成已成功完成"; public static final String MESSAGE_DATAPARTITIONTABLE_GENERATION_FAILED_D85CD23A = "DataPartitionTable 生成失败:"; public static final String MESSAGE_UNKNOWN_TASK_STATUS_E05D98F0 = "未知任务状态:"; + public static final String + EXCEPTION_FAILED_TO_LOAD_PIPE_PLUGIN_FROM_ARG_BECAUSE_THE_FOLLOWING_CLASSES_CONFLICT_WITH_THE_PARENT_CLASSLOADER_SAME_FULLY_QUALIFIED_NAME_BUT_DIFFERENT_BYTECODE_ARG_0647E8F3 = + "从 %s 加载 pipe plugin 失败,因为以下类与父 ClassLoader 冲突(全类名相同但字节码不同):%s"; + public static final String EXCEPTION_LIBROOT_CANNOT_BE_NULL_C22EAC78 = "libRoot 不能为空"; + public static final String + EXCEPTION_FAILED_TO_LOAD_PIPE_PLUGIN_FROM_ARG_BECAUSE_THE_PATH_DOES_NOT_EXIST_1AD125AD = + "从 %s 加载 pipe plugin 失败,因为路径不存在"; } diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoader.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoader.java index 745d93566fc40..46834348e4a15 100644 --- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoader.java +++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoader.java @@ -20,38 +20,58 @@ package org.apache.iotdb.commons.pipe.agent.plugin.service; import org.apache.iotdb.commons.file.SystemFileFactory; +import org.apache.iotdb.commons.i18n.PipeMessages; +import javax.annotation.concurrent.GuardedBy; import javax.annotation.concurrent.ThreadSafe; import java.io.IOException; +import java.io.InputStream; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; -import java.util.concurrent.atomic.AtomicLong; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; import java.util.stream.Collectors; import java.util.stream.Stream; +/** + * ClassLoader for a pipe plugin. Uses the standard parent-delegation model. + * + *

Before attaching any plugin jar/class URLs, it scans plugin artifacts as raw bytes (via {@link + * JarFile} / filesystem reads) and compares them with resources visible to the parent ClassLoader + * through {@link ClassLoader#getResourceAsStream(String)}. That check never defines classes into + * the parent (or this) ClassLoader; only a later explicit {@link Class#forName} loads the plugin + * entry class. + */ @ThreadSafe public class PipePluginClassLoader extends URLClassLoader { - private static final String[] PARENT_FIRST_CLASS_PREFIXES = { - "java.", "javax.", "jdk.", "sun.", "org.slf4j.", "org.apache.iotdb.pipe.api." - }; - - private final String libRoot; + private static final String CLASS_SUFFIX = ".class"; + private static final String JAR_SUFFIX = ".jar"; + private static final String MODULE_INFO_CLASS = "module-info.class"; + private static final int MAX_REPORTED_CONFLICTS = 20; /** * If activeInstanceCount is equals to 0, it means that there is no instance using this * classloader. This classloader can only be closed when activeInstanceCount is equals to 0. */ - private final AtomicLong activeInstanceCount; + @GuardedBy("this") + private long activeInstanceCount; /** * If this classloader is marked as deprecated, then this classloader can be closed after all * instances that use this classloader are closed. */ - private volatile boolean deprecated; + @GuardedBy("this") + private boolean deprecated; public PipePluginClassLoader(String libRoot) throws IOException { this(libRoot, ClassLoader.getSystemClassLoader()); @@ -59,71 +79,154 @@ public PipePluginClassLoader(String libRoot) throws IOException { PipePluginClassLoader(String libRoot, ClassLoader parent) throws IOException { super(new URL[0], parent); - this.libRoot = libRoot; - activeInstanceCount = new AtomicLong(0); + Objects.requireNonNull(libRoot, PipeMessages.EXCEPTION_LIBROOT_CANNOT_BE_NULL_C22EAC78); + activeInstanceCount = 0; deprecated = false; - addUrls(); + + final Path rootPath = SystemFileFactory.INSTANCE.getFile(libRoot).toPath(); + if (!Files.exists(rootPath)) { + throw new IOException( + String.format( + PipeMessages + .EXCEPTION_FAILED_TO_LOAD_PIPE_PLUGIN_FROM_ARG_BECAUSE_THE_PATH_DOES_NOT_EXIST_1AD125AD, + rootPath)); + } + + // Walk once and reuse for conflict check + URL registration. + final List pluginFiles; + try (Stream pathStream = Files.walk(rootPath)) { + pluginFiles = pathStream.filter(Files::isRegularFile).collect(Collectors.toList()); + } + + validateNoConflictingClassesWithParent(rootPath, pluginFiles, parent); + addUrls(pluginFiles); } - private void addUrls() throws IOException { - try (Stream pathStream = - Files.walk(SystemFileFactory.INSTANCE.getFile(libRoot).toPath())) { - // skip directory - for (Path path : - pathStream.filter(path -> !path.toFile().isDirectory()).collect(Collectors.toList())) { - super.addURL(path.toUri().toURL()); + /** + * Scan plugin jars/classes and reject those whose fully-qualified class names already exist on + * the parent ClassLoader with different bytecode. + * + *

Implementation constraints: + * + *

+ */ + static void validateNoConflictingClassesWithParent( + Path rootPath, List pluginFiles, ClassLoader parent) throws IOException { + final List conflicts = new ArrayList<>(); + + for (Path path : pluginFiles) { + final String fileName = path.getFileName().toString().toLowerCase(Locale.ROOT); + if (fileName.endsWith(JAR_SUFFIX)) { + collectJarConflicts(path, parent, conflicts); + } else if (fileName.endsWith(CLASS_SUFFIX)) { + collectLooseClassConflict(rootPath, path, parent, conflicts); } } - } - public synchronized void acquire() { - activeInstanceCount.incrementAndGet(); + if (!conflicts.isEmpty()) { + final String reported = + conflicts.stream().limit(MAX_REPORTED_CONFLICTS).collect(Collectors.joining(", ")); + throw new IOException( + String.format( + PipeMessages + .EXCEPTION_FAILED_TO_LOAD_PIPE_PLUGIN_FROM_ARG_BECAUSE_THE_FOLLOWING_CLASSES_CONFLICT_WITH_THE_PARENT_CLASSLOADER_SAME_FULLY_QUALIFIED_NAME_BUT_DIFFERENT_BYTECODE_ARG_0647E8F3, + rootPath, + reported)); + } } - public synchronized void release() throws IOException { - activeInstanceCount.decrementAndGet(); - closeIfPossible(); + private static void collectJarConflicts(Path jarPath, ClassLoader parent, List conflicts) + throws IOException { + try (JarFile jarFile = new JarFile(jarPath.toFile())) { + final Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + final JarEntry entry = entries.nextElement(); + if (entry.isDirectory() || !isComparableClassEntry(entry.getName())) { + continue; + } + try (InputStream pluginIn = jarFile.getInputStream(entry)) { + maybeAddConflict(entry.getName(), readAllBytes(pluginIn), parent, conflicts); + } + } + } } - public synchronized void markAsDeprecated() throws IOException { - deprecated = true; - closeIfPossible(); + private static void collectLooseClassConflict( + Path libRoot, Path classFile, ClassLoader parent, List conflicts) throws IOException { + final Path relative = libRoot.relativize(classFile); + final String resourceName = relative.toString().replace('\\', '/'); + if (!isComparableClassEntry(resourceName)) { + return; + } + maybeAddConflict(resourceName, Files.readAllBytes(classFile), parent, conflicts); } - @Override - protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { - synchronized (getClassLoadingLock(name)) { - Class loadedClass = findLoadedClass(name); - if (loadedClass == null) { - loadedClass = - shouldLoadFromParentFirst(name) ? super.loadClass(name, false) : loadClassLocally(name); + private static void maybeAddConflict( + String resourceName, byte[] pluginBytes, ClassLoader parent, List conflicts) + throws IOException { + // getResourceAsStream locates parent classpath bytes without defining the Class. + try (InputStream parentIn = parent.getResourceAsStream(resourceName)) { + if (parentIn == null) { + return; } - if (resolve) { - resolveClass(loadedClass); + final byte[] parentBytes = readAllBytes(parentIn); + if (!Arrays.equals(parentBytes, pluginBytes)) { + conflicts.add(resourceNameToClassName(resourceName)); } - return loadedClass; } } - private Class loadClassLocally(String name) throws ClassNotFoundException { - try { - return findClass(name); - } catch (ClassNotFoundException e) { - return super.loadClass(name, false); + private static boolean isComparableClassEntry(String resourceName) { + if (!resourceName.endsWith(CLASS_SUFFIX)) { + return false; } + final int lastSlashIndex = resourceName.lastIndexOf('/'); + final String simpleName = + lastSlashIndex >= 0 + ? resourceName.substring(lastSlashIndex + 1).toLowerCase(Locale.ROOT) + : resourceName.toLowerCase(Locale.ROOT); + return !MODULE_INFO_CLASS.equals(simpleName); } - private boolean shouldLoadFromParentFirst(String name) { - for (String prefix : PARENT_FIRST_CLASS_PREFIXES) { - if (name.startsWith(prefix)) { - return true; - } + private static String resourceNameToClassName(String resourceName) { + return resourceName + .substring(0, resourceName.length() - CLASS_SUFFIX.length()) + .replace('/', '.'); + } + + private static byte[] readAllBytes(InputStream inputStream) throws IOException { + return inputStream.readAllBytes(); + } + + private void addUrls(List pluginFiles) throws IOException { + for (Path path : pluginFiles) { + super.addURL(path.toUri().toURL()); } - return false; + } + + public synchronized void acquire() { + activeInstanceCount++; + } + + public synchronized void release() throws IOException { + if (activeInstanceCount > 0) { + activeInstanceCount--; + } + closeIfPossible(); + } + + public synchronized void markAsDeprecated() throws IOException { + deprecated = true; + closeIfPossible(); } private void closeIfPossible() throws IOException { - if (deprecated && activeInstanceCount.get() == 0) { + if (deprecated && activeInstanceCount == 0) { close(); } } diff --git a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java index 39126656fc1ab..a99a3be25cbf8 100644 --- a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java +++ b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java @@ -28,6 +28,7 @@ import java.io.File; import java.io.IOException; +import java.lang.reflect.Method; import java.net.URL; import java.net.URLClassLoader; import java.nio.charset.StandardCharsets; @@ -46,53 +47,61 @@ public class PipePluginClassLoaderTest { @Test - public void testPluginClassesShouldOverrideParentClasses() throws Exception { - final Path tempDir = Files.createTempDirectory("pipe-plugin-classloader-test"); + public void testRejectPluginWhenParentHasDifferentBytecode() throws Exception { + final Path tempDir = Files.createTempDirectory("pipe-plugin-classloader-conflict"); try { - final Path parentSources = Files.createDirectory(tempDir.resolve("parent-sources")); - final Path parentClasses = Files.createDirectory(tempDir.resolve("parent-classes")); - final Path childSources = Files.createDirectory(tempDir.resolve("child-sources")); - final Path childClasses = Files.createDirectory(tempDir.resolve("child-classes")); - - final String sampleSource = - "package test.plugin;" - + "public class Sample {" - + " public String ping() {" - + " return test.dep.Helper.value();" - + " }" - + "}"; - final String parentHelperSource = - "package test.dep;" - + "public class Helper {" - + " public static String value() {" - + " return \"parent\";" - + " }" - + "}"; - final String childHelperSource = - "package test.dep;" - + "public class Helper {" - + " public static String value() {" - + " return \"child\";" - + " }" - + "}"; + final Path parentJar = buildJarWithHelper(tempDir, "parent", "parent"); + final Path childJar = buildJarWithHelper(tempDir, "child", "child"); + try (final URLClassLoader parentClassLoader = + new URLClassLoader(new URL[] {parentJar.toUri().toURL()}, null)) { + // Ensure parent has already resolved the class resource. + Assert.assertNotNull(parentClassLoader.getResource("test/dep/Helper.class")); + + try { + new PipePluginClassLoader(childJar.toString(), parentClassLoader); + Assert.fail("Expected IOException for conflicting classes"); + } catch (final IOException e) { + Assert.assertTrue(e.getMessage().contains("test.dep.Helper")); + } + + // Conflict check must not define classes into the parent ClassLoader. + Assert.assertNull(findLoadedClass(parentClassLoader, "test.dep.Helper")); + Assert.assertNull(findLoadedClass(parentClassLoader, "test.plugin.Sample")); + } + } finally { + deleteRecursively(tempDir); + } + } + + @Test + public void testAllowPluginWhenParentHasIdenticalBytecode() throws Exception { + final Path tempDir = Files.createTempDirectory("pipe-plugin-classloader-same"); + try { + final Path sharedClasses = Files.createDirectory(tempDir.resolve("shared-classes")); + final Path sharedSources = Files.createDirectory(tempDir.resolve("shared-sources")); compile( - parentSources, - parentClasses, - createSources(sampleSource, false), - createSources(parentHelperSource, true)); - compile( - childSources, - childClasses, - createSources(sampleSource, false), - createSources(childHelperSource, true)); + sharedSources, + sharedClasses, + createSources( + "package test.dep;" + + "public class Helper {" + + " public static String value() { return \"same\"; }" + + "}", + true), + createSources( + "package test.plugin;" + + "public class Sample {" + + " public String ping() { return test.dep.Helper.value(); }" + + "}", + false)); final Path parentJar = tempDir.resolve("parent.jar"); final Path childJar = tempDir.resolve("child.jar"); - createJar(parentJar, parentClasses, Arrays.asList("test/plugin/Sample.class")); + createJar(parentJar, sharedClasses, Arrays.asList("test/dep/Helper.class")); createJar( childJar, - childClasses, + sharedClasses, Arrays.asList("test/plugin/Sample.class", "test/dep/Helper.class")); try (final URLClassLoader parentClassLoader = @@ -100,15 +109,76 @@ public void testPluginClassesShouldOverrideParentClasses() throws Exception { final PipePluginClassLoader pluginClassLoader = new PipePluginClassLoader(childJar.toString(), parentClassLoader)) { final Class sampleClass = Class.forName("test.plugin.Sample", true, pluginClassLoader); + // Sample is only in the plugin jar → loaded by plugin ClassLoader. Assert.assertSame(pluginClassLoader, sampleClass.getClassLoader()); + // Helper is identical and present on parent → parent-delegation loads parent's copy. + final Class helperClass = Class.forName("test.dep.Helper", true, pluginClassLoader); + Assert.assertSame(parentClassLoader, helperClass.getClassLoader()); final Object sample = sampleClass.getDeclaredConstructor().newInstance(); - Assert.assertEquals("child", sampleClass.getMethod("ping").invoke(sample)); + Assert.assertEquals("same", sampleClass.getMethod("ping").invoke(sample)); } } finally { deleteRecursively(tempDir); } } + @Test + public void testConflictCheckDoesNotLoadPluginClasses() throws Exception { + final Path tempDir = Files.createTempDirectory("pipe-plugin-classloader-noload"); + try { + final Path parentJar = buildJarWithHelper(tempDir, "parent", "parent"); + final Path childJar = buildJarWithHelper(tempDir, "child", "child"); + + try (final URLClassLoader parentClassLoader = + new URLClassLoader(new URL[] {parentJar.toUri().toURL()}, null)) { + try { + PipePluginClassLoader.validateNoConflictingClassesWithParent( + childJar, List.of(childJar), parentClassLoader); + Assert.fail("Expected IOException for conflicting classes"); + } catch (final IOException expected) { + // expected + } + + Assert.assertNull(findLoadedClass(parentClassLoader, "test.dep.Helper")); + Assert.assertNull(findLoadedClass(parentClassLoader, "test.plugin.Sample")); + } + } finally { + deleteRecursively(tempDir); + } + } + + private static Path buildJarWithHelper(Path tempDir, String prefix, String helperValue) + throws IOException { + final Path sources = Files.createDirectory(tempDir.resolve(prefix + "-sources")); + final Path classes = Files.createDirectory(tempDir.resolve(prefix + "-classes")); + compile( + sources, + classes, + createSources( + "package test.dep;" + + "public class Helper {" + + " public static String value() { return \"" + + helperValue + + "\"; }" + + "}", + true), + createSources( + "package test.plugin;" + + "public class Sample {" + + " public String ping() { return test.dep.Helper.value(); }" + + "}", + false)); + final Path jar = tempDir.resolve(prefix + ".jar"); + createJar(jar, classes, Arrays.asList("test/plugin/Sample.class", "test/dep/Helper.class")); + return jar; + } + + private static Class findLoadedClass(ClassLoader classLoader, String name) throws Exception { + final Method method = ClassLoader.class.getDeclaredMethod("findLoadedClass", String.class); + method.setAccessible(true); + return (Class) method.invoke(classLoader, name); + } + private static Map createSources( final String source, final boolean helperSource) { final Map sources = new LinkedHashMap<>(); From f9a0464e2bfcc9f1c800eab7594ba60870135c03 Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Fri, 7 Aug 2026 16:09:05 +0800 Subject: [PATCH 2/3] test: cover pipe plugin classloader safeguards --- .../service/PipePluginClassLoaderTest.java | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java index a99a3be25cbf8..554fe18678f13 100644 --- a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java +++ b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java @@ -28,6 +28,7 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.Method; import java.net.URL; import java.net.URLClassLoader; @@ -46,6 +47,7 @@ public class PipePluginClassLoaderTest { + // Verify that a plugin is rejected when it contains different bytecode for a parent class. @Test public void testRejectPluginWhenParentHasDifferentBytecode() throws Exception { final Path tempDir = Files.createTempDirectory("pipe-plugin-classloader-conflict"); @@ -74,6 +76,7 @@ public void testRejectPluginWhenParentHasDifferentBytecode() throws Exception { } } + // Verify that identical parent and plugin bytecode is allowed and uses parent delegation. @Test public void testAllowPluginWhenParentHasIdenticalBytecode() throws Exception { final Path tempDir = Files.createTempDirectory("pipe-plugin-classloader-same"); @@ -122,6 +125,7 @@ public void testAllowPluginWhenParentHasIdenticalBytecode() throws Exception { } } + // Verify that conflict scanning does not load plugin classes into the parent loader. @Test public void testConflictCheckDoesNotLoadPluginClasses() throws Exception { final Path tempDir = Files.createTempDirectory("pipe-plugin-classloader-noload"); @@ -147,6 +151,79 @@ public void testConflictCheckDoesNotLoadPluginClasses() throws Exception { } } + // Verify that Java core classes cannot be overridden by plugin classes. + @Test + public void testJavaCoreClassIsLoadedByBootstrapClassLoader() throws Exception { + // Verify that a plugin cannot replace a Java core class through parent delegation. + final Path tempDir = Files.createTempDirectory("pipe-plugin-core-protect"); + try { + final Path childJar = tempDir.resolve("plugin.jar"); + createJarWithResource(childJar, "java/lang/String.class", new byte[0]); + + try (final URLClassLoader parentClassLoader = new URLClassLoader(new URL[0], null)) { + try { + new PipePluginClassLoader(childJar.toString(), parentClassLoader); + Assert.fail("Expected IOException for a conflicting Java core class"); + } catch (IOException expected) { + Assert.assertTrue(expected.getMessage().contains("java.lang.String")); + } + } + } finally { + deleteRecursively(tempDir); + } + } + + // Verify that closing the plugin loader releases the plugin JAR file handle. + @Test + public void testPluginJarFileHandleReleasedAfterClose() throws Exception { + // Verify that closing the plugin class loader releases the underlying JAR file. + final Path tempDir = Files.createTempDirectory("pipe-plugin-file-handle"); + try { + final Path childJar = buildJarWithHelper(tempDir, "close-test", "dummy"); + try (final URLClassLoader parentClassLoader = new URLClassLoader(new URL[0], null); + final PipePluginClassLoader pluginClassLoader = + new PipePluginClassLoader(childJar.toString(), parentClassLoader)) { + Class.forName("test.plugin.Sample", true, pluginClassLoader); + pluginClassLoader.close(); + } + Assert.assertTrue(Files.deleteIfExists(childJar)); + } finally { + deleteRecursively(tempDir); + } + } + + // Verify parent-first resource lookup and enumeration of duplicate resources. + @Test + public void testPluginResourceIsolation() throws Exception { + // Verify parent-first lookup and enumeration of duplicate resources. + final Path tempDir = Files.createTempDirectory("pipe-plugin-resource-isolation"); + try { + final Path parentJar = tempDir.resolve("parent.jar"); + final Path childJar = tempDir.resolve("child.jar"); + createJarWithResource(parentJar, "config.properties", "source=parent"); + createJarWithResource(childJar, "config.properties", "source=child"); + try (final URLClassLoader parentClassLoader = + new URLClassLoader(new URL[] {parentJar.toUri().toURL()}, null); + final PipePluginClassLoader pluginClassLoader = + new PipePluginClassLoader(childJar.toString(), parentClassLoader)) { + final URL resourceUrl = pluginClassLoader.getResource("config.properties"); + Assert.assertNotNull(resourceUrl); + try (InputStream inputStream = resourceUrl.openStream()) { + Assert.assertEquals( + "source=parent", new String(inputStream.readAllBytes(), StandardCharsets.UTF_8)); + } + final List allResources = new ArrayList<>(); + pluginClassLoader + .getResources("config.properties") + .asIterator() + .forEachRemaining(allResources::add); + Assert.assertEquals(2, allResources.size()); + } + } finally { + deleteRecursively(tempDir); + } + } + private static Path buildJarWithHelper(Path tempDir, String prefix, String helperValue) throws IOException { final Path sources = Files.createDirectory(tempDir.resolve(prefix + "-sources")); @@ -231,6 +308,20 @@ private static void createJar( } } + private static void createJarWithResource(Path jarPath, String resourceName, String content) + throws IOException { + createJarWithResource(jarPath, resourceName, content.getBytes(StandardCharsets.UTF_8)); + } + + private static void createJarWithResource(Path jarPath, String resourceName, byte[] content) + throws IOException { + try (JarOutputStream jarOutputStream = new JarOutputStream(Files.newOutputStream(jarPath))) { + jarOutputStream.putNextEntry(new JarEntry(resourceName)); + jarOutputStream.write(content); + jarOutputStream.closeEntry(); + } + } + private static void deleteRecursively(final Path path) throws IOException { if (path == null || !Files.exists(path)) { return; From d09a543301682790d75bf4438ced9dc959fe907f Mon Sep 17 00:00:00 2001 From: luoluoyuyu Date: Fri, 7 Aug 2026 16:29:07 +0800 Subject: [PATCH 3/3] fix: handle multi-release pipe plugin jars --- .../plugin/service/PipePluginClassLoader.java | 11 ++- .../service/PipePluginClassLoaderTest.java | 71 +++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoader.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoader.java index 46834348e4a15..3e04c3eb9c925 100644 --- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoader.java +++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoader.java @@ -142,14 +142,19 @@ static void validateNoConflictingClassesWithParent( private static void collectJarConflicts(Path jarPath, ClassLoader parent, List conflicts) throws IOException { - try (JarFile jarFile = new JarFile(jarPath.toFile())) { + try (JarFile jarFile = + new JarFile(jarPath.toFile(), true, JarFile.OPEN_READ, Runtime.version())) { final Enumeration entries = jarFile.entries(); while (entries.hasMoreElements()) { final JarEntry entry = entries.nextElement(); - if (entry.isDirectory() || !isComparableClassEntry(entry.getName())) { + if (entry.isDirectory() + || entry.getName().startsWith("META-INF/versions/") + || !isComparableClassEntry(entry.getName())) { continue; } - try (InputStream pluginIn = jarFile.getInputStream(entry)) { + // Resolve the logical entry through the runtime-aware view of a multi-release JAR. + final JarEntry runtimeEntry = jarFile.getJarEntry(entry.getName()); + try (InputStream pluginIn = jarFile.getInputStream(runtimeEntry)) { maybeAddConflict(entry.getName(), readAllBytes(pluginIn), parent, conflicts); } } diff --git a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java index 554fe18678f13..a9d9e270d8f0d 100644 --- a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java +++ b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/agent/plugin/service/PipePluginClassLoaderTest.java @@ -43,6 +43,7 @@ import java.util.Map; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; import java.util.stream.Stream; public class PipePluginClassLoaderTest { @@ -224,6 +225,36 @@ public void testPluginResourceIsolation() throws Exception { } } + // Verify runtime-selected Multi-Release JAR entries are compared consistently. + @Test + public void testMultiReleaseJarConflictDetectionUsesRuntimeVersion() throws Exception { + final Path tempDir = Files.createTempDirectory("pipe-plugin-multi-release"); + try { + final Path parentJar = tempDir.resolve("parent.jar"); + final Path samePluginJar = tempDir.resolve("same-plugin.jar"); + final Path differentPluginJar = tempDir.resolve("different-plugin.jar"); + createMultiReleaseJar(parentJar, "same"); + createMultiReleaseJar(samePluginJar, "same"); + createMultiReleaseJar(differentPluginJar, "different"); + + try (final URLClassLoader parentClassLoader = + new URLClassLoader(new URL[] {parentJar.toUri().toURL()}, null)) { + try (final PipePluginClassLoader ignored = + new PipePluginClassLoader(samePluginJar.toString(), parentClassLoader)) { + // Identical runtime-selected bytes must not be reported as a conflict. + } + try { + new PipePluginClassLoader(differentPluginJar.toString(), parentClassLoader); + Assert.fail("Expected a conflict for different runtime-selected bytes"); + } catch (IOException expected) { + Assert.assertTrue(expected.getMessage().contains("test.dep.Helper")); + } + } + } finally { + deleteRecursively(tempDir); + } + } + private static Path buildJarWithHelper(Path tempDir, String prefix, String helperValue) throws IOException { final Path sources = Files.createDirectory(tempDir.resolve(prefix + "-sources")); @@ -322,6 +353,46 @@ private static void createJarWithResource(Path jarPath, String resourceName, byt } } + private static void createMultiReleaseJar(Path jarPath, String versionedValue) + throws IOException { + final Path baseSources = Files.createTempDirectory("mr-base-sources"); + final Path baseClasses = Files.createTempDirectory("mr-base-classes"); + final Path versionSources = Files.createTempDirectory("mr-version-sources"); + final Path versionClasses = Files.createTempDirectory("mr-version-classes"); + try { + compile( + baseSources, + baseClasses, + createSources( + "package test.dep; public class Helper { public static String value() { return \"base\"; } }", + true)); + compile( + versionSources, + versionClasses, + createSources( + "package test.dep; public class Helper { public static String value() { return \"" + + versionedValue + + "\"; } }", + true)); + final Manifest manifest = new Manifest(); + manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); + manifest.getMainAttributes().putValue("Multi-Release", "true"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jarPath), manifest)) { + output.putNextEntry(new JarEntry("test/dep/Helper.class")); + output.write(Files.readAllBytes(baseClasses.resolve("test/dep/Helper.class"))); + output.closeEntry(); + output.putNextEntry(new JarEntry("META-INF/versions/17/test/dep/Helper.class")); + output.write(Files.readAllBytes(versionClasses.resolve("test/dep/Helper.class"))); + output.closeEntry(); + } + } finally { + deleteRecursively(baseSources); + deleteRecursively(baseClasses); + deleteRecursively(versionSources); + deleteRecursively(versionClasses); + } + } + private static void deleteRecursively(final Path path) throws IOException { if (path == null || !Files.exists(path)) { return;