From b29855e426690fee76877956f723d78a46b90e5c Mon Sep 17 00:00:00 2001 From: RavenLiao Date: Mon, 18 May 2026 22:10:53 +0800 Subject: [PATCH] fix(macos): make GraalVM Mach-O post-processing safe --- .../application/internal/MacOsMachOPatcher.kt | 72 ++++---- .../internal/SafeMachOFileMutation.kt | 158 ++++++++++++++++++ .../internal/configureGraalvmApplication.kt | 47 +++++- .../internal/SafeMachOFileMutationTest.kt | 150 +++++++++++++++++ 4 files changed, 393 insertions(+), 34 deletions(-) create mode 100644 plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/SafeMachOFileMutation.kt create mode 100644 plugin-build/plugin/src/test/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/SafeMachOFileMutationTest.kt diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/MacOsMachOPatcher.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/MacOsMachOPatcher.kt index 07115f2e5..e8381dbbe 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/MacOsMachOPatcher.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/MacOsMachOPatcher.kt @@ -27,34 +27,48 @@ internal fun patchMachOBuildVersion( logger.lifecycle("Patching ${binary.name} LC_BUILD_VERSION: minos=$minVersion sdk=$sdkVersion") - // Remove existing code signature (vtool cannot modify signed binaries) - ProcessBuilder("codesign", "--remove-signature", binary.absolutePath) - .redirectErrorStream(true) - .start() - .waitFor() - - val vtoolExit = - ProcessBuilder( - vtool.absolutePath, - "-set-build-version", - "macos", - minVersion, - sdkVersion, - "-tool", - "ld", - "0.0", - "-replace", - "-output", - binary.absolutePath, - binary.absolutePath, - ).redirectErrorStream(true) - .start() - .waitFor() - - if (vtoolExit != 0) { - logger.warn("vtool exited with code $vtoolExit for ${binary.name} — build version patch may have failed") - return false - } + return mutateMachOFileSafely( + binary = binary, + operation = "vtool patch LC_BUILD_VERSION", + logger = logger, + mutate = { copy, runner -> + runner.run( + listOf( + vtool.absolutePath, + "-set-build-version", + "macos", + minVersion, + sdkVersion, + "-tool", + "ld", + "0.0", + "-replace", + "-output", + copy.absolutePath, + copy.absolutePath, + ), + ) + }, + validateExtra = { copy, runner -> + val showBuildResult = runner.run(listOf(vtool.absolutePath, "-show-build", copy.absolutePath)) + if (showBuildResult.exitCode != 0) { + "vtool -show-build failed (exit=${showBuildResult.exitCode})" + } else if (!hasExpectedBuildVersions(showBuildResult.output, minVersion, sdkVersion)) { + "vtool -show-build output does not contain expected minos/sdk values " + + "(minos=$minVersion, sdk=$sdkVersion)" + } else { + null + } + }, + ) +} - return true +private fun hasExpectedBuildVersions( + showBuildOutput: String, + minVersion: String, + sdkVersion: String, +): Boolean { + val minPattern = Regex("""\bminos\s+${Regex.escape(minVersion)}\b""", RegexOption.IGNORE_CASE) + val sdkPattern = Regex("""\bsdk\s+${Regex.escape(sdkVersion)}\b""", RegexOption.IGNORE_CASE) + return minPattern.containsMatchIn(showBuildOutput) && sdkPattern.containsMatchIn(showBuildOutput) } diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/SafeMachOFileMutation.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/SafeMachOFileMutation.kt new file mode 100644 index 000000000..d1050a883 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/SafeMachOFileMutation.kt @@ -0,0 +1,158 @@ +package io.github.kdroidfilter.nucleus.desktop.application.internal + +import org.gradle.api.logging.Logger +import java.io.File +import java.io.IOException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption + +internal data class MachOCommandResult( + val exitCode: Int, + val output: String = "", +) + +internal fun interface MachOCommandRunner { + fun run(command: List): MachOCommandResult +} + +internal val defaultMachOCommandRunner = + MachOCommandRunner { command -> + try { + val process = ProcessBuilder(command).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().use { it.readText() } + val exitCode = process.waitFor() + MachOCommandResult(exitCode, output) + } catch (e: Exception) { + val message = e.message ?: e::class.java.simpleName + MachOCommandResult(exitCode = -1, output = message) + } + } + +internal fun stripMachOFileSafely( + binary: File, + logger: Logger, + commandRunner: MachOCommandRunner = defaultMachOCommandRunner, +): Boolean = + mutateMachOFileSafely( + binary = binary, + operation = "strip -x", + logger = logger, + commandRunner = commandRunner, + mutate = { copy, runner -> + runner.run(listOf("/usr/bin/strip", "-x", copy.absolutePath)) + }, + ) + +internal fun mutateMachOFileSafely( + binary: File, + operation: String, + logger: Logger, + commandRunner: MachOCommandRunner = defaultMachOCommandRunner, + mutate: (copy: File, commandRunner: MachOCommandRunner) -> MachOCommandResult, + validateExtra: ((copy: File, commandRunner: MachOCommandRunner) -> String?)? = null, +): Boolean { + val parent = binary.parentFile?.toPath() + ?: throw IOException("Cannot create temporary copy for ${binary.absolutePath}: missing parent directory") + val binaryPath = binary.toPath() + val tempPath = createSiblingTempCopy(binaryPath, parent) + + try { + val tempFile = tempPath.toFile() + runCodesignRemoveSignatureBestEffort(tempFile, commandRunner, logger, operation) + + val mutationResult = runCatching { mutate(tempFile, commandRunner) } + .getOrElse { error -> + logger.warn( + "Skipping $operation for ${binary.name}: mutation step failed unexpectedly: ${error.message}", + ) + return false + } + if (mutationResult.exitCode != 0) { + logger.warn( + "Skipping $operation for ${binary.name}: tool exit=${mutationResult.exitCode}. " + + "Original file kept. Output: ${mutationResult.output.asCompactLogLine()}", + ) + return false + } + + val otoolResult = commandRunner.run(listOf("/usr/bin/otool", "-l", tempFile.absolutePath)) + if (otoolResult.exitCode != 0) { + logger.warn( + "Skipping $operation for ${binary.name}: otool validation failed (exit=${otoolResult.exitCode}). " + + "Original file kept. Output: ${otoolResult.output.asCompactLogLine()}", + ) + return false + } + + validateExtra?.let { validator -> + val validationError = runCatching { validator(tempFile, commandRunner) } + .getOrElse { error -> + "extra validation threw ${error::class.java.simpleName}: ${error.message}" + } + if (validationError != null) { + logger.warn( + "Skipping $operation for ${binary.name}: $validationError. Original file kept.", + ) + return false + } + } + + replaceOriginalWithTemp(tempPath, binaryPath) + return true + } finally { + Files.deleteIfExists(tempPath) + } +} + +private fun createSiblingTempCopy( + binaryPath: Path, + parent: Path, +): Path { + val tempPath = Files.createTempFile(parent, "${binaryPath.fileName}.nucleus-", ".tmp") + Files.copy( + binaryPath, + tempPath, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.COPY_ATTRIBUTES, + ) + return tempPath +} + +private fun runCodesignRemoveSignatureBestEffort( + binary: File, + commandRunner: MachOCommandRunner, + logger: Logger, + operation: String, +) { + val result = commandRunner.run(listOf("/usr/bin/codesign", "--remove-signature", binary.absolutePath)) + if (result.exitCode == 0) return + + logger.warn( + "codesign --remove-signature failed before $operation for ${binary.name} (exit=${result.exitCode}); " + + "continuing. Output: ${result.output.asCompactLogLine()}", + ) +} + +private fun replaceOriginalWithTemp( + tempPath: Path, + originalPath: Path, +) { + try { + Files.move( + tempPath, + originalPath, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(tempPath, originalPath, StandardCopyOption.REPLACE_EXISTING) + } +} + +private fun String.asCompactLogLine(maxLength: Int = 300): String { + val compact = replace('\n', ' ').replace('\r', ' ').trim() + if (compact.length <= maxLength) return compact + return compact.take(maxLength) + "..." +} diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureGraalvmApplication.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureGraalvmApplication.kt index ab7928a24..65da73a24 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureGraalvmApplication.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureGraalvmApplication.kt @@ -888,14 +888,37 @@ private fun JvmApplicationContext.configureMacOsGraalvmPackaging( } val stripDylibs = - tasks.register( + tasks.register( taskNameAction = "strip", taskNameObject = "graalvmDylibs", ) { description = "Strip debug symbols from dylibs" dependsOn(copyAwtDylibs) - val macosDir = appBundleDir.map { it.dir("MacOS") } - commandLine("bash", "-c", "strip -x '${macosDir.get().asFile.absolutePath}'/*.dylib") + + doLast { + val macosDir = appBundleDir.get().dir("MacOS").asFile + val dylibs = + macosDir + .listFiles { file -> file.isFile && file.extension == "dylib" } + ?.sortedBy { it.name } + .orEmpty() + + var successCount = 0 + var failureCount = 0 + + dylibs.forEach { dylib -> + val stripped = stripMachOFileSafely(dylib, logger) + if (stripped) { + successCount++ + } else { + failureCount++ + } + } + + logger.lifecycle( + "stripDylibs summary: total=${dylibs.size}, stripped=$successCount, keptOriginal=$failureCount", + ) + } } // Patch LC_BUILD_VERSION on all Mach-O binaries and dylibs so that: @@ -925,8 +948,22 @@ private fun JvmApplicationContext.configureMacOsGraalvmPackaging( .filter { it.isDirectory } .flatMap { dir -> dir.listFiles()?.asSequence() ?: emptySequence() } .filter { it.isFile && (it.extension == "dylib" || it.canExecute()) } - .forEach { file -> - patchMachOBuildVersion(file, minVer, sdkVer, logger) + .toList() + .also { files -> + var successCount = 0 + var failureCount = 0 + files.forEach { file -> + val patched = patchMachOBuildVersion(file, minVer, sdkVer, logger) + if (patched) { + successCount++ + } else { + failureCount++ + } + } + + logger.lifecycle( + "patchBuildVersion summary: total=${files.size}, patched=$successCount, keptOriginal=$failureCount", + ) } } } diff --git a/plugin-build/plugin/src/test/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/SafeMachOFileMutationTest.kt b/plugin-build/plugin/src/test/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/SafeMachOFileMutationTest.kt new file mode 100644 index 000000000..bceddb825 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/SafeMachOFileMutationTest.kt @@ -0,0 +1,150 @@ +package io.github.kdroidfilter.nucleus.desktop.application.internal + +import org.gradle.api.logging.Logging +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class SafeMachOFileMutationTest { + @get:Rule + val tmpDir = TemporaryFolder() + + private val logger = Logging.getLogger(SafeMachOFileMutationTest::class.java) + + @Test + fun `mutateMachOFileSafely replaces original file when mutation and validation succeed`() { + val binary = tmpDir.newFile("libjava.dylib") + binary.writeText("original") + + val result = + mutateMachOFileSafely( + binary = binary, + operation = "test mutation", + logger = logger, + commandRunner = fixedRunner(), + mutate = { copy, _ -> + copy.writeText("patched") + MachOCommandResult(0, "ok") + }, + ) + + assertTrue(result) + assertEquals("patched", binary.readText()) + } + + @Test + fun `mutateMachOFileSafely keeps original file when mutation fails`() { + val binary = tmpDir.newFile("libawt.dylib") + binary.writeText("original") + + val result = + mutateMachOFileSafely( + binary = binary, + operation = "test mutation", + logger = logger, + commandRunner = fixedRunner(), + mutate = { copy, _ -> + copy.writeText("patched") + MachOCommandResult(1, "mutation failed") + }, + ) + + assertFalse(result) + assertEquals("original", binary.readText()) + } + + @Test + fun `mutateMachOFileSafely keeps original file when validation fails`() { + val binary = tmpDir.newFile("libfontmanager.dylib") + binary.writeText("original") + + val runner = + MachOCommandRunner { command -> + when (command.first().substringAfterLast('/')) { + "codesign" -> MachOCommandResult(0, "") + "otool" -> MachOCommandResult(1, "malformed") + else -> MachOCommandResult(0, "") + } + } + + val result = + mutateMachOFileSafely( + binary = binary, + operation = "test mutation", + logger = logger, + commandRunner = runner, + mutate = { copy, _ -> + copy.writeText("patched") + MachOCommandResult(0, "ok") + }, + ) + + assertFalse(result) + assertEquals("original", binary.readText()) + } + + @Test + fun `mutateMachOFileSafely continues when remove-signature fails`() { + val binary = tmpDir.newFile("libjvm.dylib") + binary.writeText("original") + + val runner = + MachOCommandRunner { command -> + when (command.first().substringAfterLast('/')) { + "codesign" -> MachOCommandResult(1, "no signature") + "otool" -> MachOCommandResult(0, "") + else -> MachOCommandResult(0, "") + } + } + + val result = + mutateMachOFileSafely( + binary = binary, + operation = "test mutation", + logger = logger, + commandRunner = runner, + mutate = { copy, _ -> + copy.writeText("patched") + MachOCommandResult(0, "ok") + }, + ) + + assertTrue(result) + assertEquals("patched", binary.readText()) + } + + @Test + fun `mutateMachOFileSafely keeps original file when extra validation fails`() { + val binary = tmpDir.newFile("libmlib_image.dylib") + binary.writeText("original") + + val result = + mutateMachOFileSafely( + binary = binary, + operation = "test mutation", + logger = logger, + commandRunner = fixedRunner(), + mutate = { copy, _ -> + copy.writeText("patched") + MachOCommandResult(0, "ok") + }, + validateExtra = { _, _ -> "extra validation failed" }, + ) + + assertFalse(result) + assertEquals("original", binary.readText()) + } + + private fun fixedRunner() = + MachOCommandRunner { command -> + when (command.first().substringAfterLast('/')) { + "codesign" -> MachOCommandResult(0, "") + "otool" -> MachOCommandResult(0, "") + else -> MachOCommandResult(0, "") + } + } +} +