PlexusContainerCapsuleFactoryTest.java

/*
 * 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.maven.cling.invoker;

import javax.tools.ToolProvider;

import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;

import org.apache.maven.api.cli.ParserRequest;
import org.apache.maven.api.cli.mvn.MavenOptions;
import org.apache.maven.classrealm.ClassRealmManager;
import org.apache.maven.cling.invoker.mvn.MavenContext;
import org.apache.maven.cling.invoker.mvn.MavenInvoker;
import org.apache.maven.cling.invoker.mvn.MavenParser;
import org.apache.maven.cling.logging.Slf4jConfiguration;
import org.apache.maven.extension.internal.CoreExports;
import org.apache.maven.jline.JLineMessageBuilderFactory;
import org.apache.maven.model.Plugin;
import org.codehaus.plexus.DefaultPlexusContainer;
import org.codehaus.plexus.classworlds.ClassWorld;
import org.codehaus.plexus.classworlds.realm.ClassRealm;
import org.eclipse.aether.artifact.Artifact;
import org.eclipse.aether.artifact.DefaultArtifact;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.slf4j.LoggerFactory;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

class PlexusContainerCapsuleFactoryTest {
    @TempDir
    Path directory;

    private ClassWorld world;
    private ClassLoader originalClassLoader;
    private DefaultPlexusContainer container;

    @BeforeEach
    void setUp() {
        originalClassLoader = Thread.currentThread().getContextClassLoader();
        world = new ClassWorld("plexus.core", getClass().getClassLoader());
    }

    @AfterEach
    void tearDown() throws Exception {
        try {
            if (container != null) {
                container.dispose();
            }
        } finally {
            Thread.currentThread().setContextClassLoader(originalClassLoader);
            world.close();
        }
    }

    @ParameterizedTest
    @ValueSource(booleans = {false, true})
    void exportsPackagesToPlugins(boolean jar) throws Exception {
        Path extension = extension(jar, true);
        createContainer(List.of("-Dmaven.ext.class.path=" + extension));

        ClassRealm pluginRealm = pluginRealm(List.of());
        Class<?> exported = pluginRealm.loadClass("extension.api.Exported");
        assertSame(container.getContainerRealm(), exported.getClassLoader());
        assertThrows(ClassNotFoundException.class, () -> pluginRealm.loadClass("extension.internal.Private"));

        ClassRealm coreRealm = world.getClassRealm("plexus.core");
        assertEquals(0, coreRealm.getURLs().length);
        assertThrows(ClassNotFoundException.class, () -> coreRealm.loadClass("extension.api.Exported"));
        ClassRealm otherExtension = coreRealm.createChildRealm("coreExtension>other");
        assertThrows(ClassNotFoundException.class, () -> otherExtension.loadClass("extension.api.Exported"));
        assertTrue(container.lookup(CoreExports.class).getExportedPackages().containsKey("org.apache.maven.api"));
    }

    @ParameterizedTest
    @ValueSource(booleans = {false, true})
    void filtersExportedArtifactsFromPlugins(boolean jar) throws Exception {
        Path extension = extension(jar, true);
        createContainer(List.of("-Dmaven.ext.class.path=" + extension));

        Path dependency = Files.createDirectory(directory.resolve("dependency"));
        Path retained = Files.createDirectory(directory.resolve("retained"));
        ClassRealm pluginRealm = pluginRealm(List.of(
                new DefaultArtifact("extension:api:jar:2.0").setFile(dependency.toFile()),
                new DefaultArtifact("extension:retained:jar:1.0").setFile(retained.toFile())));
        assertArrayEquals(new java.net.URL[] {retained.toUri().toURL()}, pluginRealm.getURLs());
        assertTrue(container.lookup(CoreExports.class).getExportedArtifacts().contains("org.apache.maven:maven-core"));
    }

    @ParameterizedTest
    @ValueSource(booleans = {false, true})
    void descriptorIsOptional(boolean jar) throws Exception {
        Path extension = extension(jar, false);
        createContainer(List.of("-Dmaven.ext.class.path=" + extension));

        assertSame(
                container.getContainerRealm(),
                container
                        .getContainerRealm()
                        .loadClass("extension.api.Exported")
                        .getClassLoader());
        ClassRealm pluginRealm =
                pluginRealm(List.of(new DefaultArtifact("extension:api:jar:2.0").setFile(extension.toFile())));
        assertArrayEquals(new java.net.URL[] {extension.toUri().toURL()}, pluginRealm.getURLs());
        assertFalse(container.lookup(CoreExports.class).getExportedPackages().containsKey("extension.api"));
    }

    @Test
    void keepsCoreRealmWithoutExtensions() throws Exception {
        createContainer(List.of());
        assertSame(world.getClassRealm("plexus.core"), container.getContainerRealm());
    }

    @Test
    void mergesClassPathDescriptors() throws Exception {
        Path withoutDescriptor = extension(true, false);
        Path jar = extension(true, true);
        Path classes = extension(false, true);
        Files.writeString(classes.resolve("META-INF/maven/extension.xml"), """
                <extension>
                  <exportedPackages><exportedPackage>extension.internal</exportedPackage></exportedPackages>
                  <exportedArtifacts><exportedArtifact>extension:internal</exportedArtifact></exportedArtifacts>
                </extension>
                """);
        createContainer(List.of("-Dmaven.ext.class.path=" + withoutDescriptor + File.pathSeparator + jar
                + File.pathSeparator + classes));

        CoreExports exports = container.lookup(CoreExports.class);
        assertTrue(exports.getExportedArtifacts().containsAll(List.of("extension:api", "extension:internal")));
        ClassRealm pluginRealm = pluginRealm(List.of());
        assertSame(
                container.getContainerRealm(),
                pluginRealm.loadClass("extension.api.Exported").getClassLoader());
        assertSame(
                container.getContainerRealm(),
                pluginRealm.loadClass("extension.internal.Private").getClassLoader());
    }

    @Test
    void ignoresMalformedDescriptorWithoutSkippingLaterEntries() throws Exception {
        Path malformed = extension(false, true);
        Files.writeString(malformed.resolve("META-INF/maven/extension.xml"), "<invalid/>");
        Path valid = extension(true, true);
        createContainer(List.of("-Dmaven.ext.class.path=" + malformed + File.pathSeparator + valid));

        assertTrue(container.lookup(CoreExports.class).getExportedArtifacts().contains("extension:api"));
    }

    @Test
    void processesEquivalentClassPathEntriesOnce() throws Exception {
        Path extension = extension(true, true);
        Path alias = directory.relativize(extension);
        MavenContext context =
                createContainer(List.of("-Dmaven.ext.class.path=" + extension + File.pathSeparator + alias));

        assertEquals(List.of(extension), new PlexusContainerCapsuleFactory<MavenContext>().parseExtClasspath(context));
        assertEquals(1, container.getContainerRealm().getURLs().length);
        assertTrue(container.lookup(CoreExports.class).getExportedArtifacts().contains("extension:api"));
    }

    @Test
    void processesSymlinkedClassPathEntryOnce() throws Exception {
        Path extension = extension(true, true);
        Path alias = directory.resolve("extension-alias.jar");
        try {
            Files.createSymbolicLink(alias, extension);
        } catch (UnsupportedOperationException | java.io.IOException | SecurityException e) {
            assumeTrue(false, "Symbolic links are unavailable: " + e.getMessage());
        }
        MavenContext context =
                createContainer(List.of("-Dmaven.ext.class.path=" + extension + File.pathSeparator + alias));

        assertEquals(List.of(extension), new PlexusContainerCapsuleFactory<MavenContext>().parseExtClasspath(context));
    }

    private MavenContext createContainer(List<String> args) throws Exception {
        var request = new MavenParser()
                .parseInvocation(ParserRequest.mvn(args, new JLineMessageBuilderFactory())
                        .cwd(directory)
                        .userHome(directory)
                        .embedded(true)
                        .build());
        var context =
                new MavenContext(request, true, (MavenOptions) request.options().orElseThrow());
        context.loggerFactory = LoggerFactory.getILoggerFactory();
        context.loggerLevel = Slf4jConfiguration.Level.INFO;
        var invoker = new MavenInvoker(
                ProtoLookup.builder().addMapping(ClassWorld.class, world).build(), null);
        container = new PlexusContainerCapsuleFactory<MavenContext>().container(invoker, context, (i, c) -> List.of());
        return context;
    }

    private ClassRealm pluginRealm(List<Artifact> artifacts) throws Exception {
        Plugin plugin = new Plugin();
        plugin.setGroupId("extension");
        plugin.setArtifactId("test-plugin");
        plugin.setVersion("1.0");
        ClassRealmManager manager = container.lookup(ClassRealmManager.class);
        return manager.createPluginRealm(plugin, null, null, Map.of("", manager.getMavenApiRealm()), artifacts);
    }

    private Path extension(boolean jar, boolean descriptor) throws Exception {
        Path root = Files.createTempDirectory(directory, "extension");
        Path classes = Files.createDirectory(root.resolve("classes"));
        for (String name : List.of("extension.api.Exported", "extension.internal.Private")) {
            int separator = name.lastIndexOf('.');
            Path source = root.resolve(name.substring(separator + 1) + ".java");
            Files.writeString(
                    source,
                    "package " + name.substring(0, separator) + "; public class " + name.substring(separator + 1)
                            + " {}");
            assertEquals(
                    0,
                    ToolProvider.getSystemJavaCompiler()
                            .run(null, null, null, "-d", classes.toString(), source.toString()));
        }
        if (descriptor) {
            Path xml = classes.resolve("META-INF/maven/extension.xml");
            Files.createDirectories(xml.getParent());
            Files.writeString(xml, """
                    <extension>
                      <exportedPackages>
                        <exportedPackage>extension.api</exportedPackage>
                      </exportedPackages>
                      <exportedArtifacts>
                        <exportedArtifact>extension:api</exportedArtifact>
                      </exportedArtifacts>
                    </extension>
                    """);
        }
        if (!jar) {
            return classes;
        }
        Path archive = root.resolve("extension-1.0.jar");
        try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(archive));
                var files = Files.walk(classes)) {
            for (Path file : files.filter(Files::isRegularFile).toList()) {
                output.putNextEntry(
                        new JarEntry(classes.relativize(file).toString().replace('\\', '/')));
                Files.copy(file, output);
                output.closeEntry();
            }
        }
        return archive;
    }
}