From ae54eda1576352911619f70ae3d3d48c96da4364 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Tue, 28 Apr 2026 15:27:38 +0100 Subject: [PATCH 1/2] WIP: Implement kotlin binary compatibility validator --- .../activities/PluginManagerActivity.kt | 2 +- build.gradle.kts | 14 +++ gradle/libs.versions.toml | 2 + .../com/itsaky/androidide/plugins/IPlugin.kt | 2 +- .../androidide/plugins/InternalPluginApi.kt | 18 +++ .../androidide/plugins/PluginApiVersion.kt | 11 ++ .../plugins/manager/core/PluginManager.kt | 14 +++ .../plugins/manager/loaders/PluginLoader.kt | 6 +- .../plugins/manager/loaders/PluginManifest.kt | 11 +- .../PluginApiIncompatibleException.kt | 33 +++++ .../security/PluginApiVersionChecker.kt | 50 ++++++++ .../manager/security/PluginSecurityManager.kt | 3 +- .../security/PluginApiVersionCheckerTest.kt | 114 ++++++++++++++++++ .../impl/pluginProject/pluginManifest.kt | 2 +- 14 files changed, 266 insertions(+), 16 deletions(-) create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/InternalPluginApi.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/PluginApiVersion.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiIncompatibleException.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionChecker.kt create mode 100644 plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionCheckerTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index 53799d007f..d48f37e9f0 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -331,7 +331,7 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { append("Version: ${plugin.metadata.version}\n") append("Author: ${plugin.metadata.author}\n") append("Description: ${plugin.metadata.description}\n") - append("Min IDE Version: ${plugin.metadata.minIdeVersion}\n") + append("Min Plugin API Version: ${plugin.metadata.minPluginApiVersion}\n") append("Permissions: ${plugin.metadata.permissions.joinToString(", ")}\n") } diff --git a/build.gradle.kts b/build.gradle.kts index 8c09555454..35f4dc2455 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,9 +45,23 @@ plugins { alias(libs.plugins.google.protobuf) apply false alias(libs.plugins.spotless) alias(libs.plugins.sonarqube) + alias(libs.plugins.binary.compatibility.validator) id("jacoco") } +apiValidation { + val pluginApiProjects = setOf("plugin-api") + rootProject.subprojects.forEach { subproject -> + if (subproject.name !in pluginApiProjects) { + ignoredProjects.add(subproject.name) + } + } + + nonPublicMarkers.add("com.itsaky.androidide.plugins.InternalPluginApi") + + ignoredClasses.add("com.itsaky.androidide.plugins.api.BuildConfig") +} + jacoco { toolVersion = "0.8.11" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d8e60c15da..622362e155 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,6 +66,7 @@ libsu-core = "6.0.0" ## put it in app/src/main/jniLibs brotli4j = "1.18.0" spotless = "7.2.1" +binary-compatibility-validator = "0.18.1" fragmentKtx = "1.8.8" protobuf = "4.32.1" @@ -332,3 +333,4 @@ rikka-materialthemebuilder = { id = "dev.rikka.tools.materialthemebuilder", vers rikka-refine = { id = "dev.rikka.tools.refine", version.ref = "rikka-refine" } sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } +binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" } diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt index 818ea3bcb1..fb96361416 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt @@ -18,7 +18,7 @@ data class PluginMetadata( val version: String, val description: String, val author: String, - val minIdeVersion: String, + val minPluginApiVersion: String, val permissions: List = emptyList(), val dependencies: List = emptyList(), val iconDayPath: String? = null, diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/InternalPluginApi.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/InternalPluginApi.kt new file mode 100644 index 0000000000..09f690a7f3 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/InternalPluginApi.kt @@ -0,0 +1,18 @@ +package com.itsaky.androidide.plugins + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This declaration is internal to the IDE and is not part of the plugin API contract. " + + "It may change or be removed at any time without a major version bump." +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.TYPEALIAS, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, +) +annotation class InternalPluginApi diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/PluginApiVersion.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/PluginApiVersion.kt new file mode 100644 index 0000000000..1a0dc8eea3 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/PluginApiVersion.kt @@ -0,0 +1,11 @@ +package com.itsaky.androidide.plugins + +/** + * Plugin API contract version (SemVer). Bump major for binary-incompatible changes + * flagged by `:plugin-api:apiCheck`, minor for additive changes; then run + * `:plugin-api:apiDump` to refresh `plugin-api/api/plugin-api.api`. + */ +object PluginApiVersion { + + const val CURRENT: String = "1.0.0" +} diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index 734a984e13..c4709d9e0c 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -28,6 +28,8 @@ import com.itsaky.androidide.plugins.manager.loaders.PluginManifest import com.itsaky.androidide.plugins.manager.loaders.PluginLoader import com.itsaky.androidide.plugins.manager.loaders.toPluginMetadata import com.itsaky.androidide.plugins.manager.loaders.PluginResourceContext +import com.itsaky.androidide.plugins.manager.security.PluginApiIncompatibleException +import com.itsaky.androidide.plugins.manager.security.PluginApiVersionChecker import com.itsaky.androidide.plugins.manager.security.PluginSecurityManager import com.itsaky.androidide.plugins.manager.context.PluginContextImpl import com.itsaky.androidide.plugins.manager.context.PluginLoggerImpl @@ -394,6 +396,12 @@ class PluginManager private constructor( return Result.failure(SecurityException("plugin failed security validation: ${manifest.id}")) } + PluginApiVersionChecker.requireCompatible( + pluginId = manifest.id, + required = manifest.minPluginApiVersion ?: "1.0.0", + current = PluginApiVersion.CURRENT, + ) + // Validate sidebar slots BEFORE loading plugin code if (manifest.sidebarItems > 0) { val available = SidebarSlotManager.getAvailableSlotsForPlugins() @@ -514,6 +522,12 @@ class PluginManager private constructor( activateLoadedPlugin(loadedPlugin) Result.success(plugin) + } catch (e: PluginApiIncompatibleException) { + reservedSlotsPluginId?.let { pluginId -> + SidebarSlotManager.releasePluginSlots(pluginId) + } + logger.error("Plugin API incompatibility for ${file.name}: ${e.message}") + Result.failure(e) } catch (e: Exception) { reservedSlotsPluginId?.let { pluginId -> SidebarSlotManager.releasePluginSlots(pluginId) diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginLoader.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginLoader.kt index f4a3c05a22..28946196dc 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginLoader.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginLoader.kt @@ -240,8 +240,7 @@ class PluginLoader( val pluginDescription = metaData.getString("plugin.description") ?: "" val pluginAuthor = metaData.getString("plugin.author") ?: "" val pluginMainClass = metaData.getString("plugin.main_class") ?: return null - val pluginMinIdeVersion = metaData.getString("plugin.min_ide_version") ?: "1.0.0" - val pluginMaxIdeVersion = metaData.getString("plugin.max_ide_version") + val pluginMinPluginApiVersion = metaData.getString("plugin.min_plugin_api_version") ?: "1.0.0" // Parse permissions val permissions = metaData.getString("plugin.permissions")?.split(",")?.map { it.trim() } ?: emptyList() @@ -262,8 +261,7 @@ class PluginLoader( description = pluginDescription, author = pluginAuthor, mainClass = pluginMainClass, - minIdeVersion = pluginMinIdeVersion, - maxIdeVersion = pluginMaxIdeVersion, + minPluginApiVersion = pluginMinPluginApiVersion, permissions = permissions, dependencies = dependencies, extensions = emptyList(), diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt index 2c3d0f6d75..b175c53f4d 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt @@ -27,12 +27,9 @@ data class PluginManifest( @SerializedName("main_class") val mainClass: String, - @SerializedName("min_ide_version") - val minIdeVersion: String, - - @SerializedName("max_ide_version") - val maxIdeVersion: String? = null, - + @SerializedName("min_plugin_api_version") + val minPluginApiVersion: String? = null, + @SerializedName("permissions") val permissions: List = emptyList(), @@ -95,7 +92,7 @@ fun PluginManifest.toPluginMetadata() = PluginMetadata( version = version, description = description, author = author, - minIdeVersion = minIdeVersion, + minPluginApiVersion = minPluginApiVersion ?: "1.0.0", dependencies = dependencies, permissions = permissions ) diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiIncompatibleException.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiIncompatibleException.kt new file mode 100644 index 0000000000..09b5586812 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiIncompatibleException.kt @@ -0,0 +1,33 @@ +package com.itsaky.androidide.plugins.manager.security + +class PluginApiIncompatibleException( + val pluginId: String, + val requiredVersion: String, + val availableVersion: String, + val reason: Reason, +) : Exception(buildMessage(pluginId, requiredVersion, availableVersion, reason)) { + + enum class Reason { + MAJOR_MISMATCH, + REQUIRES_NEWER, + MALFORMED_VERSION, + } + + private companion object { + fun buildMessage( + pluginId: String, + requiredVersion: String, + availableVersion: String, + reason: Reason, + ): String = when (reason) { + Reason.MAJOR_MISMATCH -> + "Plugin '$pluginId' targets plugin API $requiredVersion, " + + "but this IDE provides $availableVersion (incompatible major version)." + Reason.REQUIRES_NEWER -> + "Plugin '$pluginId' requires plugin API $requiredVersion, " + + "but this IDE provides $availableVersion. Update the IDE to use this plugin." + Reason.MALFORMED_VERSION -> + "Plugin '$pluginId' declares an invalid plugin API version: '$requiredVersion'." + } + } +} diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionChecker.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionChecker.kt new file mode 100644 index 0000000000..523c05202f --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionChecker.kt @@ -0,0 +1,50 @@ +package com.itsaky.androidide.plugins.manager.security + +internal object PluginApiVersionChecker { + + private val SEMVER = Regex("^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?$") + + fun isCompatible(required: String, current: String): Boolean { + val r = parse(required) ?: return false + val c = parse(current) ?: error("Invalid current plugin API version: '$current'") + return r.major == c.major && + (r.minor < c.minor || (r.minor == c.minor && r.patch <= c.patch)) + } + + fun requireCompatible(pluginId: String, required: String, current: String) { + val r = parse(required) ?: throw PluginApiIncompatibleException( + pluginId = pluginId, + requiredVersion = required, + availableVersion = current, + reason = PluginApiIncompatibleException.Reason.MALFORMED_VERSION, + ) + val c = parse(current) ?: error("Invalid current plugin API version: '$current'") + if (r.major != c.major) { + throw PluginApiIncompatibleException( + pluginId = pluginId, + requiredVersion = required, + availableVersion = current, + reason = PluginApiIncompatibleException.Reason.MAJOR_MISMATCH, + ) + } + if (r.minor > c.minor || (r.minor == c.minor && r.patch > c.patch)) { + throw PluginApiIncompatibleException( + pluginId = pluginId, + requiredVersion = required, + availableVersion = current, + reason = PluginApiIncompatibleException.Reason.REQUIRES_NEWER, + ) + } + } + + private data class Version(val major: Int, val minor: Int, val patch: Int) + + private fun parse(raw: String): Version? { + val match = SEMVER.matchEntire(raw.trim()) ?: return null + return Version( + major = match.groupValues[1].toInt(), + minor = match.groupValues[2].ifBlank { "0" }.toInt(), + patch = match.groupValues[3].ifBlank { "0" }.toInt(), + ) + } +} diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginSecurityManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginSecurityManager.kt index 509145a607..fcb4474076 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginSecurityManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/security/PluginSecurityManager.kt @@ -85,8 +85,7 @@ class PluginSecurityManager { return manifest.id.isNotBlank() && manifest.name.isNotBlank() && manifest.version.isNotBlank() && - manifest.mainClass.isNotBlank() && - manifest.minIdeVersion.isNotBlank() + manifest.mainClass.isNotBlank() } private fun validatePermissions(permissions: List): Boolean { diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionCheckerTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionCheckerTest.kt new file mode 100644 index 0000000000..87839f6947 --- /dev/null +++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/security/PluginApiVersionCheckerTest.kt @@ -0,0 +1,114 @@ +package com.itsaky.androidide.plugins.manager.security + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class PluginApiVersionCheckerTest { + + @Test + fun `same version is compatible`() { + assertTrue(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "1.0.0")) + } + + @Test + fun `IDE with newer minor accepts plugin built against older minor`() { + assertTrue(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "1.5.0")) + } + + @Test + fun `IDE with newer patch accepts plugin built against older patch`() { + assertTrue(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "1.0.5")) + } + + @Test + fun `plugin requiring newer minor is incompatible`() { + assertFalse(PluginApiVersionChecker.isCompatible(required = "1.5.0", current = "1.0.0")) + } + + @Test + fun `plugin requiring newer patch within same minor is incompatible`() { + assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.5", current = "1.0.0")) + } + + @Test + fun `plugin built for older major is incompatible`() { + assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.0", current = "2.0.0")) + } + + @Test + fun `plugin built for newer major is incompatible`() { + assertFalse(PluginApiVersionChecker.isCompatible(required = "2.0.0", current = "1.0.0")) + } + + @Test + fun `major-only shorthand parses as major dot zero dot zero`() { + assertTrue(PluginApiVersionChecker.isCompatible(required = "1", current = "1.0.0")) + assertTrue(PluginApiVersionChecker.isCompatible(required = "1", current = "1.5.3")) + assertFalse(PluginApiVersionChecker.isCompatible(required = "2", current = "1.0.0")) + } + + @Test + fun `major-minor shorthand parses with patch zero`() { + assertTrue(PluginApiVersionChecker.isCompatible(required = "1.2", current = "1.2.0")) + assertTrue(PluginApiVersionChecker.isCompatible(required = "1.2", current = "1.2.5")) + assertFalse(PluginApiVersionChecker.isCompatible(required = "1.3", current = "1.2.0")) + } + + @Test + fun `malformed version is incompatible`() { + assertFalse(PluginApiVersionChecker.isCompatible(required = "v1.0.0", current = "1.0.0")) + assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.0-snapshot", current = "1.0.0")) + assertFalse(PluginApiVersionChecker.isCompatible(required = "1.0.0.0", current = "1.0.0")) + assertFalse(PluginApiVersionChecker.isCompatible(required = "", current = "1.0.0")) + assertFalse(PluginApiVersionChecker.isCompatible(required = "abc", current = "1.0.0")) + } + + @Test + fun `whitespace is trimmed before parsing`() { + assertTrue(PluginApiVersionChecker.isCompatible(required = " 1.0.0 ", current = "1.0.0")) + } + + @Test + fun `requireCompatible does not throw on compatible versions`() { + PluginApiVersionChecker.requireCompatible("p", required = "1.0.0", current = "1.5.0") + } + + @Test + fun `requireCompatible throws MAJOR_MISMATCH for cross-major rejection`() { + val ex = assertThrows(PluginApiIncompatibleException::class.java) { + PluginApiVersionChecker.requireCompatible("plugin.x", required = "2.0.0", current = "1.0.0") + } + assertEquals("plugin.x", ex.pluginId) + assertEquals("2.0.0", ex.requiredVersion) + assertEquals("1.0.0", ex.availableVersion) + assertEquals(PluginApiIncompatibleException.Reason.MAJOR_MISMATCH, ex.reason) + } + + @Test + fun `requireCompatible throws REQUIRES_NEWER when plugin asks newer minor`() { + val ex = assertThrows(PluginApiIncompatibleException::class.java) { + PluginApiVersionChecker.requireCompatible("plugin.y", required = "1.5.0", current = "1.0.0") + } + assertEquals(PluginApiIncompatibleException.Reason.REQUIRES_NEWER, ex.reason) + } + + @Test + fun `requireCompatible throws MALFORMED_VERSION for unparseable input`() { + val ex = assertThrows(PluginApiIncompatibleException::class.java) { + PluginApiVersionChecker.requireCompatible("plugin.z", required = "not-a-version", current = "1.0.0") + } + assertEquals(PluginApiIncompatibleException.Reason.MALFORMED_VERSION, ex.reason) + assertEquals("not-a-version", ex.requiredVersion) + } + + @Test + fun `requireCompatible throws REQUIRES_NEWER for newer patch within same minor`() { + val ex = assertThrows(PluginApiIncompatibleException::class.java) { + PluginApiVersionChecker.requireCompatible("p", required = "1.0.5", current = "1.0.0") + } + assertEquals(PluginApiIncompatibleException.Reason.REQUIRES_NEWER, ex.reason) + } +} diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/pluginProject/pluginManifest.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/pluginProject/pluginManifest.kt index 43f2feee74..7dfde01c70 100644 --- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/pluginProject/pluginManifest.kt +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/pluginProject/pluginManifest.kt @@ -36,7 +36,7 @@ fun pluginAndroidManifest(data: PluginTemplateData): String { android:value="${data.author}" />