diff --git a/src/main/java/org/codehaus/plexus/classworlds/realm/ClassRealm.java b/src/main/java/org/codehaus/plexus/classworlds/realm/ClassRealm.java index bbeacee1..7edf14c4 100644 --- a/src/main/java/org/codehaus/plexus/classworlds/realm/ClassRealm.java +++ b/src/main/java/org/codehaus/plexus/classworlds/realm/ClassRealm.java @@ -18,14 +18,21 @@ import java.io.IOException; import java.io.PrintStream; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Enumeration; import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; @@ -438,6 +445,214 @@ public Enumeration loadResourcesFromParent(String name) { return null; } + // --------------------------------------------------------------------------------------------- + // Packages are visible exactly where classes in them are: own class path, imports, parent + // --------------------------------------------------------------------------------------------- + + /** + * Realms whose package lookup is running on the current thread. Imports and parents may form cycles, which class + * loading never walks for a missing package but {@link Package#getPackage(String)} does. + */ + private static final ThreadLocal> PACKAGE_LOOKUPS = new ThreadLocal<>(); + + @Override + @SuppressWarnings("deprecation") + protected Package getPackage(String name) { + if (!enterPackageLookup()) { + return null; + } + try { + Package pkg = super.getPackage(name); + + for (String className : getClassNamesIn(name, foreignImports)) { + if (pkg != null) { + break; + } + ClassLoader importClassLoader = getImportClassLoader(className); + if (importClassLoader != null) { + pkg = getPackage(importClassLoader, name); + } + } + + ClassLoader parent = getParentClassLoader(); + if (pkg == null && parent != null && isPackageImportedFromParent(name)) { + pkg = getPackage(parent, name); + } + + return pkg; + } finally { + exitPackageLookup(); + } + } + + @Override + protected Package[] getPackages() { + if (!enterPackageLookup()) { + return new Package[0]; + } + try { + Map packages = new LinkedHashMap<>(); + + for (Package pkg : super.getPackages()) { + packages.putIfAbsent(pkg.getName(), pkg); + } + + for (ClassLoader importClassLoader : getImportClassLoaders()) { + for (Package pkg : getPackages(importClassLoader)) { + if (isPackageImportedFrom(pkg.getName(), importClassLoader)) { + packages.putIfAbsent(pkg.getName(), pkg); + } + } + } + + ClassLoader parent = getParentClassLoader(); + if (parent != null) { + for (Package pkg : getPackages(parent)) { + if (isPackageImportedFromParent(pkg.getName())) { + packages.putIfAbsent(pkg.getName(), pkg); + } + } + } + + return packages.values().toArray(new Package[0]); + } finally { + exitPackageLookup(); + } + } + + /** + * Class names standing for every way a class in the package can be routed by the given imports: one no class can + * have, which only package imports match, plus each imported name that is a class directly inside the package. + */ + private static Collection getClassNamesIn(String packageName, Collection imports) { + Collection classNames = new ArrayList<>(); + classNames.add(packageName + ".-"); + if (imports != null) { + for (Entry entry : imports) { + String importName = entry.getPackageName(); + int index = importName.lastIndexOf('.'); + if (index > 0 + && !importName.endsWith(".*") + && importName.substring(0, index).equals(packageName)) { + classNames.add(importName); + } + } + } + return classNames; + } + + private boolean isPackageImportedFrom(String packageName, ClassLoader importClassLoader) { + for (String className : getClassNamesIn(packageName, foreignImports)) { + if (getImportClassLoader(className) == importClassLoader) { + return true; + } + } + return false; + } + + private boolean isPackageImportedFromParent(String packageName) { + for (String className : getClassNamesIn(packageName, parentImports)) { + if (isImportedFromParent(className)) { + return true; + } + } + return false; + } + + private Collection getImportClassLoaders() { + Collection classLoaders = new LinkedHashSet<>(); + for (Entry entry : foreignImports) { + if (entry.getClassLoader() != null) { + classLoaders.add(entry.getClassLoader()); + } + } + return classLoaders; + } + + private boolean enterPackageLookup() { + Set lookups = PACKAGE_LOOKUPS.get(); + if (lookups == null) { + lookups = Collections.newSetFromMap(new IdentityHashMap<>()); + PACKAGE_LOOKUPS.set(lookups); + } + return lookups.add(this); + } + + private void exitPackageLookup() { + Set lookups = PACKAGE_LOOKUPS.get(); + lookups.remove(this); + if (lookups.isEmpty()) { + PACKAGE_LOOKUPS.remove(); + } + } + + // ClassLoader.getDefinedPackage(s) are public since Java 9; on Java 8 only the protected getPackage(s) exist + private static final Method GET_DEFINED_PACKAGE = findMethod("getDefinedPackage", String.class); + + private static final Method GET_DEFINED_PACKAGES = findMethod("getDefinedPackages"); + + private static final Method GET_PACKAGE = + GET_DEFINED_PACKAGE == null ? findMethod("getPackage", String.class) : null; + + private static final Method GET_PACKAGES = GET_DEFINED_PACKAGES == null ? findMethod("getPackages") : null; + + private static Method findMethod(String name, Class... parameterTypes) { + try { + Method method = ClassLoader.class.getDeclaredMethod(name, parameterTypes); + if (!Modifier.isPublic(method.getModifiers())) { + method.setAccessible(true); + } + return method; + } catch (NoSuchMethodException | RuntimeException e) { + return null; + } + } + + private static Object invoke(Method method, ClassLoader classLoader, Object... args) { + if (method == null) { + return null; + } + try { + return method.invoke(classLoader, args); + } catch (ReflectiveOperationException | RuntimeException e) { + return null; + } + } + + private static Package getPackage(ClassLoader classLoader, String name) { + if (classLoader instanceof ClassRealm) { + return ((ClassRealm) classLoader).getPackage(name); + } + if (GET_DEFINED_PACKAGE == null) { + return (Package) invoke(GET_PACKAGE, classLoader, name); + } + for (ClassLoader loader = classLoader; loader != null; loader = loader.getParent()) { + Package pkg = (Package) invoke(GET_DEFINED_PACKAGE, loader, name); + if (pkg != null) { + return pkg; + } + } + return null; + } + + private static Package[] getPackages(ClassLoader classLoader) { + if (classLoader instanceof ClassRealm) { + return ((ClassRealm) classLoader).getPackages(); + } + if (GET_DEFINED_PACKAGES == null) { + Package[] packages = (Package[]) invoke(GET_PACKAGES, classLoader); + return packages != null ? packages : new Package[0]; + } + Collection packages = new ArrayList<>(); + for (ClassLoader loader = classLoader; loader != null; loader = loader.getParent()) { + Package[] defined = (Package[]) invoke(GET_DEFINED_PACKAGES, loader); + if (defined != null) { + Collections.addAll(packages, defined); + } + } + return packages.toArray(new Package[0]); + } + static { registerAsParallelCapable(); } diff --git a/src/test/java/org/codehaus/plexus/classworlds/realm/ClassRealmPackageTest.java b/src/test/java/org/codehaus/plexus/classworlds/realm/ClassRealmPackageTest.java new file mode 100644 index 00000000..fa8db6b2 --- /dev/null +++ b/src/test/java/org/codehaus/plexus/classworlds/realm/ClassRealmPackageTest.java @@ -0,0 +1,202 @@ +/* + * 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.codehaus.plexus.classworlds.realm; + +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import org.codehaus.plexus.classworlds.AbstractClassWorldsTestCase; +import org.codehaus.plexus.classworlds.ClassWorld; +import org.codehaus.plexus.classworlds.realm.probe.PackageProbe; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Packages are visible from a realm exactly when classes in them are: through its own class path, an import, or the + * parent when the parent imports allow it. + */ +class ClassRealmPackageTest extends AbstractClassWorldsTestCase { + private ClassWorld world; + + private ClassRealm realmA; + + private Package packageA; + + @TempDir + Path probeDir; + + @BeforeEach + void setUp() throws Exception { + world = new ClassWorld(); + realmA = world.newRealm("realmA", null); + realmA.addURL(getJarUrl("a.jar")); + packageA = realmA.loadClass("a.A").getPackage(); + assertNotNull(packageA); + + String resource = PackageProbe.class.getName().replace('.', '/') + ".class"; + Path target = probeDir.resolve(resource); + Files.createDirectories(target.getParent()); + try (InputStream in = PackageProbe.class.getClassLoader().getResourceAsStream(resource)) { + Files.copy(in, target); + } + } + + @Test + void packageGetPackageSeesForeignImport() throws Exception { + ClassRealm realmB = newProbeRealm("realmB"); + realmB.importFrom("realmA", "a"); + + assertSame(packageA, probeGetPackage(realmB, "a")); + } + + @Test + void packageGetPackageSeesParentImport() throws Exception { + ClassRealm child = newProbeRealm("child"); + child.setParentRealm(realmA); + child.importFromParent("a"); + + assertSame(packageA, probeGetPackage(child, "a")); + } + + @Test + void packageNotImportedStaysInvisible() throws Exception { + ClassRealm realmB = newProbeRealm("realmB"); + realmB.importFrom("realmA", "b"); + + assertNull(probeGetPackage(realmB, "a")); + assertNull(realmB.getPackage("a")); + } + + @Test + void parentPackageOutsideParentImportsStaysInvisible() throws Exception { + ClassRealm child = newProbeRealm("child"); + child.setParentRealm(realmA); + child.importFromParent("b"); + + assertNull(child.getPackage("a")); + assertFalse(names(child.getPackages()).contains("a")); + } + + @Test + void filteredRealmDoesNotExposeFilteredPackage() throws Exception { + ClassRealm filtered = world.newRealm("filtered", null, name -> !name.startsWith("a/")); + filtered.addURL(getJarUrl("a.jar")); + ClassRealm realmB = world.newRealm("realmB", null); + realmB.importFrom("filtered", "a"); + + assertNull(realmB.getPackage("a")); + } + + @Test + void exactClassImportExposesItsPackage() throws Exception { + world.newRealm("empty", null); + ClassRealm realmB = world.newRealm("realmB", null); + realmB.importFrom("realmA", "a.A"); + realmB.importFrom("empty", "a"); + + assertSame(realmA, realmB.loadClass("a.A").getClassLoader()); + assertSame(packageA, realmB.getPackage("a")); + assertTrue(Arrays.asList(realmB.getPackages()).contains(packageA)); + } + + @Test + void moreSpecificImportShadowsOnlyItsClass() throws Exception { + realmA.loadClass("a.Aa"); + world.newRealm("empty", null); + ClassRealm realmB = world.newRealm("realmB", null); + realmB.importFrom("realmA", "a"); + realmB.importFrom("empty", "a.A"); + + // a.A is routed to the empty realm, but a.Aa still loads from realmA, so the package stays visible + assertThrows(ClassNotFoundException.class, () -> realmB.loadClass("a.A")); + assertSame(realmA, realmB.loadClass("a.Aa").getClassLoader()); + assertSame(packageA, realmB.getPackage("a")); + } + + @Test + void exactClassParentImportExposesItsPackage() throws Exception { + ClassRealm child = world.newRealm("child", null); + child.setParentRealm(realmA); + child.importFromParent("a.A"); + + assertSame(realmA, child.loadClass("a.A").getClassLoader()); + assertSame(packageA, child.getPackage("a")); + assertTrue(Arrays.asList(child.getPackages()).contains(packageA)); + } + + @Test + void getPackagesListsImportedPackagesOnly() throws Exception { + ClassRealm realmC = world.newRealm("realmC", null); + realmC.addURL(getJarUrl("b.jar")); + realmC.loadClass("b.B"); + ClassRealm realmB = newProbeRealm("realmB"); + realmB.importFrom("realmA", "a"); + + assertTrue(Arrays.asList(probeGetPackages(realmB)).contains(packageA)); + assertFalse(names(realmB.getPackages()).contains("b")); + } + + @Test + void importCycleDoesNotRecurse() throws Exception { + ClassRealm realmB = world.newRealm("realmB", null); + ClassRealm realmC = world.newRealm("realmC", null); + realmB.importFrom("realmC", "missing"); + realmC.importFrom("realmB", "missing"); + realmB.setParentRealm(realmC); + realmC.setParentRealm(realmB); + + assertNull(realmB.getPackage("missing")); + assertFalse(names(realmB.getPackages()).contains("missing")); + } + + private ClassRealm newProbeRealm(String id) throws Exception { + ClassRealm realm = world.newRealm(id, null); + realm.addURL(probeDir.toUri().toURL()); + return realm; + } + + private static Package probeGetPackage(ClassRealm realm, String name) throws Exception { + return (Package) probeMethod(realm, "getPackage", String.class).invoke(null, name); + } + + private static Package[] probeGetPackages(ClassRealm realm) throws Exception { + return (Package[]) probeMethod(realm, "getPackages").invoke(null); + } + + private static Method probeMethod(ClassRealm realm, String name, Class... parameterTypes) throws Exception { + Class probe = realm.loadClass(PackageProbe.class.getName()); + assertSame(realm, probe.getClassLoader(), "probe must be defined by the realm under test"); + return probe.getMethod(name, parameterTypes); + } + + private static java.util.List names(Package[] packages) { + return Arrays.stream(packages).map(Package::getName).collect(java.util.stream.Collectors.toList()); + } +} diff --git a/src/test/java/org/codehaus/plexus/classworlds/realm/probe/PackageProbe.java b/src/test/java/org/codehaus/plexus/classworlds/realm/probe/PackageProbe.java new file mode 100644 index 00000000..61829ec1 --- /dev/null +++ b/src/test/java/org/codehaus/plexus/classworlds/realm/probe/PackageProbe.java @@ -0,0 +1,36 @@ +/* + * 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.codehaus.plexus.classworlds.realm.probe; + +/** + * Calls {@link Package#getPackage(String)} from inside a realm, which resolves the name through the class loader that + * defined this class. Commons JEXL checks its import pragma this way. + */ +public final class PackageProbe { + private PackageProbe() {} + + @SuppressWarnings("deprecation") + public static Package getPackage(String name) { + return Package.getPackage(name); + } + + public static Package[] getPackages() { + return Package.getPackages(); + } +}