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 e8646e005a32b..2d7646bc623ba 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
@@ -967,5 +967,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 c6da4c6082e38..448742edce9a5 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
@@ -933,5 +933,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..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
@@ -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,159 @@ 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:
+ *
+ *
+ * - Never call {@code Class.forName} / {@code loadClass} for plugin classes.
+ *
- Never add plugin URLs to the parent ClassLoader.
+ *
- Only read bytes via {@link JarFile} / {@link Files} and {@link
+ * ClassLoader#getResourceAsStream(String)}.
+ *
+ */
+ 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(), true, JarFile.OPEN_READ, Runtime.version())) {
+ final Enumeration entries = jarFile.entries();
+ while (entries.hasMoreElements()) {
+ final JarEntry entry = entries.nextElement();
+ if (entry.isDirectory()
+ || entry.getName().startsWith("META-INF/versions/")
+ || !isComparableClassEntry(entry.getName())) {
+ continue;
+ }
+ // 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);
+ }
+ }
+ }
}
- 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..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
@@ -28,6 +28,8 @@
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;
import java.nio.charset.StandardCharsets;
@@ -41,58 +43,69 @@
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 {
+ // Verify that a plugin is rejected when it contains different bytecode for a parent class.
@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);
+ }
+ }
+
+ // 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");
+ 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 +113,180 @@ 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);
+ }
+ }
+
+ // 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");
+ 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);
+ }
+ }
+
+ // 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);
+ }
+ }
+
+ // 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"));
+ 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<>();
@@ -161,6 +339,60 @@ 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 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;