UnpackZipMojo.java

package network.ike.plugin;

import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

/**
 * Download a zip archive from a URL and unpack it.
 *
 * <p>Uses {@link java.util.zip.ZipInputStream} directly, bypassing
 * Plexus Archiver. This avoids the 1800+ case-sensitivity warnings
 * that Plexus emits on macOS when a zip contains entries that differ
 * only by case (e.g., the DocBook XSL distribution).
 *
 * <p>The downloaded zip is cached in a local directory so subsequent
 * builds skip the download when the cached file already exists.
 *
 * <p>This goal replaces both {@code download-maven-plugin:wget} and
 * the {@code exec-maven-plugin} call to system {@code unzip} in the
 * docbook-xsl build.
 *
 * <p>Usage:
 * <pre>{@code
 * <execution>
 *     <id>download-and-unpack-docbook-xsl</id>
 *     <phase>generate-resources</phase>
 *     <goals><goal>unpack-zip</goal></goals>
 *     <configuration>
 *         <url>https://github.com/.../docbook-xsl-1.79.2.zip</url>
 *         <outputDirectory>${project.build.directory}</outputDirectory>
 *     </configuration>
 * </execution>
 * }</pre>
 *
 * @since 100
 */
@Mojo(name = "unpack-zip",
      defaultPhase = "generate-resources")
public class UnpackZipMojo implements org.apache.maven.api.plugin.Mojo {

    @org.apache.maven.api.di.Inject
    private org.apache.maven.api.plugin.Log log;
    /**
     * Access the Maven logger.
     *
     * @return the logger
     */
    protected org.apache.maven.api.plugin.Log getLog() { return log; }

    /**
     * URL of the zip archive to download.
     */
    @Parameter(property = "ike.unpack.url", required = true)
    String url;

    /**
     * Directory to unpack into. The zip's internal directory structure
     * is preserved beneath this directory.
     */
    @Parameter(property = "ike.unpack.outputDirectory", required = true)
    String outputDirectory;

    /**
     * Local cache directory for downloaded archives. If the zip file
     * already exists here, the download is skipped.
     *
     * <p>Defaults to {@code ~/.m2/repository/.cache/ike-maven-plugin/}.
     */
    @Parameter(property = "ike.unpack.cacheDirectory",
               defaultValue = "${settings.localRepository}/.cache/ike-maven-plugin")
    String cacheDirectory;

    /**
     * Skip execution.
     */
    @Parameter(property = "ike.unpack.skip", defaultValue = "false")
    boolean skip;

    /**
     * HTTP connect/read timeout in seconds.
     */
    @Parameter(property = "ike.unpack.timeout", defaultValue = "60")
    int timeoutSeconds;

    /** Creates this goal instance. */
    public UnpackZipMojo() {}

    @Override
    public void execute() throws MojoException {
        if (skip) {
            getLog().debug("unpack-zip: skipped");
            return;
        }

        try {
            Path zipFile = download();
            unpack(zipFile);
        } catch (IOException e) {
            throw new MojoException(
                    "unpack-zip failed for " + url, e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new MojoException(
                    "unpack-zip interrupted for " + url, e);
        }
    }

    /**
     * Download the zip to the cache directory, skipping if already present.
     *
     * @return path to the cached zip file
     * @throws IOException if download or I/O fails
     * @throws InterruptedException if the download is interrupted
     */
    private Path download() throws IOException, InterruptedException {
        Path cacheDir = Path.of(cacheDirectory);
        Files.createDirectories(cacheDir);

        // Derive filename from URL (last path segment)
        String filename = url.substring(url.lastIndexOf('/') + 1);
        Path cached = cacheDir.resolve(filename);

        if (Files.isRegularFile(cached)) {
            getLog().info("unpack-zip: using cached " + cached);
            return cached;
        }

        getLog().info("unpack-zip: downloading " + url);

        HttpClient client = HttpClient.newBuilder()
                .followRedirects(HttpClient.Redirect.NORMAL)
                .connectTimeout(Duration.ofSeconds(timeoutSeconds))
                .build();

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(Duration.ofSeconds(timeoutSeconds))
                .GET()
                .build();

        // Download to a temp file, then move atomically to avoid
        // partial files in the cache on failure
        Path temp = Files.createTempFile(cacheDir, "download-", ".tmp");
        try {
            HttpResponse<Path> response = client.send(request,
                    HttpResponse.BodyHandlers.ofFile(temp));

            if (response.statusCode() != 200) {
                throw new IOException("HTTP " + response.statusCode()
                        + " downloading " + url);
            }

            Files.move(temp, cached, StandardCopyOption.REPLACE_EXISTING);
            getLog().info("unpack-zip: cached " + filename
                    + " (" + Files.size(cached) / 1024 + " KB)");
            return cached;
        } catch (IOException | InterruptedException e) {
            Files.deleteIfExists(temp);
            throw e;
        }
    }

    /**
     * Unpack a zip file to the output directory using
     * {@link ZipInputStream}.
     *
     * <p>Unlike Plexus Archiver, this does not warn on
     * case-insensitive filesystem collisions — it silently overwrites,
     * matching the behavior of system {@code unzip -o}.
     *
     * @param zipFile the zip archive to unpack
     * @throws IOException if unpacking fails
     */
    private void unpack(Path zipFile) throws IOException {
        Path outDir = Path.of(outputDirectory);
        Files.createDirectories(outDir);

        int count = 0;
        try (ZipInputStream zis = new ZipInputStream(
                Files.newInputStream(zipFile))) {

            ZipEntry entry;
            while ((entry = zis.getNextEntry()) != null) {
                Path target = outDir.resolve(entry.getName()).normalize();

                // Guard against zip-slip
                if (!target.startsWith(outDir)) {
                    throw new IOException(
                            "Zip entry outside target directory: "
                                    + entry.getName());
                }

                if (entry.isDirectory()) {
                    Files.createDirectories(target);
                } else {
                    Files.createDirectories(target.getParent());
                    Files.copy(zis, target,
                            StandardCopyOption.REPLACE_EXISTING);
                    count++;
                }
                zis.closeEntry();
            }
        }

        getLog().info("unpack-zip: extracted " + count + " files to "
                + outDir);
    }
}