CodesignPkgMojo.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.nio.file.DirectoryStream;
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.List;
import java.util.Locale;

/**
 * Re-sign the {@code .app} bundle inside a jpackage-produced {@code .pkg}
 * installer to add macOS entitlements required by the JVM.
 *
 * <p>This workaround exists because of <a
 * href="https://bugs.openjdk.org/browse/JDK-8358723">JDK-8358723</a>:
 * {@code jpackage --mac-sign} in older JDKs signs the main launcher and
 * nested runtime binaries without entitlements, so the JVM's JIT
 * entitlements ({@code com.apple.security.cs.allow-jit}, etc.) are
 * missing.  Without them the JVM crashes immediately on Apple Silicon
 * with {@code EXC_BREAKPOINT} in {@code pthread_jit_write_protect_np}.
 *
 * <p>The fix for JDK-8358723 is backported to <b>JDK 25.0.2+</b> via
 * <a href="https://bugs.openjdk.org/browse/JDK-8369477">JDK-8369477</a>
 * (OpenJDK 25.0.2 Jan 2026 CPU; Oracle JDK 25.0.3 Apr 2026 CPU) and is
 * present in JDK 26 mainline.  On those JDKs jpackage signs correctly,
 * and re-signing on top produces a signature variant macOS 26.4's notary
 * rejects.  This goal therefore <b>auto-skips on JDK 25.0.2 or newer</b>.
 *
 * <p>This goal post-processes the {@code .pkg} (only on JDK &lt; 25.0.2):
 * <ol>
 *   <li>Expands the {@code .pkg} with {@code pkgutil --expand}</li>
 *   <li>Extracts the Payload (gzip + cpio archive)</li>
 *   <li>Re-signs the main executable and {@code .app} bundle with entitlements</li>
 *   <li>Repacks the Payload and regenerates the BOM</li>
 *   <li>Flattens the {@code .pkg} with {@code pkgutil --flatten}</li>
 *   <li>Signs the {@code .pkg} with {@code productsign}</li>
 * </ol>
 *
 * <p>Bind this goal after jpackage but before notarization:
 * <pre>{@code
 * <execution>
 *     <id>codesign-pkg</id>
 *     <phase>verify[0.5]</phase>
 *     <goals><goal>codesign-pkg</goal></goals>
 *     <configuration>
 *         <entitlementsFile>${project.basedir}/src/main/resources/installer/resourceDir_unix/default.plist</entitlementsFile>
 *     </configuration>
 * </execution>
 * }</pre>
 *
 * <p>On non-macOS platforms the goal skips silently.  Set
 * {@code -Dcodesign.pkg.forceWorkaround=true} to run the re-sign on
 * JDK 25.0.2+ (debugging only).
 */
@Mojo(name = "codesign-pkg",
      defaultPhase = "verify",
      projectRequired = true)
public class CodesignPkgMojo 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 CodesignPkgMojo() {}

    /**
     * Directory containing the {@code .pkg} files produced by jpackage.
     */
    @Parameter(property = "codesign.pkgDir",
               defaultValue = "${project.build.directory}/jreleaser/assemble/komet/jpackage")
    private File pkgDir;

    /**
     * The {@code codesign} signing identity for the application.
     * Typically {@code "Developer ID Application: Name (TEAMID)"}.
     *
     * <p>The installer signing identity is derived automatically by
     * replacing "Application" with "Installer".
     */
    @Parameter(property = "codesign.identity")
    private String signingIdentity;

    /**
     * Path to the entitlements plist file.
     */
    @Parameter(property = "codesign.entitlements")
    private File entitlementsFile;

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

    /**
     * Force the entitlements workaround even on JDK 25.0.2+ where
     * jpackage itself signs with entitlements (JDK-8369477 / JDK-8358723).
     * Normally the goal auto-skips on fixed JDKs; this override is for
     * debugging or temporary compatibility with downstream tooling.
     */
    @Parameter(property = "codesign.pkg.forceWorkaround", defaultValue = "false")
    private boolean forceWorkaround;

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

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

        if (!forceWorkaround && jpackageHasEntitlementsFix(Runtime.version())) {
            getLog().info("Package codesigning skipped — running JDK "
                    + Runtime.version()
                    + " includes the JDK-8358723 entitlements fix"
                    + " (JDK-8369477 backport). jpackage signs correctly;"
                    + " re-signing here would produce a signature macOS 26.4+"
                    + " notarization rejects."
                    + " Set -Dcodesign.pkg.forceWorkaround=true to override.");
            return;
        }

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

        if (pkgDir == null || !pkgDir.isDirectory()) {
            getLog().warn("Package directory does not exist: " + pkgDir
                    + " — skipping package codesigning");
            return;
        }

        if (signingIdentity == null || signingIdentity.isBlank()) {
            getLog().info("Package codesigning skipped — no signing identity provided");
            return;
        }

        if (entitlementsFile == null || !entitlementsFile.isFile()) {
            getLog().warn("Entitlements file not found: " + entitlementsFile
                    + " — skipping package codesigning");
            return;
        }

        unlockKeychainIfNeeded();

        List<Path> pkgFiles = findPkgFiles();
        if (pkgFiles.isEmpty()) {
            getLog().warn("No .pkg files found in " + pkgDir);
            return;
        }

        getLog().info("");
        getLog().info("Package Entitlement Codesigning");
        getLog().info("═══════════════════════════════════════════════════");
        getLog().info("  Package dir:     " + pkgDir);
        getLog().info("  Identity:        " + signingIdentity);
        getLog().info("  Entitlements:    " + entitlementsFile);
        getLog().info("  Packages found:  " + pkgFiles.size());

        for (Path pkg : pkgFiles) {
            processPackage(pkg);
        }

        getLog().info("");
        getLog().info("Package codesigning complete");
        getLog().info("");
    }

    /**
     * Unlock the login keychain if a password 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");
        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");
    }

    /**
     * Process a single {@code .pkg} file: expand, re-sign app with
     * entitlements, repack, and re-sign the package.
     */
    private void processPackage(Path pkg) throws MojoException {
        String pkgName = pkg.getFileName().toString();
        getLog().info("Processing: " + pkgName);

        Path workDir = null;
        Path expandedDir = null;
        Path payloadDir = null;

        try {
            // Step 1: Expand the .pkg
            // pkgutil --expand requires the target to NOT exist, so create
            // a parent temp dir and use a child path that doesn't exist yet
            workDir = Files.createTempDirectory("codesign-pkg-work-");
            expandedDir = workDir.resolve("expanded");
            getLog().info("  Expanding .pkg...");
            ReleaseSupport.exec(pkg.getParent().toFile(), getLog(),
                    "pkgutil", "--expand", pkg.toString(), expandedDir.toString());

            // Step 2: Find the inner component .pkg directory
            Path componentPkg = findComponentPkg(expandedDir);
            if (componentPkg == null) {
                throw new MojoException(
                        "No component .pkg found inside expanded package");
            }
            getLog().info("  Component: " + componentPkg.getFileName());

            Path payload = componentPkg.resolve("Payload");
            if (!Files.isRegularFile(payload)) {
                throw new MojoException(
                        "No Payload found in " + componentPkg);
            }

            // Step 3: Extract the Payload (gzip + cpio)
            payloadDir = Files.createTempDirectory("codesign-pkg-payload-");
            getLog().info("  Extracting Payload...");
            extractPayload(payload, payloadDir);

            // Step 4: Find the .app bundle and its main executable
            Path appBundle = findAppBundle(payloadDir);
            if (appBundle == null) {
                throw new MojoException(
                        "No .app bundle found in extracted Payload");
            }
            getLog().info("  App bundle: " + appBundle.getFileName());

            Path macosDir = appBundle.resolve("Contents/MacOS");
            Path mainExec = findMainExecutable(macosDir);
            if (mainExec == null) {
                throw new MojoException(
                        "No executable found in " + macosDir);
            }
            getLog().info("  Main executable: " + mainExec.getFileName());

            // Step 5: Re-sign the main executable with entitlements
            getLog().info("  Signing executable with entitlements...");
            codesignWithEntitlements(mainExec);

            // Step 6: Re-sign the .app bundle
            getLog().info("  Signing .app bundle...");
            codesignWithEntitlements(appBundle);

            // Step 7: Repack the Payload
            getLog().info("  Repacking Payload...");
            repackPayload(payloadDir, payload);

            // Step 8: Regenerate BOM
            Path bom = componentPkg.resolve("Bom");
            getLog().info("  Regenerating BOM...");
            ReleaseSupport.exec(payloadDir.toFile(), getLog(),
                    "mkbom", payloadDir.toString(), bom.toString());

            // Step 9: Flatten back to .pkg
            Path flattenedPkg = pkg.getParent().resolve(pkgName + ".tmp");
            getLog().info("  Flattening .pkg...");
            ReleaseSupport.exec(pkg.getParent().toFile(), getLog(),
                    "pkgutil", "--flatten", expandedDir.toString(),
                    flattenedPkg.toString());

            // Step 10: Sign the .pkg with productsign
            String installerIdentity = deriveInstallerIdentity(signingIdentity);
            Path signedPkg = pkg.getParent().resolve(pkgName + ".signed");
            getLog().info("  Signing .pkg with: " + installerIdentity);
            ReleaseSupport.exec(pkg.getParent().toFile(), getLog(),
                    "productsign",
                    "--sign", installerIdentity,
                    "--timestamp",
                    flattenedPkg.toString(),
                    signedPkg.toString());

            // Step 11: Replace the original
            Files.delete(flattenedPkg);
            Files.move(signedPkg, pkg, StandardCopyOption.REPLACE_EXISTING);

            getLog().info("  Done — entitlements applied to " + pkgName);

        } catch (IOException e) {
            throw new MojoException(
                    "Failed to process package: " + pkgName, e);
        } finally {
            if (payloadDir != null) deleteRecursively(payloadDir);
            if (workDir != null) deleteRecursively(workDir);
        }
    }

    /**
     * Find the inner component {@code .pkg} directory inside an expanded
     * package.  This is the directory containing Payload, Bom, and
     * PackageInfo.
     */
    private Path findComponentPkg(Path expandedDir) throws IOException {
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(
                expandedDir,
                entry -> entry.getFileName().toString()
                        .toLowerCase(Locale.ROOT).endsWith(".pkg"))) {
            for (Path entry : stream) {
                if (Files.isDirectory(entry)
                        && Files.isRegularFile(entry.resolve("Payload"))) {
                    return entry;
                }
            }
        }
        return null;
    }

    /**
     * Find the {@code .app} bundle directory inside an extracted Payload.
     */
    private Path findAppBundle(Path payloadDir) throws IOException {
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(
                payloadDir,
                entry -> entry.getFileName().toString()
                        .toLowerCase(Locale.ROOT).endsWith(".app"))) {
            for (Path entry : stream) {
                if (Files.isDirectory(entry)) {
                    return entry;
                }
            }
        }
        return null;
    }

    /**
     * Find the main executable inside a {@code Contents/MacOS} directory.
     * Returns the first regular file found.
     */
    private Path findMainExecutable(Path macosDir) throws IOException {
        if (!Files.isDirectory(macosDir)) return null;

        try (DirectoryStream<Path> stream = Files.newDirectoryStream(macosDir)) {
            for (Path entry : stream) {
                if (Files.isRegularFile(entry) && Files.isExecutable(entry)) {
                    return entry;
                }
            }
        }
        return null;
    }

    /**
     * Extract a gzip-compressed cpio Payload archive.
     */
    private void extractPayload(Path payload, Path targetDir)
            throws MojoException {
        // gunzip -dc Payload | cpio -id
        // We use a shell pipeline for this
        ReleaseSupport.exec(targetDir.toFile(), getLog(),
                "sh", "-c",
                "gunzip -dc " + shellQuote(payload.toString())
                        + " | cpio -id 2>/dev/null");
    }

    /**
     * Repack extracted files into a gzip-compressed cpio Payload.
     */
    private void repackPayload(Path sourceDir, Path payloadFile)
            throws MojoException {
        // find . -print | cpio -o --format odc | gzip -c > Payload
        ReleaseSupport.exec(sourceDir.toFile(), getLog(),
                "sh", "-c",
                "find . -print | cpio -o --format odc 2>/dev/null"
                        + " | gzip -c > " + shellQuote(payloadFile.toString()));
    }

    /**
     * Sign a file or bundle with entitlements.
     */
    private void codesignWithEntitlements(Path target)
            throws MojoException {
        ReleaseSupport.exec(target.getParent().toFile(), getLog(),
                "codesign", "--force", "--timestamp",
                "--options", "runtime",
                "--entitlements", entitlementsFile.getAbsolutePath(),
                "--sign", signingIdentity,
                target.toString());
    }

    /**
     * Derive the "Developer ID Installer" identity from the
     * "Developer ID Application" identity.
     */
    static String deriveInstallerIdentity(String applicationIdentity) {
        return applicationIdentity.replace(
                "Developer ID Application",
                "Developer ID Installer");
    }

    /**
     * True when the JDK described by {@code v} contains the JDK-8358723
     * entitlements fix — JDK 26 mainline, or JDK 25.0.2+ via the
     * JDK-8369477 backport.
     *
     * @param v the JDK version to test (typically {@link Runtime#version()})
     * @return {@code true} if jpackage on this JDK signs with entitlements
     *         and the re-sign workaround should be skipped
     */
    static boolean jpackageHasEntitlementsFix(Runtime.Version v) {
        int feature = v.feature();
        if (feature >= 26) return true;
        if (feature == 25 && v.update() >= 2) return true;
        return false;
    }

    /**
     * Find {@code .pkg} files in the package directory.
     */
    private List<Path> findPkgFiles() throws MojoException {
        List<Path> result = new ArrayList<>();
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(
                pkgDir.toPath(),
                entry -> entry.getFileName().toString()
                        .toLowerCase(Locale.ROOT).endsWith(".pkg"))) {
            for (Path entry : stream) {
                if (Files.isRegularFile(entry)) {
                    result.add(entry);
                }
            }
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to scan for .pkg files in " + pkgDir, e);
        }
        return result;
    }

    /**
     * Quote a string for use in a shell command.
     */
    private static String shellQuote(String s) {
        return "'" + s.replace("'", "'\\''") + "'";
    }

    /**
     * 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
        }
    }
}