CodesignNativesMojo.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.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

/**
 * Sign native libraries ({@code .dylib}, {@code .jnilib}) inside a
 * jlink runtime image so the resulting installer passes Apple notarization.
 *
 * <p>Apple requires every executable binary in a notarized bundle to be
 * signed with a Developer ID certificate and include a secure timestamp.
 * JARs containing native libraries (JNA, RocksDB, etc.) ship with
 * unsigned or ad-hoc-signed binaries that Apple rejects.
 *
 * <p>This goal walks the runtime image directory, finds native libraries
 * both loose and inside JARs, and signs each one with {@code codesign}.
 * For JARs, the native entries are extracted, signed, and repacked.
 *
 * <p>Bind this goal after jlink image assembly and dependency staging
 * but before jpackage creates the installer:
 * <pre>{@code
 * <execution>
 *     <id>codesign-natives</id>
 *     <phase>package[5]</phase>
 *     <goals><goal>codesign-natives</goal></goals>
 *     <configuration>
 *         <runtimeImageDir>${project.build.directory}/jreleaser-jlink/assemble/komet-standard/jlink/...</runtimeImageDir>
 *         <signingIdentity>Developer ID Application: Your Name (TEAMID)</signingIdentity>
 *     </configuration>
 * </execution>
 * }</pre>
 *
 * <p>On non-macOS platforms the goal skips silently.
 *
 * @see <a href="https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution">
 *      Apple: Notarizing macOS Software Before Distribution</a>
 */
@Mojo(name = "codesign-natives",
      defaultPhase = "package",
      projectRequired = true)
public class CodesignNativesMojo 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; }

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

    /**
     * Root directory of the jlink runtime image to scan.
     * All {@code .jar}, {@code .dylib}, and {@code .jnilib} files
     * under this tree are inspected.
     */
    @Parameter(property = "codesign.runtimeImageDir")
    private File runtimeImageDir;

    /**
     * The {@code codesign} signing identity. Typically a
     * "Developer ID Application" certificate name including the team ID,
     * e.g., {@code "Developer ID Application: Jane Doe (ABCDE12345)"}.
     *
     * <p>Required on macOS; ignored on other platforms.
     * Not marked {@code required=true} so the goal can skip gracefully
     * on non-macOS without Maven failing parameter validation first.
     */
    @Parameter(property = "codesign.identity")
    private String signingIdentity;

    /**
     * Skip native codesigning entirely.
     */
    @Parameter(property = "codesign.skip", defaultValue = "false")
    private boolean skip;

    /**
     * Keychain password for unlocking the signing keychain before codesign.
     * Read from {@code CODESIGN_KEYCHAIN_PASSWORD} environment variable
     * if not set via Maven property. When provided, the login keychain
     * is unlocked automatically — no interactive prompt.
     */
    @Parameter(property = "codesign.keychainPassword",
               defaultValue = "${env.CODESIGN_KEYCHAIN_PASSWORD}")
    private String keychainPassword;

    @Override
    public void execute() throws MojoException {
        if (skip) {
            getLog().info("Native codesigning skipped (codesign.skip=true)");
            return;
        }

        if (!ReleaseSupport.isMacOS()) {
            getLog().info("Native codesigning skipped \u2014 not running on macOS");
            return;
        }

        if (runtimeImageDir == null || !runtimeImageDir.isDirectory()) {
            getLog().warn("Runtime image directory does not exist: " + runtimeImageDir
                    + " \u2014 skipping native codesigning");
            return;
        }

        if (signingIdentity == null || signingIdentity.isBlank()) {
            getLog().info("Native codesigning skipped \u2014 no signing identity provided");
            getLog().info("  To sign, pass -Dcodesign.identity=\"Developer ID Application: ...\"");
            return;
        }

        unlockKeychainIfNeeded();

        getLog().info("");
        getLog().info("Native Library Codesigning");
        getLog().info("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
        getLog().info("  Runtime image:   " + runtimeImageDir);
        getLog().info("  Identity:        " + signingIdentity);

        // Phase 1: Discover native files and JARs containing natives
        List<Path> looseNatives = new ArrayList<>();
        List<Path> jarsWithNatives = new ArrayList<>();
        scanTree(runtimeImageDir.toPath(), looseNatives, jarsWithNatives);

        getLog().info("  Loose natives:   " + looseNatives.size());
        getLog().info("  JARs to repack:  " + jarsWithNatives.size());

        int signedCount = 0;

        // Phase 2: Sign loose native files directly
        for (Path nativeFile : looseNatives) {
            codesign(nativeFile);
            signedCount++;
        }

        // Phase 3: Extract, sign, and repack JARs with embedded natives
        for (Path jarPath : jarsWithNatives) {
            signedCount += processJar(jarPath);
        }

        getLog().info("");
        getLog().info("Codesigning complete \u2014 " + signedCount + " native(s) signed");
        getLog().info("");
    }

    /**
     * Walk the directory tree, collecting loose native files and JARs
     * that contain native entries.
     */
    private void scanTree(Path root, List<Path> looseNatives,
                          List<Path> jarsWithNatives)
            throws MojoException {
        try {
            Files.walkFileTree(root, new SimpleFileVisitor<>() {
                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                    String name = file.getFileName().toString().toLowerCase(Locale.ROOT);
                    if (isNativeFile(name)) {
                        looseNatives.add(file);
                    } else if (name.endsWith(".jar")) {
                        try {
                            if (jarContainsNatives(file)) {
                                jarsWithNatives.add(file);
                            }
                        } catch (IOException e) {
                            getLog().warn("Could not inspect JAR: " + file + " \u2014 " + e.getMessage());
                        }
                    }
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to scan runtime image: " + root, e);
        }
    }

    /**
     * Check if a JAR contains any native library entries.
     */
    static boolean jarContainsNatives(Path jarPath) throws IOException {
        try (ZipFile zip = new ZipFile(jarPath.toFile())) {
            Enumeration<? extends ZipEntry> entries = zip.entries();
            while (entries.hasMoreElements()) {
                String name = entries.nextElement().getName().toLowerCase(Locale.ROOT);
                if (isNativeFile(name)) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Extract native entries from a JAR, sign them, and repack the JAR.
     *
     * @return the number of native files signed in this JAR
     */
    private int processJar(Path jarPath) throws MojoException {
        String jarName = jarPath.getFileName().toString();
        getLog().info("Processing JAR: " + jarName);

        Path tempDir;
        try {
            tempDir = Files.createTempDirectory("codesign-jar-");
        } catch (IOException e) {
            throw new MojoException("Failed to create temp directory", e);
        }

        try {
            // Step 1: Extract native entries and record their info
            List<String> nativeEntries = new ArrayList<>();
            try (ZipFile zip = new ZipFile(jarPath.toFile())) {
                Enumeration<? extends ZipEntry> entries = zip.entries();
                while (entries.hasMoreElements()) {
                    ZipEntry entry = entries.nextElement();
                    if (entry.isDirectory()) continue;
                    if (!isNativeFile(entry.getName().toLowerCase(Locale.ROOT))) continue;

                    nativeEntries.add(entry.getName());
                    Path extractTarget = tempDir.resolve(entry.getName());
                    Files.createDirectories(extractTarget.getParent());
                    try (InputStream in = zip.getInputStream(entry)) {
                        Files.copy(in, extractTarget, StandardCopyOption.REPLACE_EXISTING);
                    }
                }
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to extract natives from " + jarPath, e);
            }

            if (nativeEntries.isEmpty()) {
                return 0;
            }

            // Step 2: Sign each extracted native
            for (String entryName : nativeEntries) {
                Path extracted = tempDir.resolve(entryName);
                codesign(extracted);
            }

            // Step 3: Repack the JAR with signed natives replacing originals
            Path repackedJar = tempDir.resolve("repacked.jar");
            repackJar(jarPath, repackedJar, tempDir, nativeEntries);

            // Step 4: Atomically replace the original JAR
            try {
                Files.move(repackedJar, jarPath,
                        StandardCopyOption.REPLACE_EXISTING,
                        StandardCopyOption.ATOMIC_MOVE);
            } catch (IOException e) {
                // ATOMIC_MOVE may not be supported across filesystems
                try {
                    Files.move(repackedJar, jarPath,
                            StandardCopyOption.REPLACE_EXISTING);
                } catch (IOException e2) {
                    throw new MojoException(
                            "Failed to replace JAR: " + jarPath, e2);
                }
            }

            getLog().info("  Signed " + nativeEntries.size()
                    + " native(s) in " + jarName);
            return nativeEntries.size();

        } finally {
            // Clean up temp directory
            deleteRecursively(tempDir);
        }
    }

    /**
     * Repack a JAR, substituting signed native entries from the temp directory.
     * Preserves entry order, compression method, and extra fields.
     */
    private void repackJar(Path originalJar, Path outputJar,
                           Path signedDir, List<String> nativeEntries)
            throws MojoException {
        try (ZipFile original = new ZipFile(originalJar.toFile());
             OutputStream fos = Files.newOutputStream(outputJar);
             ZipOutputStream zos = new ZipOutputStream(fos)) {

            Enumeration<? extends ZipEntry> entries = original.entries();
            while (entries.hasMoreElements()) {
                ZipEntry oldEntry = entries.nextElement();
                ZipEntry newEntry = new ZipEntry(oldEntry.getName());

                // Preserve compression method
                newEntry.setMethod(oldEntry.getMethod());
                if (oldEntry.getMethod() == ZipEntry.STORED) {
                    // STORED entries need explicit size and CRC
                    if (nativeEntries.contains(oldEntry.getName())) {
                        // Will be set from the signed file
                        Path signedFile = signedDir.resolve(oldEntry.getName());
                        long size = Files.size(signedFile);
                        newEntry.setSize(size);
                        newEntry.setCompressedSize(size);
                        newEntry.setCrc(computeCrc(signedFile));
                    } else {
                        newEntry.setSize(oldEntry.getSize());
                        newEntry.setCompressedSize(oldEntry.getCompressedSize());
                        newEntry.setCrc(oldEntry.getCrc());
                    }
                }
                if (oldEntry.getExtra() != null) {
                    newEntry.setExtra(oldEntry.getExtra());
                }
                if (oldEntry.getComment() != null) {
                    newEntry.setComment(oldEntry.getComment());
                }
                newEntry.setTime(oldEntry.getTime());

                zos.putNextEntry(newEntry);

                if (!oldEntry.isDirectory()) {
                    if (nativeEntries.contains(oldEntry.getName())) {
                        // Substitute with signed version
                        Path signedFile = signedDir.resolve(oldEntry.getName());
                        Files.copy(signedFile, zos);
                    } else {
                        // Copy original bytes
                        try (InputStream in = original.getInputStream(oldEntry)) {
                            in.transferTo(zos);
                        }
                    }
                }

                zos.closeEntry();
            }

        } catch (IOException e) {
            throw new MojoException(
                    "Failed to repack JAR: " + originalJar, e);
        }
    }

    /**
     * Unlock the login keychain if a password is available.
     * This prevents interactive prompts during codesign on dev machines
     * and is required for CI where no GUI is available.
     */
    private void unlockKeychainIfNeeded() throws MojoException {
        if (keychainPassword == null || keychainPassword.isBlank()) {
            return;
        }
        getLog().info("  Unlocking keychain for codesign...");
        ReleaseSupport.exec(new java.io.File("."), getLog(),
                "security", "unlock-keychain",
                "-p", keychainPassword,
                System.getProperty("user.home")
                        + "/Library/Keychains/login.keychain-db");
        // Also set the partition list so codesign can access the key
        ReleaseSupport.exec(new java.io.File("."), getLog(),
                "security", "set-key-partition-list",
                "-S", "apple-tool:,apple:,codesign:",
                "-s", "-k", keychainPassword,
                System.getProperty("user.home")
                        + "/Library/Keychains/login.keychain-db");
    }

    /**
     * Sign a single native file with {@code codesign}.
     * Captures and logs stdout/stderr so that signing errors
     * (keychain access, certificate issues) are visible (#134).
     */
    private void codesign(Path file) throws MojoException {
        try {
            ReleaseSupport.execCaptureAndLog(
                    file.getParent().toFile(), getLog(),
                    "codesign", "--force", "--timestamp",
                    "--options", "runtime",
                    "--sign", signingIdentity,
                    file.toString());
        } catch (MojoException e) {
            // Re-throw with the file path for context — the captured
            // output was already logged by execCaptureAndLog
            throw new MojoException(
                    "codesign failed for " + file.getFileName()
                    + ": " + e.getMessage(), e);
        }
    }

    /**
     * Check if a filename represents a native library.
     */
    static boolean isNativeFile(String name) {
        return name.endsWith(".dylib") || name.endsWith(".jnilib");
    }

    /**
     * Compute CRC-32 for a file (needed for STORED zip entries).
     */
    private static long computeCrc(Path file) throws IOException {
        java.util.zip.CRC32 crc = new java.util.zip.CRC32();
        try (InputStream in = Files.newInputStream(file)) {
            byte[] buf = new byte[8192];
            int n;
            while ((n = in.read(buf)) != -1) {
                crc.update(buf, 0, n);
            }
        }
        return crc.getValue();
    }

    /**
     * Recursively delete a directory tree.
     */
    private static void deleteRecursively(Path dir) {
        try {
            Files.walkFileTree(dir, new SimpleFileVisitor<>() {
                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
                        throws IOException {
                    Files.delete(file);
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult postVisitDirectory(Path d, IOException exc)
                        throws IOException {
                    Files.delete(d);
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (IOException _) {
            // Best-effort cleanup
        }
    }
}