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

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


The following commit(s) were added to refs/heads/master by this push:
     new fb61aeeb63f [fix](plugin) Allow legacy unversioned authentication 
plugins (#67286)
fb61aeeb63f is described below

commit fb61aeeb63feb10eaeb08ce1a42a70fc4ec76bda
Author: Calvin Kirs <[email protected]>
AuthorDate: Fri Oct 30 22:19:47 2026 +0800

    [fix](plugin) Allow legacy unversioned authentication plugins (#67286)
    
    ### What problem does this PR solve?
    
    #66211
    
    Problem Summary:
    
    The authentication plugin API-version gate rejects every plugin jar that
    does not contain `Doris-Authentication-Plugin-Api-Version`.
    Authentication plugins were distributed before this manifest contract
    existed, so upgrading FE breaks those legacy artifacts even when they
    implement the current v1 API.
    
    This change adds an opt-in legacy mapping to the generic API gate and
    configures only the authentication family to treat a genuinely absent
    declaration as API 1.0:
    
    - unversioned legacy authentication plugins load with an operator
    warning;
    - empty, malformed, and explicitly incompatible declarations remain
    rejected;
    - connector, filesystem, lineage, and other strict gates keep their
    existing fail-closed behavior;
    - when the FE authentication API moves to another major, legacy v1
    plugins are rejected automatically.
---
 .../handler/AuthenticationPluginManager.java       |   8 +-
 .../handler/AuthenticationPluginManagerTest.java   |  24 ++---
 .../doris/extension/loader/ApiVersionGate.java     |  94 +++++++++++++---
 .../loader/DirectoryPluginRuntimeManager.java      |  49 ++++++---
 .../doris/extension/loader/ManifestVersions.java   |   8 +-
 .../doris/extension/loader/ApiVersionGateTest.java |  37 ++++++-
 ...irectoryPluginRuntimeManagerApiVersionTest.java | 119 +++++++++++++++++++++
 7 files changed, 292 insertions(+), 47 deletions(-)

diff --git 
a/fe/fe-authentication/fe-authentication-handler/src/main/java/org/apache/doris/authentication/handler/AuthenticationPluginManager.java
 
b/fe/fe-authentication/fe-authentication-handler/src/main/java/org/apache/doris/authentication/handler/AuthenticationPluginManager.java
index 3565013dca8..202bef69339 100644
--- 
a/fe/fe-authentication/fe-authentication-handler/src/main/java/org/apache/doris/authentication/handler/AuthenticationPluginManager.java
+++ 
b/fe/fe-authentication/fe-authentication-handler/src/main/java/org/apache/doris/authentication/handler/AuthenticationPluginManager.java
@@ -66,15 +66,19 @@ public class AuthenticationPluginManager {
 
     /** Family label in the process-wide {@link PluginRegistry}. */
     private static final String PLUGIN_FAMILY = "AUTHENTICATION";
+    private static final String LEGACY_UNVERSIONED_API_VERSION = "1.0";
 
     /**
      * The authentication plugin API contract this FE serves. Built from the 
version filtered into
      * fe-authentication-spi at build time, anchored on {@link 
AuthenticationPluginFactory} so that it is read
      * from the very artifact carrying the SPI. A missing or malformed 
resource is a build defect and fails
-     * class initialization loudly rather than degrading into a check that 
admits everything.
+     * class initialization loudly rather than degrading into a check that 
admits everything. Authentication
+     * plugins shipped before the manifest contract are treated as API 1.0 for 
upgrade compatibility; explicit
+     * malformed or incompatible declarations remain rejected.
      */
     private static final ApiVersionGate API_VERSION_GATE =
-            ApiVersionGate.forFamily("authentication", 
AuthenticationPluginFactory.class);
+            ApiVersionGate.forFamilyAllowingUnversioned(
+                    "authentication", AuthenticationPluginFactory.class, 
LEGACY_UNVERSIONED_API_VERSION);
 
     /** Factories by plugin name (e.g., "ldap", "oidc", "password") */
     private final Map<String, AuthenticationPluginFactory> factories = new 
ConcurrentHashMap<>();
diff --git 
a/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java
 
b/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java
index 10f929712ee..0c38463d220 100644
--- 
a/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java
+++ 
b/fe/fe-authentication/fe-authentication-handler/src/test/java/org/apache/doris/authentication/handler/AuthenticationPluginManagerTest.java
@@ -27,7 +27,6 @@ import 
org.apache.doris.authentication.spi.AuthenticationPluginFactory;
 import org.apache.doris.extension.loader.ApiVersionGate;
 import org.apache.doris.extension.loader.ClassLoadingPolicy;
 import org.apache.doris.extension.loader.DirectoryPluginRuntimeManager;
-import org.apache.doris.extension.loader.LoadFailure;
 import org.apache.doris.extension.loader.LoadReport;
 import org.apache.doris.extension.loader.PluginHandle;
 
@@ -405,27 +404,22 @@ public class AuthenticationPluginManagerTest {
     }
 
     @Test
-    @DisplayName("UT-HANDLER-PM-022: Plugin declaring no API version is 
refused (fail-closed)")
-    void testLoadAll_UndeclaredApiVersionIsRefused() throws Exception {
-        // Given: a jar built with no awareness of this contract at all.
+    @DisplayName("UT-HANDLER-PM-022: Legacy plugin declaring no API version 
remains loadable")
+    void testLoadAll_UndeclaredLegacyApiVersionIsAccepted() throws Exception {
+        // Given: an authentication plugin built before the manifest version 
contract existed.
         Path root = Files.createTempDirectory("plugin-root-no-api-version");
         createPluginJar(
                 
Files.createDirectories(root.resolve("external-dir-test")).resolve("external-dir-test.jar"),
                 DirectoryPluginFactory.class.getName(),
                 null);
 
-        // When: this is the only directory, so loadAll reports total 
failure...
-        AuthenticationException ex = Assertions.assertThrows(
-                AuthenticationException.class,
-                () -> pluginManager.loadAll(
-                        Arrays.asList(root),
-                        Thread.currentThread().getContextClassLoader()));
+        // When
+        pluginManager.loadAll(Arrays.asList(root), 
Thread.currentThread().getContextClassLoader());
 
-        // Then: ...and the thrown message already carries the version reason, 
so the caller that wraps it
-        // does not have to.
-        Assertions.assertFalse(pluginManager.hasFactory("external-dir-test"));
-        
Assertions.assertTrue(ex.getMessage().contains(LoadFailure.STAGE_API_VERSION), 
ex.getMessage());
-        
Assertions.assertTrue(ex.getMessage().contains(AUTH_GATE.getManifestAttribute()),
 ex.getMessage());
+        // Then: absence maps to the documented legacy API 1.0, while 
explicitly incompatible versions are
+        // still covered by UT-HANDLER-PM-021.
+        Assertions.assertTrue(pluginManager.hasFactory("external-dir-test"));
+        Assertions.assertEquals("", pluginManager.apiVersionRejectionHint());
     }
 
     @Test
diff --git 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/ApiVersionGate.java
 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/ApiVersionGate.java
index 63040c303ac..e0949edfa99 100644
--- 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/ApiVersionGate.java
+++ 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/ApiVersionGate.java
@@ -64,7 +64,9 @@ import java.util.jar.JarFile;
  *
  * <p>Major must match exactly; minor and patch are ignored in both 
directions. That is only sound because
  * "major" is defined as <em>any</em> change to the SPI surface — including 
additions — so a compatible minor
- * can never change the set of API elements a plugin can see.
+ * can never change the set of API elements a plugin can see. Families that 
distributed plugins before this
+ * manifest contract existed may explicitly map an absent declaration to their 
documented legacy version;
+ * strict rejection remains the default, and explicit declarations always 
follow the same major rule.
  */
 public final class ApiVersionGate {
 
@@ -75,14 +77,19 @@ public final class ApiVersionGate {
     private final String manifestAttribute;
     private final String expectedVersion;
     private final int expectedMajor;
+    private final String legacyUnversionedVersion;
+    private final int legacyUnversionedMajor;
     private final ClassLoader kernelClassLoader;
 
     private ApiVersionGate(String familyLabel, String manifestAttribute, 
String expectedVersion,
-            int expectedMajor, ClassLoader kernelClassLoader) {
+            int expectedMajor, String legacyUnversionedVersion, int 
legacyUnversionedMajor,
+            ClassLoader kernelClassLoader) {
         this.familyLabel = familyLabel;
         this.manifestAttribute = manifestAttribute;
         this.expectedVersion = expectedVersion;
         this.expectedMajor = expectedMajor;
+        this.legacyUnversionedVersion = legacyUnversionedVersion;
+        this.legacyUnversionedMajor = legacyUnversionedMajor;
         this.kernelClassLoader = kernelClassLoader;
     }
 
@@ -100,6 +107,29 @@ public final class ApiVersionGate {
      *         degrading into a check that admits everything.
      */
     public static ApiVersionGate forFamily(String family, Class<?> spiAnchor) {
+        return forFamily(family, spiAnchor, null);
+    }
+
+    /**
+     * Builds a gate that maps a genuinely absent manifest declaration to a 
documented legacy API version.
+     *
+     * <p>This is only for a plugin family that shipped external plugins 
before the manifest contract existed.
+     * It preserves upgrade compatibility for those artifacts without 
weakening validation for malformed
+     * declarations or explicitly incompatible majors. If the FE later moves 
to another major, unversioned
+     * plugins are rejected automatically because their legacy major no longer 
matches.
+     *
+     * @param family lower-case single-token family name
+     * @param spiAnchor a type from the family's SPI artifact
+     * @param legacyUnversionedVersion API version implemented by artifacts 
that predate the manifest contract
+     */
+    public static ApiVersionGate forFamilyAllowingUnversioned(
+            String family, Class<?> spiAnchor, String 
legacyUnversionedVersion) {
+        Objects.requireNonNull(legacyUnversionedVersion, 
"legacyUnversionedVersion");
+        return forFamily(family, spiAnchor, legacyUnversionedVersion);
+    }
+
+    private static ApiVersionGate forFamily(
+            String family, Class<?> spiAnchor, String 
legacyUnversionedVersion) {
         Objects.requireNonNull(family, "family");
         Objects.requireNonNull(spiAnchor, "spiAnchor");
         String resource = versionResourceOf(family);
@@ -110,8 +140,19 @@ public final class ApiVersionGate {
                     + " (from " + spiAnchor.getName() + "); expected 
major[.minor[.patch]]. This is a build"
                     + " defect: the maven property feeding this resource is 
missing or was not filtered.");
         }
+        int legacyMajor = -1;
+        if (legacyUnversionedVersion != null) {
+            OptionalInt parsedLegacyMajor = 
parseMajor(legacyUnversionedVersion);
+            if (!parsedLegacyMajor.isPresent()) {
+                throw new IllegalArgumentException("Malformed legacy 
unversioned API version='"
+                        + legacyUnversionedVersion + "' for " + family + "; 
expected major[.minor[.patch]]");
+            }
+            legacyUnversionedVersion = legacyUnversionedVersion.trim();
+            legacyMajor = parsedLegacyMajor.getAsInt();
+        }
         return new ApiVersionGate(family.toUpperCase(Locale.ROOT), 
manifestAttributeOf(family),
-                declared.trim(), major.getAsInt(), spiAnchor.getClassLoader());
+                declared.trim(), major.getAsInt(), legacyUnversionedVersion, 
legacyMajor,
+                spiAnchor.getClassLoader());
     }
 
     /** Kernel-side resource path for a family, by convention. */
@@ -155,18 +196,28 @@ public final class ApiVersionGate {
     /**
      * Judges what a plugin jar declared.
      *
-     * <p>Fail-closed: a jar that declares nothing is rejected, so that a 
plugin built without any awareness of
-     * this contract cannot slip through. A third party can still get in by 
declaring a version it was not
-     * built against, but that is an active false claim rather than an 
omission.
+     * <p>Strict gates reject a jar that declares nothing. A family that 
explicitly configures a legacy
+     * unversioned version may accept an absent declaration when that legacy 
major matches the FE. Blank,
+     * malformed, and explicitly incompatible declarations always remain 
fail-closed.
      *
      * @param declaredVersion the value of {@link #getManifestAttribute()} in 
the plugin jar, or null when absent
      * @return null when the plugin may load, otherwise a diagnostic naming 
the declared and expected values
      */
     public String rejectionReason(String declaredVersion) {
-        if (declaredVersion == null || declaredVersion.trim().isEmpty()) {
-            return "no " + manifestAttribute + " in the plugin jar MANIFEST; 
this FE serves " + familyLabel
-                    + " plugin API " + expectedVersion + ". Declare the 
attribute and rebuild the plugin"
-                    + " against this Doris release.";
+        if (declaredVersion == null) {
+            if (legacyUnversionedVersion == null) {
+                return missingDeclarationReason();
+            }
+            if (legacyUnversionedMajor != expectedMajor) {
+                return "unversioned legacy " + familyLabel + " plugin API " + 
legacyUnversionedVersion
+                        + " is incompatible with this FE, which serves plugin 
API " + expectedVersion
+                        + ". Rebuild the plugin against this Doris release.";
+            }
+            return null;
+        }
+        if (declaredVersion.trim().isEmpty()) {
+            return "empty " + manifestAttribute + " in the plugin jar 
MANIFEST; this FE serves " + familyLabel
+                    + " plugin API " + expectedVersion + ".";
         }
         OptionalInt major = parseMajor(declaredVersion);
         if (!major.isPresent()) {
@@ -183,21 +234,40 @@ public final class ApiVersionGate {
         return null;
     }
 
+    /**
+     * Returns an operator-facing warning when an accepted plugin relied on 
the legacy unversioned mapping.
+     */
+    public String acceptanceWarning(String declaredVersion) {
+        if (declaredVersion == null && legacyUnversionedVersion != null
+                && legacyUnversionedMajor == expectedMajor) {
+            return "plugin jar has no " + manifestAttribute + "; treating it 
as legacy " + familyLabel
+                    + " plugin API " + legacyUnversionedVersion + ". Rebuild 
the plugin with an explicit"
+                    + " API version declaration.";
+        }
+        return null;
+    }
+
     /** Applies the same manifest-major contract to a classpath-discovered 
provider. */
     public String rejectionReasonForClass(Class<?> pluginClass) {
         Path definingJar = ManifestVersions.jarOf(pluginClass);
         if (definingJar == null) {
             // Maven/IDE runs expose kernel-built providers as class 
directories, but an independently
             // loaded exploded provider has no immutable declaration and must 
remain fail-closed.
-            return pluginClass.getClassLoader() == kernelClassLoader ? null : 
rejectionReason((String) null);
+            return pluginClass.getClassLoader() == kernelClassLoader ? null : 
missingDeclarationReason();
         }
         try (JarFile jar = new JarFile(definingJar.toFile())) {
             return rejectionReason(ManifestVersions.mainAttribute(jar, 
manifestAttribute));
         } catch (IOException e) {
-            return rejectionReason((String) null);
+            return missingDeclarationReason();
         }
     }
 
+    private String missingDeclarationReason() {
+        return "no " + manifestAttribute + " in the plugin jar MANIFEST; this 
FE serves " + familyLabel
+                + " plugin API " + expectedVersion + ". Declare the attribute 
and rebuild the plugin"
+                + " against this Doris release.";
+    }
+
     /**
      * Major segment of {@code major[.minor[.patch]]}, or empty when the value 
is not that shape.
      *
diff --git 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManager.java
 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManager.java
index 556242738c5..e9c6d0b71c5 100644
--- 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManager.java
+++ 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManager.java
@@ -19,6 +19,9 @@ package org.apache.doris.extension.loader;
 
 import org.apache.doris.extension.spi.PluginFactory;
 
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
 import java.io.Closeable;
 import java.io.IOException;
 import java.net.MalformedURLException;
@@ -104,6 +107,8 @@ import java.util.stream.Stream;
  */
 public class DirectoryPluginRuntimeManager<F extends PluginFactory> {
 
+    private static final Logger LOG = 
LogManager.getLogger(DirectoryPluginRuntimeManager.class);
+
     private final ConcurrentMap<String, PluginHandle<F>> handlesByName = new 
ConcurrentHashMap<>();
     private final Object lifecycleLock = new Object();
 
@@ -278,8 +283,18 @@ public class DirectoryPluginRuntimeManager<F extends 
PluginFactory> {
             // Version check before construction, and before asSubclass(): a 
plugin built against a
             // different SPI major is precisely the one whose types may no 
longer line up, so it must be
             // answered with a diagnosis naming both versions rather than with 
a cast or linkage error.
-            String declaredApiVersion = readManifestMainAttribute(
-                    discoveredClass, allJars, 
apiVersionGate.getManifestAttribute());
+            String declaredApiVersion;
+            try {
+                declaredApiVersion = readManifestMainAttribute(
+                        discoveredClass, allJars, 
apiVersionGate.getManifestAttribute());
+            } catch (IOException e) {
+                throw new PluginLoadException(
+                        normalizedDir,
+                        LoadFailure.STAGE_API_VERSION,
+                        "Failed to read " + 
apiVersionGate.getManifestAttribute() + " for factory '"
+                                + factoryClassName + "' in " + normalizedDir + 
": " + e.getMessage(),
+                        e);
+            }
             String rejection = 
apiVersionGate.rejectionReason(declaredApiVersion);
             if (rejection != null) {
                 throw new PluginLoadException(
@@ -288,6 +303,10 @@ public class DirectoryPluginRuntimeManager<F extends 
PluginFactory> {
                         "Rejected plugin in " + normalizedDir + ": " + 
rejection,
                         null);
             }
+            String acceptanceWarning = 
apiVersionGate.acceptanceWarning(declaredApiVersion);
+            if (acceptanceWarning != null) {
+                LOG.warn("Loading legacy unversioned plugin from {}: {}", 
normalizedDir, acceptanceWarning);
+            }
 
             try {
                 @SuppressWarnings("unchecked")
@@ -366,17 +385,16 @@ public class DirectoryPluginRuntimeManager<F extends 
PluginFactory> {
      * source when available (covers layouts where the service descriptor sits 
in a root jar but the
      * implementation lives in lib/), otherwise the first candidate jar 
containing the class entry.
      *
-     * <p>An unreadable jar degrades to null rather than throwing. For the 
display-only version that leaves
-     * the inventory row's version empty; for the plugin API version it reads 
as "declared nothing", which
-     * {@link ApiVersionGate} rejects — the fail-closed direction in both 
cases.
+     * <p>Null means a readable defining jar genuinely omitted the requested 
datum. An unreadable or
+     * unresolved defining jar throws: the API gate must not mistake failed 
verification for an absent
+     * legacy declaration. Once the code source identifies a jar, another 
candidate cannot replace it.
      */
-    private String readFromDefiningJar(Class<?> factoryClass, List<Path> 
candidateJars, ManifestReader reader) {
+    private String readFromDefiningJar(Class<?> factoryClass, List<Path> 
candidateJars, ManifestReader reader)
+            throws IOException {
         Path definingJar = ManifestVersions.jarOf(factoryClass);
         if (definingJar != null) {
             try (JarFile jarFile = new JarFile(definingJar.toFile())) {
                 return reader.read(jarFile);
-            } catch (IOException ignored) {
-                // Fall through to scanning the candidate jars.
             }
         }
         String classEntry = factoryClass.getName().replace('.', '/') + 
".class";
@@ -386,23 +404,26 @@ public class DirectoryPluginRuntimeManager<F extends 
PluginFactory> {
                     continue;
                 }
                 return reader.read(jarFile);
-            } catch (IOException ignored) {
-                // Fall through to the next candidate jar.
             }
         }
-        return null;
+        throw new IOException("Cannot locate defining jar for factory '" + 
factoryClass.getName() + "'");
     }
 
     /** Implementation-Version, honoring the class's package section. 
Display-only metadata. */
     private String readImplementationVersion(Class<?> factoryClass, List<Path> 
candidateJars) {
         String packagePath = ManifestVersions.packagePathOf(factoryClass);
-        return readFromDefiningJar(factoryClass, candidateJars,
-                jarFile -> ManifestVersions.fromManifest(jarFile, 
packagePath));
+        try {
+            return readFromDefiningJar(factoryClass, candidateJars,
+                    jarFile -> ManifestVersions.fromManifest(jarFile, 
packagePath));
+        } catch (IOException ignored) {
+            // Display-only metadata may be unavailable without making a 
verified plugin unloadable.
+            return null;
+        }
     }
 
     /** The plugin API version a jar declares, as a MANIFEST main attribute. 
Null when absent. */
     private String readManifestMainAttribute(Class<?> factoryClass, List<Path> 
candidateJars,
-            String attributeName) {
+            String attributeName) throws IOException {
         return readFromDefiningJar(factoryClass, candidateJars,
                 jarFile -> ManifestVersions.mainAttribute(jarFile, 
attributeName));
     }
diff --git 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/ManifestVersions.java
 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/ManifestVersions.java
index 82c28c3710d..18c986c8a44 100644
--- 
a/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/ManifestVersions.java
+++ 
b/fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/ManifestVersions.java
@@ -91,7 +91,11 @@ final class ManifestVersions {
         return lastDot < 0 ? null : className.substring(0, 
lastDot).replace('.', '/') + "/";
     }
 
-    /** Jar file that defined the class per its protection domain, or null. */
+    /**
+     * Defining jar location from the protection domain, or null for class 
directories or unavailable
+     * provenance. Keep the location even if the jar was removed after class 
loading, so callers report
+     * the read failure instead of falling back to an unrelated candidate with 
the same class entry.
+     */
     static Path jarOf(Class<?> clazz) {
         try {
             CodeSource codeSource = 
clazz.getProtectionDomain().getCodeSource();
@@ -99,7 +103,7 @@ final class ManifestVersions {
                 return null;
             }
             Path path = Paths.get(codeSource.getLocation().toURI());
-            return Files.isRegularFile(path) ? path : null;
+            return Files.isDirectory(path) ? null : path;
         } catch (URISyntaxException | RuntimeException e) {
             return null;
         }
diff --git 
a/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/ApiVersionGateTest.java
 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/ApiVersionGateTest.java
index fa4ccb24151..06ed183f926 100644
--- 
a/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/ApiVersionGateTest.java
+++ 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/ApiVersionGateTest.java
@@ -21,8 +21,9 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 /**
- * The decision rule itself: major must match, minor and patch never matter, 
and anything a plugin did not
- * clearly declare is refused.
+ * The decision rule itself: major must match and minor and patch never 
matter. Strict gates refuse missing
+ * declarations; an explicitly configured legacy gate accepts only a genuinely 
absent declaration for the
+ * documented legacy major.
  *
  * <p>Each case here encodes a promise made to plugin authors, not just 
current behavior. The two-way minor
  * cases are the load-bearing ones: they are only sound because "major" is 
defined to cover <em>additions</em>
@@ -37,6 +38,8 @@ class ApiVersionGateTest {
 
     private static final ApiVersionGate GATE =
             ApiVersionGate.forFamily("test", ApiVersionGateTest.class);
+    private static final ApiVersionGate LEGACY_GATE =
+            ApiVersionGate.forFamilyAllowingUnversioned("test", 
ApiVersionGateTest.class, "7.0");
 
     @Test
     void testKernelVersionComesFromTheFilteredResource() {
@@ -116,6 +119,36 @@ class ApiVersionGateTest {
         Assertions.assertNotNull(GATE.rejectionReason("   "));
     }
 
+    @Test
+    void testDocumentedLegacyUnversionedPluginIsAcceptedWithWarning() {
+        Assertions.assertNull(LEGACY_GATE.rejectionReason(null));
+        String warning = LEGACY_GATE.acceptanceWarning(null);
+        Assertions.assertNotNull(warning);
+        Assertions.assertTrue(warning.contains("legacy TEST plugin API 7.0"), 
warning);
+
+        Assertions.assertNotNull(LEGACY_GATE.rejectionReason(""),
+                "an explicit empty declaration is malformed, not a legacy 
artifact");
+        Assertions.assertNull(LEGACY_GATE.acceptanceWarning("7.0"),
+                "a versioned plugin must not be mislabeled as legacy");
+    }
+
+    @Test
+    void testLegacyUnversionedPluginIsRejectedAfterKernelMajorUpgrade() {
+        ApiVersionGate oldLegacy =
+                ApiVersionGate.forFamilyAllowingUnversioned("test", 
ApiVersionGateTest.class, "6.9");
+        String reason = oldLegacy.rejectionReason(null);
+        Assertions.assertNotNull(reason);
+        Assertions.assertTrue(reason.contains("6.9") && 
reason.contains("7.2"), reason);
+        Assertions.assertNull(oldLegacy.acceptanceWarning(null));
+    }
+
+    @Test
+    void testMalformedLegacyVersionConfigurationFailsLoudly() {
+        Assertions.assertThrows(IllegalArgumentException.class,
+                () -> ApiVersionGate.forFamilyAllowingUnversioned(
+                        "test", ApiVersionGateTest.class, "legacy-v7"));
+    }
+
     @Test
     void testMalformedDeclarationIsRejectedRatherThanTruncated() {
         // "7.x" must not be read as major 7. Silently salvaging a major out 
of a typo would admit a plugin
diff --git 
a/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManagerApiVersionTest.java
 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManagerApiVersionTest.java
index 3ff1f1964d6..73b3fad9ac2 100644
--- 
a/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManagerApiVersionTest.java
+++ 
b/fe/fe-extension-loader/src/test/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManagerApiVersionTest.java
@@ -25,9 +25,17 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
 import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.security.CodeSource;
+import java.security.ProtectionDomain;
+import java.security.cert.Certificate;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
 
 /**
  * The gate as the directory loader applies it: what a real plugin jar 
declares in its MANIFEST decides
@@ -45,6 +53,8 @@ import java.util.Collections;
 class DirectoryPluginRuntimeManagerApiVersionTest {
 
     private static final ApiVersionGate GATE = 
DirectoryPluginRuntimeManagerMetadataTest.TEST_GATE;
+    private static final ApiVersionGate LEGACY_GATE = 
ApiVersionGate.forFamilyAllowingUnversioned(
+            "test", DirectoryPluginRuntimeManagerApiVersionTest.class, "7.0");
 
     @TempDir
     Path tempDir;
@@ -80,6 +90,70 @@ class DirectoryPluginRuntimeManagerApiVersionTest {
         assertRefused(report, "seven-point-two", "7.2");
     }
 
+    @Test
+    void testReadableUnversionedLegacyPluginLoads() throws IOException {
+        Path root = tempDir.resolve("legacy");
+        DirectoryPluginRuntimeManagerMetadataTest.createPluginJar(
+                root.resolve("metadata-test").resolve("metadata-test.jar"),
+                MetadataTestPluginFactory.class, "3.1.4", null);
+        DirectoryPluginRuntimeManager<PluginFactory> manager = new 
DirectoryPluginRuntimeManager<>();
+        LoadReport<PluginFactory> report = 
manager.loadAll(Collections.singletonList(root),
+                Thread.currentThread().getContextClassLoader(), 
PluginFactory.class, null, LEGACY_GATE);
+
+        Assertions.assertEquals(1, report.getSuccesses().size(), () -> 
describe(report));
+        Assertions.assertEquals("3.1.4", 
report.getSuccesses().get(0).getVersion());
+        manager.discard("metadata-test");
+    }
+
+    @Test
+    void testLegacyGateStillRejectsExplicitInvalidDeclarations() throws 
IOException {
+        for (String version : Arrays.asList("", "seven-point-two", "6.0")) {
+            Path root = tempDir.resolve("invalid-legacy-" + version);
+            DirectoryPluginRuntimeManagerMetadataTest.createPluginJar(
+                    root.resolve("metadata-test").resolve("metadata-test.jar"),
+                    MetadataTestPluginFactory.class, "3.1.4", version);
+            LoadReport<PluginFactory> report = new 
DirectoryPluginRuntimeManager<PluginFactory>().loadAll(
+                    Collections.singletonList(root), 
Thread.currentThread().getContextClassLoader(),
+                    PluginFactory.class, null, LEGACY_GATE);
+
+            assertRefused(report, "Doris-Test-Plugin-Api-Version", "7.2");
+        }
+    }
+
+    @Test
+    void testUnreadableDefiningJarIsRefusedInsteadOfUsingAnotherCandidate() 
throws IOException {
+        Path definingJar = tempDir.resolve("unreadable.jar");
+        Files.write(definingJar, "not a jar".getBytes(StandardCharsets.UTF_8));
+
+        assertRefused(loadWithFactoryCodeSource(definingJar, true),
+                "Failed to read Doris-Test-Plugin-Api-Version", 
"metadata-test");
+    }
+
+    @Test
+    void testUnreadableManifestIsRefusedAsApiVersionFailure() throws 
IOException {
+        Path definingJar = tempDir.resolve("bad-manifest.jar");
+        try (JarOutputStream jar = new 
JarOutputStream(Files.newOutputStream(definingJar))) {
+            jar.putNextEntry(new JarEntry("META-INF/MANIFEST.MF"));
+            jar.write("invalid manifest 
header\n".getBytes(StandardCharsets.UTF_8));
+            jar.closeEntry();
+        }
+
+        assertRefused(loadWithFactoryCodeSource(definingJar, false),
+                "Failed to read Doris-Test-Plugin-Api-Version", "invalid 
header");
+    }
+
+    @Test
+    void testMissingDefiningJarIsRefusedInsteadOfBeingTreatedAsLegacy() throws 
IOException {
+        
assertRefused(loadWithFactoryCodeSource(tempDir.resolve("missing.jar"), true),
+                "Failed to read Doris-Test-Plugin-Api-Version", "missing.jar");
+    }
+
+    @Test
+    void testUnresolvedDefiningJarIsRefusedInsteadOfBeingTreatedAsLegacy() 
throws IOException {
+        assertRefused(loadWithFactoryCodeSource(null, false),
+                "Failed to read Doris-Test-Plugin-Api-Version", "Cannot locate 
defining jar");
+    }
+
     @Test
     void testRefusedPluginLeavesNoTraceAndRepeatsIdentically() throws 
IOException {
         Path root = tempDir.resolve("repeat");
@@ -127,6 +201,51 @@ class DirectoryPluginRuntimeManagerApiVersionTest {
                 GATE);
     }
 
+    private LoadReport<PluginFactory> loadWithFactoryCodeSource(Path 
definingJar, boolean includeFactory)
+            throws IOException {
+        Path root = tempDir.resolve("lookup-failure");
+        Path pluginJar = 
root.resolve("metadata-test").resolve("metadata-test.jar");
+        if (includeFactory) {
+            // This readable candidate must not hide a failure to read the 
actual defining jar.
+            DirectoryPluginRuntimeManagerMetadataTest.createPluginJar(
+                    pluginJar, MetadataTestPluginFactory.class, "3.1.4", null);
+        } else {
+            Files.createDirectories(pluginJar.getParent());
+            try (JarOutputStream jar = new 
JarOutputStream(Files.newOutputStream(pluginJar))) {
+                jar.putNextEntry(new JarEntry("META-INF/services/" + 
PluginFactory.class.getName()));
+                jar.write((MetadataTestPluginFactory.class.getName() + 
"\n").getBytes(StandardCharsets.UTF_8));
+                jar.closeEntry();
+            }
+        }
+        // Define the factory before the separate manifest lookup, with 
controlled provenance. This makes
+        // missing/unreadable metadata deterministic without racing filesystem 
mutation against loading.
+        ClassLoader parent = new FactoryClassLoader(definingJar);
+        ClassLoadingPolicy policy = new ClassLoadingPolicy(
+                
Collections.singletonList(MetadataTestPluginFactory.class.getName()));
+        DirectoryPluginRuntimeManager<PluginFactory> manager = new 
DirectoryPluginRuntimeManager<>();
+        LoadReport<PluginFactory> report = 
manager.loadAll(Collections.singletonList(root),
+                parent, PluginFactory.class, policy, LEGACY_GATE);
+
+        Assertions.assertTrue(manager.list().isEmpty(), "failed verification 
must not publish a handle");
+        Assertions.assertFalse(manager.get("metadata-test").isPresent());
+        return report;
+    }
+
+    private static class FactoryClassLoader extends ClassLoader {
+        FactoryClassLoader(Path definingJar) throws IOException {
+            super(Thread.currentThread().getContextClassLoader());
+            String name = MetadataTestPluginFactory.class.getName();
+            String classEntry = name.replace('.', '/') + ".class";
+            try (InputStream in = 
MetadataTestPluginFactory.class.getResourceAsStream("/" + classEntry)) {
+                Assertions.assertNotNull(in);
+                byte[] bytes = in.readAllBytes();
+                CodeSource source = definingJar == null ? null
+                        : new CodeSource(definingJar.toUri().toURL(), 
(Certificate[]) null);
+                defineClass(name, bytes, 0, bytes.length, new 
ProtectionDomain(source, null));
+            }
+        }
+    }
+
     private static void assertRefused(LoadReport<PluginFactory> report, 
String... mustMention) {
         Assertions.assertTrue(report.getSuccesses().isEmpty(),
                 "an incompatible plugin must never be published: " + 
describe(report));


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to