[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.
This commit is contained in:
Calvin Kirs authored and GitHub committed 2026-10-30 22:19:47 +08:00
1 parent 374d87d81d
commit fb61aeeb63
7 files changed
+292 -47

No files matched your search

@@ -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<>();
@@ -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
@@ -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.
*
@@ -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));
}
@@ -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;
}
@@ -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
@@ -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));