From 9ed877fe5a721d9c7f1a027399da46c821a13c17 Mon Sep 17 00:00:00 2001
From: JunRuiLee <107924572+JunRuiLee@users.noreply.github.com>
Date: Wed, 16 Sep 2026 11:08:27 +0800
Subject: [PATCH] [filesystem] Isolate Jindo OSS presigning dependencies
Load OSS presigning dependencies from a private plugin directory with the existing PluginLoader mechanism, without adding a Maven module or changing the shared classloader implementation.
Keep commons-logging available privately, initialize the plugin loader lazily, and preserve Jindo-specific dependency notices and JDOM/JAXB license resources.
Use the existing unit/integration test naming convention to verify packaged class and resource isolation, minimal and conflicting host classpaths, multi-release entries, and presigner class boundaries.
---
paimon-filesystems/paimon-jindo/pom.xml | 127 ++++-
.../paimon/jindo/JindoBlobPresigner.java | 85 +++
.../org/apache/paimon/jindo/JindoFileIO.java | 98 ++--
.../src/main/resources/META-INF/NOTICE | 32 ++
.../apache/paimon/jindo/JindoFileIOTest.java | 16 +-
.../paimon/jindo/JindoPluginITCase.java | 522 ++++++++++++++++++
6 files changed, 824 insertions(+), 56 deletions(-)
create mode 100644 paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoBlobPresigner.java
create mode 100644 paimon-filesystems/paimon-jindo/src/main/resources/META-INF/NOTICE
create mode 100644 paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoPluginITCase.java
diff --git a/paimon-filesystems/paimon-jindo/pom.xml b/paimon-filesystems/paimon-jindo/pom.xml
index e96a5ff63bd6..f70fe56d9185 100644
--- a/paimon-filesystems/paimon-jindo/pom.xml
+++ b/paimon-filesystems/paimon-jindo/pom.xml
@@ -34,6 +34,7 @@
3.17.4
6.9.1
+ paimon-plugin-jindo-oss
@@ -55,6 +56,12 @@
org.apache.paimon
paimon-oss-impl
${project.version}
+
+
+ *
+ *
+
+
@@ -160,6 +167,9 @@
org.apache.maven.plugins
maven-jar-plugin
+
+ org/apache/paimon/jindo/JindoBlobPresigner*.class
+
true
@@ -168,34 +178,121 @@
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+
+
+ copy-oss-presigner-implementation
+ prepare-package
+
+ copy-resources
+
+
+ ${project.build.outputDirectory}/${oss.plugin.directory}
+
+
+ ${project.build.outputDirectory}
+
+ org/apache/paimon/jindo/JindoBlobPresigner*.class
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+
+ unpack-oss-runtime
+ prepare-package
+
+ unpack-dependencies
+
+
+ runtime
+ paimon-oss-impl,slf4j-api,jsr305
+ ${project.build.outputDirectory}/${oss.plugin.directory}
+ META-INF/*.SF,META-INF/*.DSA,META-INF/*.RSA,META-INF/maven/**
+
+
+
+ unpack-oss-presigner
+ prepare-package
+
+ unpack
+
+
+
+
+ org.apache.paimon
+ paimon-oss-impl
+ ${project.version}
+ true
+ ${project.build.outputDirectory}/${oss.plugin.directory}
+ org/apache/paimon/oss/OSSBlobPresigner*.class
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ integration-tests
+
+
+ ${project.build.directory}/${project.build.finalName}.jar
+
+
+
+
+
+
org.apache.maven.plugins
maven-shade-plugin
- org.apache.paimon:paimon-oss-impl
- com.aliyun.oss:aliyun-sdk-oss
- com.aliyun:*
- com.google.code.gson:gson
- commons-codec:commons-codec
- commons-logging:commons-logging
- io.opentracing:*
- org.apache.httpcomponents:*
- org.codehaus.jettison:jettison
- org.ini4j:ini4j
- org.jdom:jdom2
- stax:stax-api
+ *:*
+
+ org.slf4j:slf4j-api
+ com.google.code.findbugs:jsr305
+
- org.apache.paimon:paimon-oss-impl
+ *:*
- org/apache/paimon/oss/OSSBlobPresigner.class
- META-INF/versions/11/**
+ org/apache/paimon/jindo/**
+ ${oss.plugin.directory}/**
+ META-INF/MANIFEST.MF
+ META-INF/LICENSE*
+ META-INF/licenses/LICENSE.jdom
+ META-INF/licenses/LICENSE.jaxb
+ META-INF/NOTICE*
+ META-INF/DEPENDENCIES
+ META-INF/maven/**
+ META-INF/services/org.apache.paimon.fs.FileIOLoader
+ META-INF/versions/11/javax/xml/bind/**
+
+ org.apache.paimon:paimon-oss-impl
+
+ META-INF/NOTICE*
+
+
diff --git a/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoBlobPresigner.java b/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoBlobPresigner.java
new file mode 100644
index 000000000000..57e399e9d55f
--- /dev/null
+++ b/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoBlobPresigner.java
@@ -0,0 +1,85 @@
+/*
+ * 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
+ *
+ * http://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.paimon.jindo;
+
+import org.apache.paimon.data.BlobDescriptor;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.oss.OSSBlobPresigner;
+import org.apache.paimon.utils.StringUtils;
+
+import com.aliyun.oss.OSSClient;
+import com.aliyun.oss.OSSClientBuilder;
+
+import java.io.IOException;
+import java.time.Duration;
+
+/** OSS presigning implementation loaded from the private plugin directory. */
+public class JindoBlobPresigner implements JindoFileIO.BlobPresigner {
+
+ private OSSClient client;
+
+ public JindoBlobPresigner() {}
+
+ JindoBlobPresigner(OSSClient client) {
+ this.client = client;
+ }
+
+ @Override
+ public void configure(Options options) {
+ client = createBlobClient(options);
+ }
+
+ @Override
+ public String create(Path tableRoot, BlobDescriptor descriptor, Duration validity)
+ throws IOException {
+ return OSSBlobPresigner.create(client, tableRoot, descriptor, validity);
+ }
+
+ @Override
+ public void close() {
+ client.shutdown();
+ }
+
+ static OSSClient createBlobClient(Options options) {
+ String endpoint = options.get("fs.oss.endpoint");
+ if (!endpoint.contains("://")) {
+ endpoint = "https://" + endpoint;
+ }
+ String securityToken = options.get("fs.oss.securityToken");
+ OSSClientBuilder builder = new OSSClientBuilder();
+ OSSClient client =
+ (OSSClient)
+ (StringUtils.isNullOrWhitespaceOnly(securityToken)
+ ? builder.build(
+ endpoint,
+ options.get("fs.oss.accessKeyId"),
+ options.get("fs.oss.accessKeySecret"))
+ : builder.build(
+ endpoint,
+ options.get("fs.oss.accessKeyId"),
+ options.get("fs.oss.accessKeySecret"),
+ securityToken));
+ String region = options.get("fs.oss.region");
+ if (!StringUtils.isNullOrWhitespaceOnly(region)) {
+ client.setRegion(region);
+ }
+ return client;
+ }
+}
diff --git a/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java b/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java
index 1e4646d4b9f9..31e0d440d969 100644
--- a/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java
+++ b/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java
@@ -25,7 +25,7 @@
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.TwoPhaseOutputStream;
import org.apache.paimon.options.Options;
-import org.apache.paimon.oss.OSSBlobPresigner;
+import org.apache.paimon.plugin.PluginLoader;
import org.apache.paimon.utils.IOUtils;
import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.SensitiveConfigUtils;
@@ -36,8 +36,6 @@
import com.aliyun.jindodata.oss.JindoOssFileSystem;
import com.aliyun.jindodata.oss.auth.SimpleCredentialsProvider;
import com.aliyun.jindodata.store.JindoMpuStore;
-import com.aliyun.oss.OSSClient;
-import com.aliyun.oss.OSSClientBuilder;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.slf4j.Logger;
@@ -70,11 +68,9 @@ public class JindoFileIO extends HadoopCompliantFileIO implements HadoopOptionsP
*/
private static final String[] CONFIG_PREFIXES = {"fs."};
- private static final String OSS_ENDPOINT = "fs.oss.endpoint";
private static final String OSS_ACCESS_KEY_ID = "fs.oss.accessKeyId";
private static final String OSS_ACCESS_KEY_SECRET = "fs.oss.accessKeySecret";
private static final String OSS_SECURITY_TOKEN = "fs.oss.securityToken";
- private static final String OSS_REGION = "fs.oss.region";
private static final String OSS_USER_AGENT_EXTENDED = "fs.oss.user.agent.extended";
private static final String OSS_SHOW_DIR_TIMESTAMP = "fs.oss.show-dir-timestamp";
private static final String DLF_ACCESS_TRACKING_EXTENDED_INFO =
@@ -99,12 +95,12 @@ public class JindoFileIO extends HadoopCompliantFileIO implements HadoopOptionsP
private Options hadoopOptions;
private Options hadoopOptionsWithCache;
private boolean allowCache = true;
- private transient OSSClient blobClient;
+ private transient BlobPresigner blobPresigner;
public JindoFileIO() {}
- JindoFileIO(OSSClient blobClient) {
- this.blobClient = blobClient;
+ JindoFileIO(BlobPresigner blobPresigner) {
+ this.blobPresigner = blobPresigner;
}
@Override
@@ -229,40 +225,45 @@ public TwoPhaseOutputStream newTwoPhaseOutputStream(Path path, boolean overwrite
@Override
public String createBlobPresignedUrl(
Path tableRoot, BlobDescriptor descriptor, Duration validity) throws IOException {
- return OSSBlobPresigner.create(blobClient(), tableRoot, descriptor, validity);
+ BlobPresigner presigner = blobPresigner();
+ Thread thread = Thread.currentThread();
+ ClassLoader previous = thread.getContextClassLoader();
+ try {
+ thread.setContextClassLoader(presigner.getClass().getClassLoader());
+ return presigner.create(tableRoot, descriptor, validity);
+ } finally {
+ thread.setContextClassLoader(previous);
+ }
}
- private synchronized OSSClient blobClient() {
- if (blobClient == null) {
- blobClient = createBlobClient(hadoopOptions);
+ private synchronized BlobPresigner blobPresigner() {
+ if (blobPresigner == null) {
+ PluginLoader loader = BlobPlugin.getLoader();
+ Thread thread = Thread.currentThread();
+ ClassLoader previous = thread.getContextClassLoader();
+ try {
+ thread.setContextClassLoader(loader.submoduleClassLoader());
+ BlobPresigner presigner =
+ loader.newInstance("org.apache.paimon.jindo.JindoBlobPresigner");
+ presigner.configure(hadoopOptions);
+ blobPresigner = presigner;
+ } finally {
+ thread.setContextClassLoader(previous);
+ }
}
- return blobClient;
+ return blobPresigner;
}
- static OSSClient createBlobClient(Options options) {
- String endpoint = options.get(OSS_ENDPOINT);
- if (!endpoint.contains("://")) {
- endpoint = "https://" + endpoint;
- }
- String securityToken = options.get(OSS_SECURITY_TOKEN);
- OSSClientBuilder builder = new OSSClientBuilder();
- OSSClient client =
- (OSSClient)
- (StringUtils.isNullOrWhitespaceOnly(securityToken)
- ? builder.build(
- endpoint,
- options.get(OSS_ACCESS_KEY_ID),
- options.get(OSS_ACCESS_KEY_SECRET))
- : builder.build(
- endpoint,
- options.get(OSS_ACCESS_KEY_ID),
- options.get(OSS_ACCESS_KEY_SECRET),
- securityToken));
- String region = options.get(OSS_REGION);
- if (!StringUtils.isNullOrWhitespaceOnly(region)) {
- client.setRegion(region);
+ private static class BlobPlugin {
+
+ private static PluginLoader loader;
+
+ private static synchronized PluginLoader getLoader() {
+ if (loader == null) {
+ loader = new PluginLoader("paimon-plugin-jindo-oss");
+ }
+ return loader;
}
- return client;
}
@Override
@@ -314,9 +315,16 @@ protected Pair createFileSystem(
@Override
public synchronized void close() {
- if (blobClient != null) {
- blobClient.shutdown();
- blobClient = null;
+ if (blobPresigner != null) {
+ Thread thread = Thread.currentThread();
+ ClassLoader previous = thread.getContextClassLoader();
+ try {
+ thread.setContextClassLoader(blobPresigner.getClass().getClassLoader());
+ blobPresigner.close();
+ blobPresigner = null;
+ } finally {
+ thread.setContextClassLoader(previous);
+ }
}
if (!allowCache) {
fsMap.values().stream().map(Pair::getKey).forEach(IOUtils::closeQuietly);
@@ -324,6 +332,18 @@ public synchronized void close() {
}
}
+ /** Contract shared with the isolated OSS implementation. */
+ public interface BlobPresigner extends AutoCloseable {
+
+ void configure(Options options);
+
+ String create(Path tableRoot, BlobDescriptor descriptor, Duration validity)
+ throws IOException;
+
+ @Override
+ void close();
+ }
+
private static class CacheKey {
private final Options options;
diff --git a/paimon-filesystems/paimon-jindo/src/main/resources/META-INF/NOTICE b/paimon-filesystems/paimon-jindo/src/main/resources/META-INF/NOTICE
new file mode 100644
index 000000000000..e5d564724e87
--- /dev/null
+++ b/paimon-filesystems/paimon-jindo/src/main/resources/META-INF/NOTICE
@@ -0,0 +1,32 @@
+paimon-jindo
+Copyright 2023-2026 The Apache Software Foundation
+
+This project includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
+
+This project bundles the following dependencies under the Apache Software License 2.0 (http://www.apache.org/licenses/LICENSE-2.0.txt)
+
+- com.aliyun.oss:aliyun-sdk-oss:3.17.4
+- com.aliyun:aliyun-java-sdk-core:4.5.10
+- com.aliyun:aliyun-java-sdk-kms:2.11.0
+- com.aliyun:aliyun-java-sdk-ram:3.1.0
+- com.google.code.gson:gson:2.8.6
+- commons-codec:commons-codec:1.11
+- commons-logging:commons-logging:1.1.3
+- io.opentracing:opentracing-api:0.33.0
+- io.opentracing:opentracing-noop:0.33.0
+- io.opentracing:opentracing-util:0.33.0
+- org.apache.httpcomponents:httpclient:4.5.13
+- org.apache.httpcomponents:httpcore:4.4.13
+- org.codehaus.jettison:jettison:1.5.4
+- org.ini4j:ini4j:0.5.4
+
+This project bundles the following dependencies under the JDOM license.
+You find it under licenses/LICENSE.jdom.
+
+- org.jdom:jdom2:2.0.6.1
+
+This project bundles the following dependencies under the CDDL 1.1 license.
+You find it under licenses/LICENSE.jaxb.
+
+- javax.xml.bind:jaxb-api:2.3.1
diff --git a/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoFileIOTest.java b/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoFileIOTest.java
index 91406badb4eb..06b430a2a23f 100644
--- a/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoFileIOTest.java
+++ b/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoFileIOTest.java
@@ -37,6 +37,7 @@
import java.net.URL;
import java.security.MessageDigest;
import java.time.Duration;
+import java.util.Date;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -85,7 +86,7 @@ public void testCreateBlobClientUsesConfiguredSts() {
options.set("fs.oss.accessKeySecret", "access-secret");
options.set("fs.oss.securityToken", "security-token");
- OSSClient client = JindoFileIO.createBlobClient(options);
+ OSSClient client = JindoBlobPresigner.createBlobClient(options);
try {
assertThat(client.getEndpoint()).isEqualTo(URI.create("https://oss.example.com"));
assertThat(client.getObjectOperation().getRegion()).isEqualTo("cn-hangzhou");
@@ -95,6 +96,17 @@ public void testCreateBlobClientUsesConfiguredSts() {
.isEqualTo("access-secret");
assertThat(client.getCredentialsProvider().getCredentials().getSecurityToken())
.isEqualTo("security-token");
+ URL signedUrl =
+ client.generatePresignedUrl(
+ "bucket", "object", new Date(System.currentTimeMillis() + 60_000));
+ assertThat(signedUrl.getProtocol()).isEqualTo("https");
+ assertThat(signedUrl.getHost()).isEqualTo("bucket.oss.example.com");
+ assertThat(signedUrl.getPath()).isEqualTo("/object");
+ assertThat(signedUrl.getQuery())
+ .contains(
+ "OSSAccessKeyId=access-key",
+ "Signature=",
+ "security-token=security-token");
} finally {
client.shutdown();
}
@@ -119,7 +131,7 @@ public void testCreateBlobPresignedUrlUsesOssClient() throws Exception {
"https://bucket.oss.example.com/"
+ invocation.getArgument(1)));
- JindoFileIO fileIO = new JindoFileIO(client);
+ JindoFileIO fileIO = new JindoFileIO(new JindoBlobPresigner(client));
assertThat(fileIO.createBlobPresignedUrl(tableRoot, descriptor, Duration.ofHours(1)))
.startsWith("https://bucket.oss.example.com/table/data/_bloburl_");
diff --git a/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoPluginITCase.java b/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoPluginITCase.java
new file mode 100644
index 000000000000..7f4e2451aef1
--- /dev/null
+++ b/paimon-filesystems/paimon-jindo/src/test/java/org/apache/paimon/jindo/JindoPluginITCase.java
@@ -0,0 +1,522 @@
+/*
+ * 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
+ *
+ * http://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.paimon.jindo;
+
+import org.apache.paimon.catalog.CatalogContext;
+import org.apache.paimon.data.BlobDescriptor;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.options.Options;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Properties;
+import java.util.ServiceLoader;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Packaged-artifact tests for {@link JindoFileIO}. */
+public class JindoPluginITCase {
+
+ private static final String PLUGIN_DIRECTORY = "paimon-plugin-jindo-oss/";
+
+ private static final String PRESIGNER_IMPLEMENTATION =
+ "org.apache.paimon.jindo.JindoBlobPresigner";
+
+ @TempDir java.nio.file.Path tempDir;
+
+ @Test
+ public void testPluginJarContents() throws Exception {
+ try (JarFile jar = new JarFile(pluginJar())) {
+ Set entries =
+ jar.stream()
+ .map(JarEntry::getName)
+ .map(JindoPluginITCase::normalizeEntry)
+ .collect(Collectors.toSet());
+ for (String originalPackage :
+ Arrays.asList(
+ "com/google/gson/",
+ "com/aliyun/oss/",
+ "com/aliyuncs/",
+ "org/apache/http/",
+ "org/apache/commons/codec/",
+ "org/apache/commons/logging/",
+ "org/jdom2/",
+ "org/codehaus/jettison/",
+ "io/opentracing/",
+ "org/ini4j/",
+ "javax/xml/stream/")) {
+ assertThat(entries)
+ .as("Private package %s at the root of %s", originalPackage, jar.getName())
+ .noneMatch(entry -> entry.startsWith(originalPackage));
+ }
+ for (String representativeClass :
+ Arrays.asList(
+ PRESIGNER_IMPLEMENTATION.replace('.', '/'),
+ "org/apache/paimon/oss/OSSBlobPresigner",
+ "com/google/gson/Gson",
+ "com/aliyun/oss/OSSClient",
+ "com/aliyuncs/DefaultAcsClient",
+ "org/apache/http/impl/client/HttpClients",
+ "org/apache/commons/codec/binary/Base64",
+ "org/apache/commons/logging/LogFactory",
+ "org/jdom2/Document",
+ "org/codehaus/jettison/json/JSONObject",
+ "io/opentracing/Tracer",
+ "org/ini4j/Ini")) {
+ assertThat(entries)
+ .contains(PLUGIN_DIRECTORY + representativeClass + ".class")
+ .doesNotContain(representativeClass + ".class");
+ }
+ for (String rootEntry :
+ Arrays.asList(
+ "org/apache/paimon/jindo/JindoFileIO.class",
+ "org/apache/paimon/jindo/JindoFileIO$BlobPresigner.class",
+ "org/apache/paimon/jindo/JindoLoader.class",
+ "META-INF/services/org.apache.paimon.fs.FileIOLoader")) {
+ assertThat(entries)
+ .contains(rootEntry)
+ .doesNotContain(PLUGIN_DIRECTORY + rootEntry);
+ }
+ for (String resource :
+ Arrays.asList(
+ "versioninfo.properties", "common.properties", "oss.properties")) {
+ assertThat(entries).contains(PLUGIN_DIRECTORY + resource).doesNotContain(resource);
+ }
+ for (String providedPackage :
+ Arrays.asList("com/aliyun/jindodata/", "org/apache/hadoop/", "org/slf4j/")) {
+ assertThat(entries)
+ .noneMatch(
+ entry ->
+ entry.startsWith(providedPackage)
+ || entry.startsWith(
+ PLUGIN_DIRECTORY + providedPackage));
+ }
+ assertPrivatePaimonClasses(entries);
+ }
+ }
+
+ @Test
+ public void testPluginWithMinimalHostClasspath() throws Exception {
+ assertPluginClassLoading(false, false);
+ }
+
+ @Test
+ public void testPluginBeforeHostDependencies() throws Exception {
+ assertPluginClassLoading(true, true);
+ }
+
+ @Test
+ public void testPluginAfterHostDependencies() throws Exception {
+ assertPluginClassLoading(false, true);
+ }
+
+ @Test
+ public void testPrivatePaimonClassesAllowNestedImplementations() {
+ Set entries =
+ new HashSet<>(
+ Arrays.asList(
+ PLUGIN_DIRECTORY
+ + "org/apache/paimon/jindo/JindoBlobPresigner.class",
+ PLUGIN_DIRECTORY + "org/apache/paimon/oss/OSSBlobPresigner.class",
+ PLUGIN_DIRECTORY
+ + "org/apache/paimon/jindo/JindoBlobPresigner$1.class",
+ PLUGIN_DIRECTORY
+ + "META-INF/versions/11/org/apache/paimon/oss/OSSBlobPresigner$Helper.class"));
+ assertPrivatePaimonClasses(entries);
+ }
+
+ @Test
+ public void testPrivatePaimonClassesRejectHostClasses() {
+ for (String unexpectedClass :
+ Arrays.asList(
+ PLUGIN_DIRECTORY + "org/apache/paimon/fs/Path.class",
+ PLUGIN_DIRECTORY + "META-INF/versions/11/org/apache/paimon/fs/Path.class",
+ "META-INF/versions/11/"
+ + PLUGIN_DIRECTORY
+ + "org/apache/paimon/fs/Path.class",
+ PLUGIN_DIRECTORY
+ + "org/apache/paimon/jindo/JindoFileIO$BlobPresigner.class",
+ PLUGIN_DIRECTORY + "org/apache/paimon/oss/OSSBlobPresignerOther.class")) {
+ Set entries =
+ new HashSet<>(
+ Arrays.asList(
+ PLUGIN_DIRECTORY
+ + "org/apache/paimon/jindo/JindoBlobPresigner.class",
+ PLUGIN_DIRECTORY
+ + "org/apache/paimon/oss/OSSBlobPresigner.class",
+ unexpectedClass));
+ assertThatThrownBy(() -> assertPrivatePaimonClasses(entries))
+ .as("Reject private host class %s", unexpectedClass)
+ .isInstanceOf(AssertionError.class);
+ }
+ }
+
+ private static String normalizeEntry(String entry) {
+ return entry.replaceFirst("^META-INF/versions/[0-9]+/", "")
+ .replaceFirst(
+ "^" + PLUGIN_DIRECTORY + "META-INF/versions/[0-9]+/", PLUGIN_DIRECTORY);
+ }
+
+ private static void assertPrivatePaimonClasses(Set entries) {
+ assertThat(entries.stream().map(JindoPluginITCase::normalizeEntry))
+ .filteredOn(
+ entry ->
+ entry.startsWith(PLUGIN_DIRECTORY + "org/apache/paimon/")
+ && entry.endsWith(".class"))
+ .allMatch(
+ entry ->
+ entry.matches(
+ PLUGIN_DIRECTORY
+ + "org/apache/paimon/(jindo/JindoBlobPresigner|oss/OSSBlobPresigner)(\\$[^/]+)?\\.class"));
+ }
+
+ private static File pluginJar() throws Exception {
+ File jar = new File(System.getProperty("jindo.plugin.jar")).getCanonicalFile();
+ assertThat(jar).isFile();
+ return jar;
+ }
+
+ private static File classLocation(Class> type) throws Exception {
+ return new File(type.getProtectionDomain().getCodeSource().getLocation().toURI())
+ .getCanonicalFile();
+ }
+
+ private static Class> loadClassFrom(
+ ClassLoader classLoader, String className, File expectedJar) throws Exception {
+ Class> type = Class.forName(className, true, classLoader);
+ assertThat(type.getClassLoader()).as("Loader of %s", className).isSameAs(classLoader);
+ assertThat(classLocation(type)).as("Source of %s", className).isEqualTo(expectedJar);
+ return type;
+ }
+
+ private void assertPluginClassLoading(boolean jarFirst, boolean conflictingDependencies)
+ throws Exception {
+ File jar = pluginJar();
+ List urls = new ArrayList<>();
+ urls.add(tempDir.toUri().toURL());
+ List hostClasses =
+ new ArrayList<>(
+ Arrays.asList(
+ "org.apache.paimon.plugin.PluginLoader",
+ "org.apache.paimon.options.Options",
+ "org.apache.paimon.shade.guava30.com.google.common.collect.Iterators",
+ "com.aliyun.jindodata.Version",
+ "com.aliyun.jindodata.common.JindoHadoopSystem"));
+ for (String artifact : Arrays.asList("hadoop-common", "slf4j-api")) {
+ urls.add(hostDependency(artifact).toURI().toURL());
+ }
+ if (conflictingDependencies) {
+ urls.add(hostDependency("commons-logging").toURI().toURL());
+ hostClasses.addAll(
+ Arrays.asList(
+ "com.google.gson.Gson",
+ "org.apache.http.client.methods.HttpGet",
+ "org.apache.http.HttpVersion",
+ "org.apache.paimon.oss.OSSBlobPresigner",
+ "com.aliyun.oss.OSSClient"));
+ }
+ for (String className : hostClasses) {
+ URL location = classLocation(testClass(className)).toURI().toURL();
+ if (!urls.contains(location)) {
+ urls.add(location);
+ }
+ }
+ Files.write(
+ tempDir.resolve("versioninfo.properties"),
+ "version=host-version\n".getBytes(StandardCharsets.UTF_8));
+ Files.write(
+ tempDir.resolve("common.properties"),
+ "ConnectionError=host-error\n".getBytes(StandardCharsets.UTF_8));
+ urls.add(jarFirst ? 0 : urls.size(), jar.toURI().toURL());
+ Thread thread = Thread.currentThread();
+ ClassLoader contextClassLoader = thread.getContextClassLoader();
+ try (URLClassLoader host =
+ new URLClassLoader(
+ urls.toArray(new URL[0]), ClassLoader.getSystemClassLoader().getParent())) {
+ thread.setContextClassLoader(host);
+ assertThatThrownBy(() -> host.loadClass(PRESIGNER_IMPLEMENTATION))
+ .isInstanceOf(ClassNotFoundException.class);
+ assertThat(host.getResource(PRESIGNER_IMPLEMENTATION.replace('.', '/') + ".class"))
+ .isNull();
+ Class> optionsClass = host.loadClass(Options.class.getName());
+ Map settings = new HashMap<>();
+ settings.put("fs.oss.endpoint", "oss.example.com");
+ settings.put("fs.oss.region", "cn-hangzhou");
+ settings.put("fs.oss.accessKeyId", "access-key");
+ settings.put("fs.oss.accessKeySecret", "access-secret");
+ settings.put("fs.oss.securityToken", "security-token");
+ Object options = optionsClass.getConstructor(Map.class).newInstance(settings);
+ Class> implementation = assertPublicEntryPoint(host, jar, options);
+ try (URLClassLoader plugin = (URLClassLoader) implementation.getClassLoader()) {
+ assertThat(implementation.getSimpleName()).isEqualTo("JindoBlobPresigner");
+ assertThat(implementation.getEnclosingClass()).isNull();
+ assertThat(implementation.getClassLoader()).isSameAs(plugin).isNotSameAs(host);
+ assertThat(implementation.getInterfaces())
+ .contains(host.loadClass(JindoFileIO.BlobPresigner.class.getName()));
+ Class> logFactory = plugin.loadClass("org.apache.commons.logging.LogFactory");
+ if (conflictingDependencies) {
+ assertThat(logFactory)
+ .isSameAs(host.loadClass("org.apache.commons.logging.LogFactory"));
+ } else {
+ assertThat(logFactory.getClassLoader()).isSameAs(plugin);
+ assertThatThrownBy(
+ () -> host.loadClass("org.apache.commons.logging.LogFactory"))
+ .isInstanceOf(ClassNotFoundException.class);
+ }
+ for (String className :
+ Arrays.asList(
+ "com.google.gson.Gson",
+ "org.apache.http.client.methods.HttpGet",
+ "org.apache.paimon.oss.OSSBlobPresigner",
+ "com.aliyun.oss.OSSClient")) {
+ Class> privateClass = Class.forName(className, true, plugin);
+ assertThat(privateClass.getClassLoader()).isSameAs(plugin);
+ assertThat(privateClass.getProtectionDomain().getCodeSource().getLocation())
+ .isEqualTo(host.getResource(PLUGIN_DIRECTORY));
+ if (conflictingDependencies) {
+ assertThat(
+ loadClassFrom(
+ host,
+ className,
+ classLocation(testClass(className))))
+ .isNotSameAs(privateClass);
+ } else {
+ assertThatThrownBy(() -> host.loadClass(className))
+ .isInstanceOf(ClassNotFoundException.class);
+ assertThat(host.getResource(className.replace('.', '/') + ".class"))
+ .isNull();
+ }
+ }
+ assertGson(plugin);
+ if (conflictingDependencies) {
+ assertGson(host);
+ }
+ assertPluginResources(host, plugin);
+ assertBlobClient(implementation, optionsClass, options, plugin);
+ assertThat(thread.getContextClassLoader()).isSameAs(host);
+ }
+ Class> serviceClass = host.loadClass("org.apache.paimon.fs.FileIOLoader");
+ boolean found = false;
+ for (Object provider : ServiceLoader.load(serviceClass, host)) {
+ if (provider.getClass().getName().equals("org.apache.paimon.jindo.JindoLoader")) {
+ loadClassFrom(host, provider.getClass().getName(), jar);
+ assertThat(serviceClass.getMethod("getScheme").invoke(provider))
+ .isEqualTo("oss");
+ found = true;
+ break;
+ }
+ }
+ assertThat(found).as("Jindo FileIOLoader SPI from plugin artifact").isTrue();
+ } finally {
+ thread.setContextClassLoader(contextClassLoader);
+ }
+ }
+
+ private static Class> testClass(String name) throws ClassNotFoundException {
+ return Class.forName(name, false, JindoPluginITCase.class.getClassLoader());
+ }
+
+ private static File hostDependency(String artifact) throws IOException {
+ String classPath =
+ System.getProperty(
+ "surefire.test.class.path", System.getProperty("java.class.path"));
+ for (String entry : classPath.split(File.pathSeparator)) {
+ File dependency = new File(entry);
+ if (dependency.isFile()
+ && dependency.getName().startsWith(artifact + "-")
+ && dependency.getName().endsWith(".jar")) {
+ return dependency.getCanonicalFile();
+ }
+ }
+ throw new AssertionError("Missing host dependency: " + artifact);
+ }
+
+ private static Class> assertPublicEntryPoint(ClassLoader host, File jar, Object options)
+ throws Exception {
+ Class> fileIOClass = loadClassFrom(host, "org.apache.paimon.jindo.JindoFileIO", jar);
+ assertThat(fileIOClass.getDeclaredClasses())
+ .contains(host.loadClass(JindoFileIO.BlobPresigner.class.getName()))
+ .allSatisfy(nestedClass -> assertThat(nestedClass.getClassLoader()).isSameAs(host));
+ Object fileIO = fileIOClass.getConstructor().newInstance();
+ Field presignerField = fileIOClass.getDeclaredField("blobPresigner");
+ presignerField.setAccessible(true);
+ try {
+ assertThat(presignerField.get(fileIO)).as("Presigner is lazy").isNull();
+ Class> contextClass = host.loadClass(CatalogContext.class.getName());
+ Object context =
+ contextClass.getMethod("create", options.getClass()).invoke(null, options);
+ fileIOClass.getMethod("configure", contextClass).invoke(fileIO, context);
+ assertThat(presignerField.get(fileIO))
+ .as("Configuration does not load the plugin")
+ .isNull();
+ Class> pathClass = host.loadClass(Path.class.getName());
+ Class> descriptorClass = host.loadClass(BlobDescriptor.class.getName());
+ Object tableRoot =
+ pathClass.getConstructor(String.class).newInstance("oss://bucket/table");
+ Object descriptor =
+ descriptorClass
+ .getConstructor(String.class, long.class, long.class)
+ .newInstance("oss://bucket/table/data/file", 10L, 20L);
+ Method presign =
+ fileIOClass.getMethod(
+ "createBlobPresignedUrl", pathClass, descriptorClass, Duration.class);
+ for (int attempt = 0; attempt < 2; attempt++) {
+ assertThatThrownBy(
+ () -> presign.invoke(fileIO, tableRoot, descriptor, Duration.ZERO))
+ .isInstanceOf(InvocationTargetException.class)
+ .hasCauseInstanceOf(IOException.class)
+ .hasStackTraceContaining("positive whole seconds")
+ .hasStackTraceContaining(PRESIGNER_IMPLEMENTATION + ".create")
+ .hasStackTraceContaining("org.apache.paimon.oss.OSSBlobPresigner.create");
+ assertThat(Thread.currentThread().getContextClassLoader()).isSameAs(host);
+ }
+ Class> implementation = presignerField.get(fileIO).getClass();
+ assertThat(implementation.getName()).isEqualTo(PRESIGNER_IMPLEMENTATION);
+ return implementation;
+ } finally {
+ fileIOClass.getMethod("close").invoke(fileIO);
+ assertThat(Thread.currentThread().getContextClassLoader()).isSameAs(host);
+ }
+ }
+
+ private static void assertGson(ClassLoader loader) throws Exception {
+ Class> gsonClass = loader.loadClass("com.google.gson.Gson");
+ Object gson = gsonClass.getConstructor().newInstance();
+ assertThat(
+ gsonClass
+ .getMethod("toJson", Object.class)
+ .invoke(gson, Collections.singletonMap("plugin", 1)))
+ .isEqualTo("{\"plugin\":1}");
+ }
+
+ private static void assertBlobClient(
+ Class> implementation, Class> optionsClass, Object options, ClassLoader plugin)
+ throws Exception {
+ Method createClient = implementation.getDeclaredMethod("createBlobClient", optionsClass);
+ createClient.setAccessible(true);
+ Class> clientClass = plugin.loadClass("com.aliyun.oss.OSSClient");
+ assertThat(clientClass.getClassLoader()).isSameAs(plugin);
+ Object client = createClient.invoke(null, options);
+ try {
+ assertThat(client.getClass()).isSameAs(clientClass);
+ assertThat(clientClass.getMethod("getEndpoint").invoke(client))
+ .isEqualTo(URI.create("https://oss.example.com"));
+ Object operation = clientClass.getMethod("getObjectOperation").invoke(client);
+ assertThat(operation.getClass().getMethod("getRegion").invoke(operation))
+ .isEqualTo("cn-hangzhou");
+ URL signedUrl =
+ (URL)
+ clientClass
+ .getMethod(
+ "generatePresignedUrl",
+ String.class,
+ String.class,
+ Date.class)
+ .invoke(
+ client,
+ "bucket",
+ "object",
+ new Date(System.currentTimeMillis() + 60_000));
+ assertThat(signedUrl.getProtocol()).isEqualTo("https");
+ assertThat(signedUrl.getHost()).isEqualTo("bucket.oss.example.com");
+ assertThat(signedUrl.getPath()).isEqualTo("/object");
+ assertThat(signedUrl.getQuery())
+ .contains(
+ "OSSAccessKeyId=access-key",
+ "Signature=",
+ "security-token=security-token");
+ } finally {
+ client.getClass().getMethod("shutdown").invoke(client);
+ }
+ }
+
+ private static void assertPluginResources(ClassLoader host, ClassLoader plugin)
+ throws Exception {
+ for (String resource : Arrays.asList("versioninfo.properties", "common.properties")) {
+ URL pluginResource = plugin.getResource(resource);
+ assertThat(pluginResource).isNotNull();
+ assertThat(pluginResource.toExternalForm())
+ .contains("!/" + PLUGIN_DIRECTORY + resource);
+ assertThat(Collections.list(plugin.getResources(resource)))
+ .containsExactly(pluginResource);
+ }
+ assertThat(resourceProperties(host, "versioninfo.properties").getProperty("version"))
+ .isEqualTo("host-version");
+ assertThat(resourceProperties(host, "common.properties").getProperty("ConnectionError"))
+ .isEqualTo("host-error");
+ String version =
+ resourceProperties(plugin, "versioninfo.properties").getProperty("version");
+ assertThat(version).isNotBlank().isNotIn("host-version", "unknown-version");
+ assertThat(
+ plugin.loadClass("com.aliyun.oss.common.utils.VersionInfoUtils")
+ .getMethod("getVersion")
+ .invoke(null))
+ .isEqualTo(version);
+ Class> managerClass = plugin.loadClass("com.aliyun.oss.common.utils.ResourceManager");
+ Object manager =
+ managerClass
+ .getMethod("getInstance", String.class, Locale.class)
+ .invoke(null, "common", Locale.ROOT);
+ String message =
+ resourceProperties(plugin, "common.properties").getProperty("ConnectionError");
+ assertThat(message).isNotBlank().isNotEqualTo("host-error");
+ assertThat(
+ managerClass
+ .getMethod("getString", String.class)
+ .invoke(manager, "ConnectionError"))
+ .isEqualTo(message);
+ }
+
+ private static Properties resourceProperties(ClassLoader loader, String resource)
+ throws IOException {
+ Properties properties = new Properties();
+ try (InputStream stream = loader.getResourceAsStream(resource)) {
+ assertThat(stream).as(resource).isNotNull();
+ properties.load(stream);
+ }
+ return properties;
+ }
+}