app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 92cf3a757805df1f215df36791b1d827b5367618
parent 477e12e2f55a0affeaf69482009ec23e6283fccf
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 21:30:11 +0000

build: add the Rust FFI convention plugin

- centralize Cargo and UniFFI execution behind typed service-backed tasks
- preserve native target, provenance, and generated Kotlin contracts
- expose only native artifacts needed by the pending packaging seam
- cover stale output, target, byte, provenance, and cache failures

Diffstat:
Mapp/desktop/build.gradle.kts | 351+++++---------------------------------------------------------------------------
Mapp/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductCoordinateConsumerTest.kt | 11++++++++---
Mbuild-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleConventionPlugins.kt | 4----
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt | 5+++++
Abuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinateConsumers.kt | 8+++++++-
Abuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/RustFfiTasks.kt | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 656 insertions(+), 344 deletions(-)

diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -4,51 +4,29 @@ import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property -import org.gradle.api.tasks.Delete -import org.gradle.api.tasks.Exec import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction -import org.gradle.api.tasks.testing.Test import org.gradle.jvm.tasks.Jar +import org.harvestcircle.buildlogic.plugins.HarvestCircleRustFfiExtension import org.harvestcircle.gradle.FfiCompatibilityBaseline -import org.harvestcircle.gradle.GenerateCompatibilityExpectations import org.harvestcircle.gradle.ProductCoordinates -import org.harvestcircle.gradle.VerifyGeneratedCompatibilityExpectations import org.jetbrains.compose.desktop.application.dsl.TargetFormat -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.File -import java.security.MessageDigest import java.util.jar.JarFile plugins { id("org.harvestcircle.build.desktop-app") + id("org.harvestcircle.build.rust-ffi") } -val rustCoreSource = +val rustManifest = rootProject.layout.projectDirectory - .dir("core") + .file("core/Cargo.toml") .asFile -val cargoTargetRoot = - providers.environmentVariable("CARGO_TARGET_DIR").orNull?.let(::file) - ?: rustCoreSource.resolve("target") - -val radrootsOffline = - providers - .gradleProperty("radrootsOffline") - .map(String::toBooleanStrict) - .orElse(false) -val immutableCargoArguments = - if (radrootsOffline.get()) { - listOf("--frozen", "--offline") - } else { - listOf("--locked") - } - -val rustManifest = rustCoreSource.resolve("Cargo.toml") fun workspacePackageValue(key: String): String { val workspacePackage = @@ -88,284 +66,22 @@ check(Regex("""[1-9]\d*(\.\d+){0,2}""").matches(installableVersion)) { val applicationName = productCoordinates["product.name"] val productSlug = productCoordinates["product.slug"] val bundleId = productCoordinates["desktop.bundle_id"] -val ffiKotlinPackage = productCoordinates["ffi.kotlin_package"] -val environmentPrefix = productCoordinates["environment.prefix"] - -fun productEnvironment(suffix: String) = environmentPrefix + suffix -val developmentDataDirectoryEnvironment = - productEnvironment("DEVELOPMENT_DATA_DIR") val copyrightNotice = productCoordinates["copyright.notice"] val vendorName = productCoordinates["vendor.name"] -val rustSources = - fileTree(rustCoreSource) { - include( - "Cargo.toml", - "Cargo.lock", - "rust-toolchain.toml", - "compatibility/**", - "crates/**", - ) - exclude("target/**") - } - -data class NativeTarget( - val libraryName: String, - val jnaPrefix: String, -) - -fun resolveNativeTarget( - osName: String, - architecture: String, - cdylibName: String, -): NativeTarget { - val os = osName.lowercase() - val arch = architecture.lowercase() - return when { - os.startsWith("mac") && arch in setOf("aarch64", "arm64") -> - NativeTarget("lib$cdylibName.dylib", "darwin-aarch64") - os.startsWith("mac") && arch in setOf("x86_64", "amd64") -> - NativeTarget("lib$cdylibName.dylib", "darwin-x86-64") - os.startsWith("windows") && arch in setOf("aarch64", "arm64") -> - NativeTarget("$cdylibName.dll", "win32-aarch64") - os.startsWith("windows") && arch in setOf("x86_64", "amd64") -> - NativeTarget("$cdylibName.dll", "win32-x86-64") - os.startsWith("linux") && arch in setOf("aarch64", "arm64") -> - NativeTarget("lib$cdylibName.so", "linux-aarch64") - os.startsWith("linux") && arch in setOf("x86_64", "amd64") -> - NativeTarget("lib$cdylibName.so", "linux-x86-64") - else -> throw GradleException("Unsupported native desktop host: $osName/$architecture") - } -} - -val nativeOsName = providers.gradleProperty("nativeOs").getOrElse(System.getProperty("os.name")) -val nativeArchitecture = providers.gradleProperty("nativeArch").getOrElse(System.getProperty("os.arch")) -val nativeTarget = - resolveNativeTarget( - nativeOsName, - nativeArchitecture, - productCoordinates["ffi.cdylib_name"], - ) +val rustFfi = extensions.getByType<HarvestCircleRustFfiExtension>() +val nativeOsName = rustFfi.nativeOsName.get() val isMacOsHost = nativeOsName.lowercase().startsWith("mac") val isLinuxHost = nativeOsName.lowercase().startsWith("linux") val isWindowsHost = nativeOsName.lowercase().startsWith("windows") -val rustLibraryName = nativeTarget.libraryName -val rustDebugLibrary = file(cargoTargetRoot).resolve("debug/$rustLibraryName") -val rustReleaseLibrary = file(cargoTargetRoot).resolve("release/$rustLibraryName") -val jnaPlatformPrefix = nativeTarget.jnaPrefix -val buildSourceCommitEnvironment = productEnvironment("BUILD_SOURCE_COMMIT") -val buildSourceDirtyEnvironment = productEnvironment("BUILD_SOURCE_DIRTY") -val buildRadrootsRevisionEnvironment = productEnvironment("BUILD_RADROOTS_REVISION") -val buildRustToolchainEnvironment = productEnvironment("BUILD_RUST_TOOLCHAIN") -val buildJavaToolchainEnvironment = productEnvironment("BUILD_JAVA_TOOLCHAIN") -val buildKotlinToolchainEnvironment = productEnvironment("BUILD_KOTLIN_TOOLCHAIN") -val buildSourceCommit = providers.environmentVariable(buildSourceCommitEnvironment).orElse("unknown") -val buildSourceDirty = providers.environmentVariable(buildSourceDirtyEnvironment).orElse("unknown") -val buildRadrootsRevision = providers.environmentVariable(buildRadrootsRevisionEnvironment).orElse("unknown") -val buildRustToolchain = providers.environmentVariable(buildRustToolchainEnvironment).orElse("1.97.1") -val buildJavaToolchain = providers.environmentVariable(buildJavaToolchainEnvironment).orElse(System.getProperty("java.version")) -val buildKotlinToolchain = providers.environmentVariable(buildKotlinToolchainEnvironment).orElse(libs.versions.kotlin.get()) -val buildSourceDateEpoch = providers.environmentVariable("SOURCE_DATE_EPOCH").orElse("0") -val buildProvenanceDigest = - providers.provider { - listOf( - "$buildSourceCommitEnvironment=${buildSourceCommit.get()}", - "$buildSourceDirtyEnvironment=${buildSourceDirty.get()}", - "$buildRadrootsRevisionEnvironment=${buildRadrootsRevision.get()}", - "$buildRustToolchainEnvironment=${buildRustToolchain.get()}", - "$buildJavaToolchainEnvironment=${buildJavaToolchain.get()}", - "$buildKotlinToolchainEnvironment=${buildKotlinToolchain.get()}", - "SOURCE_DATE_EPOCH=${buildSourceDateEpoch.get()}", - ).joinToString("\n").let { input -> - MessageDigest - .getInstance("SHA-256") - .digest(input.toByteArray(Charsets.UTF_8)) - .joinToString("") { byte -> "%02x".format(byte) } - } - } - -fun Exec.injectBuildProvenance() { - environment(buildSourceCommitEnvironment, buildSourceCommit.get()) - environment(buildSourceDirtyEnvironment, buildSourceDirty.get()) - environment(buildRadrootsRevisionEnvironment, buildRadrootsRevision.get()) - environment(buildRustToolchainEnvironment, buildRustToolchain.get()) - environment(buildJavaToolchainEnvironment, buildJavaToolchain.get()) - environment(buildKotlinToolchainEnvironment, buildKotlinToolchain.get()) - environment("SOURCE_DATE_EPOCH", buildSourceDateEpoch.get()) - inputs.property("buildSourceCommit", buildSourceCommit) - inputs.property("buildSourceDirty", buildSourceDirty) - inputs.property("buildRadrootsRevision", buildRadrootsRevision) - inputs.property("buildRustToolchain", buildRustToolchain) - inputs.property("buildJavaToolchain", buildJavaToolchain) - inputs.property("buildKotlinToolchain", buildKotlinToolchain) - inputs.property("buildSourceDateEpoch", buildSourceDateEpoch) -} - -val buildRustCoreDebug by tasks.registering(Exec::class) { - injectBuildProvenance() - workingDir(rustCoreSource) - commandLine( - "cargo", - "build", - "--manifest-path", - rustManifest.absolutePath, - "-p", - "harvestcircle_ffi", - *immutableCargoArguments.toTypedArray(), - ) - inputs.files(rustSources) - outputs.file(rustDebugLibrary) -} - -val buildRustCoreRelease by tasks.registering(Exec::class) { - injectBuildProvenance() - workingDir(rustCoreSource) - commandLine( - "cargo", - "build", - "--release", - "--manifest-path", - rustManifest.absolutePath, - "-p", - "harvestcircle_ffi", - *immutableCargoArguments.toTypedArray(), - ) - inputs.files(rustSources) - outputs.file(rustReleaseLibrary) -} - -val generatedUniFfiKotlin = layout.buildDirectory.dir("generated/uniffi/kotlin") -val generatedCompatibilityKotlin = layout.buildDirectory.dir("generated/compatibility/kotlin") -val generatedCompatibilityFile = - generatedCompatibilityKotlin.map { - it.file("org/harvestcircle/application/generated/NativeCompatibilityExpectations.kt") - } -val generateCompatibilityExpectations by tasks.registering(GenerateCompatibilityExpectations::class) { - baselineFile.set(ffiCompatibilityBaselineFile) - outputFile.set(generatedCompatibilityFile) -} -val verifyGeneratedSources by tasks.registering(VerifyGeneratedCompatibilityExpectations::class) { - dependsOn(generateCompatibilityExpectations) - baselineFile.set(ffiCompatibilityBaselineFile) - generatedFile.set(generatedCompatibilityFile) -} -val generatedReleaseNativeResources = layout.buildDirectory.dir("generated/uniffi/release-native-resources") -val cleanGeneratedUniFfiKotlin by tasks.registering(Delete::class) { - delete(generatedUniFfiKotlin) -} -val generateUniFfiKotlin by tasks.registering(Exec::class) { - dependsOn(buildRustCoreDebug, cleanGeneratedUniFfiKotlin) - workingDir(rustCoreSource) - commandLine( - "cargo", - "run", - "--manifest-path", - rustManifest.absolutePath, - "-p", - "harvestcircle_uniffi_bindgen", - *immutableCargoArguments.toTypedArray(), - "--", - "generate", - "--library", - "--language", - "kotlin", - "--metadata-no-deps", - "--no-format", - "--config", - rustCoreSource.resolve("crates/harvestcircle_ffi/uniffi.toml").absolutePath, - "--out-dir", - generatedUniFfiKotlin.get().asFile.absolutePath, - rustDebugLibrary.absolutePath, - ) - inputs.file(rustDebugLibrary) - inputs.file(rustCoreSource.resolve("crates/harvestcircle_ffi/uniffi.toml")) - outputs.dir(generatedUniFfiKotlin) -} - -abstract class VerifyUniFfiBindings : DefaultTask() { - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val generatedDirectory: DirectoryProperty - - @get:Input - abstract val expectedPackage: Property<String> - - @TaskAction - fun verify() { - val kotlinFiles = - generatedDirectory.asFileTree.files - .filter { it.isFile && it.extension == "kt" } - if (kotlinFiles.size != 1) { - throw GradleException("Expected exactly one generated UniFFI Kotlin source") - } - if (!kotlinFiles.single().readText().contains("package ${expectedPackage.get()}")) { - throw GradleException("Generated UniFFI Kotlin package does not match the runtime contract") - } - } -} -val verifyUniFfiBindings by tasks.registering(VerifyUniFfiBindings::class) { - dependsOn(generateUniFfiKotlin) - generatedDirectory.set(generatedUniFfiKotlin) - expectedPackage.set(ffiKotlinPackage) -} -val cleanReleaseNativeResources by tasks.registering(Delete::class) { - delete(generatedReleaseNativeResources) -} -val stageReleaseNativeLibrary by tasks.registering(Copy::class) { - dependsOn(buildRustCoreRelease, cleanReleaseNativeResources) - from(rustReleaseLibrary) - into(generatedReleaseNativeResources.map { it.dir(jnaPlatformPrefix) }) -} - -abstract class VerifyReleaseNativeLibrary : DefaultTask() { - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val releaseLibrary: RegularFileProperty - - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val stagedDirectory: DirectoryProperty - - @get:Input - abstract val expectedName: Property<String> - - @get:Input - abstract val expectedBuildEvidence: ListProperty<String> - - @TaskAction - fun verify() { - val files = stagedDirectory.asFileTree.files.filter { it.isFile } - if (files.size != 1) { - throw GradleException("Release resources must contain exactly one native library") - } - if (files.single().name != expectedName.get()) { - throw GradleException("Unexpected release native library name") - } - if (!files.single().readBytes().contentEquals(releaseLibrary.get().asFile.readBytes())) { - throw GradleException("Staged native library does not match the Cargo release artifact") - } - val binary = - releaseLibrary - .get() - .asFile - .readBytes() - .toString(Charsets.ISO_8859_1) - expectedBuildEvidence.get().forEach { evidence -> - require(binary.contains(evidence)) { "Release native library is missing build provenance evidence" } - } - } -} -val verifyReleaseNativeLibrary by tasks.registering(VerifyReleaseNativeLibrary::class) { - dependsOn(stageReleaseNativeLibrary) - releaseLibrary.set(rustReleaseLibrary) - stagedDirectory.set(generatedReleaseNativeResources) - expectedName.set(rustLibraryName) - expectedBuildEvidence.set(listOf(buildProvenanceDigest.get())) -} -val releaseNativeResourcesJar by tasks.registering(Jar::class) { - dependsOn(verifyReleaseNativeLibrary) - archiveClassifier.set("release-native-resources") - from(generatedReleaseNativeResources) -} +val rustLibraryName = rustFfi.libraryName.get() +val rustDebugLibrary = rustFfi.debugLibrary.get().asFile +val rustReleaseLibrary = rustFfi.releaseLibrary.get().asFile +val jnaPlatformPrefix = rustFfi.jnaPlatformPrefix.get() +val buildSourceCommit = rustFfi.sourceCommit +val buildSourceDirty = rustFfi.sourceDirty +val buildRadrootsRevision = rustFfi.radrootsRevision +val buildSourceDateEpoch = rustFfi.sourceDateEpoch +val releaseNativeResourcesJar = tasks.named<Jar>("releaseNativeResourcesJar") val releaseNativeRuntimeJar = releaseNativeResourcesJar .get() @@ -684,40 +400,9 @@ abstract class VerifyMacOsNotarization : DefaultTask() { } tasks.named("check") { - dependsOn(verifyGeneratedSources) dependsOn(verifyDesktopBuildMetadataArtifact) } -tasks.named<KotlinCompile>("compileKotlin") { - dependsOn(generateUniFfiKotlin, generateCompatibilityExpectations) - source(generatedUniFfiKotlin, generatedCompatibilityKotlin) -} -tasks.withType<Test>().configureEach { - dependsOn(buildRustCoreDebug) - val nativeTestData = - layout.buildDirectory - .dir("native-test-data") - .get() - .asFile.absolutePath - environment( - developmentDataDirectoryEnvironment, - nativeTestData, - ) - systemProperty("$productSlug.development.data.dir", nativeTestData) - systemProperty( - "jna.library.path", - rustDebugLibrary.parentFile.absolutePath, - ) -} -tasks.withType<JavaExec>().configureEach { - dependsOn(buildRustCoreDebug) - systemProperty("$productSlug.development", "true") - systemProperty( - "jna.library.path", - rustDebugLibrary.parentFile.absolutePath, - ) -} - compose.desktop { application { disableDefaultConfiguration() @@ -780,7 +465,7 @@ val verifyMacOsPackage by tasks.registering(VerifyMacOsPackage::class) { expectedFileName.set("$applicationName-$installableVersion.dmg") } val verifyLinuxPackage by tasks.registering(VerifyNativeInstallPackage::class) { - dependsOn("packageDeb", verifyReleaseNativeLibrary) + dependsOn("packageDeb", "verifyReleaseNativeLibrary") packageDirectory.set(layout.buildDirectory.dir("compose/binaries/main/deb")) releaseLibrary.set(rustReleaseLibrary) packageExtension.set("deb") @@ -789,7 +474,7 @@ val verifyLinuxPackage by tasks.registering(VerifyNativeInstallPackage::class) { hostFamily.set("linux") } val verifyWindowsPackage by tasks.registering(VerifyNativeInstallPackage::class) { - dependsOn("packageMsi", verifyReleaseNativeLibrary) + dependsOn("packageMsi", "verifyReleaseNativeLibrary") packageDirectory.set(layout.buildDirectory.dir("compose/binaries/main/msi")) releaseLibrary.set(rustReleaseLibrary) packageExtension.set("msi") @@ -855,7 +540,7 @@ val sourceReadiness by tasks.registering { ":verifyFoundationArchive", ":app:shared:check", "check", - verifyUniFfiBindings, + "verifyUniFfiBindings", ) } val packageReadiness by tasks.registering { diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductCoordinateConsumerTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductCoordinateConsumerTest.kt @@ -29,11 +29,16 @@ class ProductCoordinateConsumerTest { assertEquals("harvestcircle.sqlite3", coordinates.getProperty("database.filename")) assertEquals("org.harvestcircle.desktop.nostr", coordinates.getProperty("keyring.service")) - val build = root.resolve("app/desktop/build.gradle.kts").readText() + val build = + listOf( + "app/desktop/build.gradle.kts", + "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt", + "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt", + ).joinToString("\n") { relativePath -> root.resolve(relativePath).readText() } assertTrue(build.contains("ProductCoordinates.parse")) - assertTrue(build.contains("mainClass = desktopMainClass")) + assertTrue(build.contains("application.mainClass = mainClass")) assertTrue(build.contains("bundleID = bundleId")) - assertTrue(build.contains("expectedPackage.set(ffiKotlinPackage)")) + assertTrue(build.contains("expectedPackage.set(productCoordinates[\"ffi.kotlin_package\"])")) assertFalse(Files.exists(root.resolve("core/compatibility/v5-baseline.properties"))) } } diff --git a/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt b/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt @@ -23,7 +23,12 @@ class ConventionPluginSmokeTest { pluginIds.forEach { pluginId -> val fixture = temporaryDirectory.resolve(pluginId.substringAfterLast('.')).createDirectories() - val desktopFixture = pluginId == "org.harvestcircle.build.desktop-app" + val desktopFixture = + pluginId in + setOf( + "org.harvestcircle.build.desktop-app", + "org.harvestcircle.build.rust-ffi", + ) fixture.resolve("settings.gradle.kts").writeText( buildString { append("pluginManagement { repositories { gradlePluginPortal(); mavenCentral() } }\n") @@ -32,7 +37,7 @@ class ConventionPluginSmokeTest { if (desktopFixture) append("include(\":app:shared\", \":app:desktop\")\n") }, ) - if (pluginId in setOf("org.harvestcircle.build.kmp-shared", "org.harvestcircle.build.desktop-app")) { + if (pluginId == "org.harvestcircle.build.kmp-shared" || desktopFixture) { fixture.resolve("gradle").createDirectories().resolve("libs.versions.toml").writeText(kmpCatalog) } val buildFile = @@ -42,7 +47,13 @@ class ConventionPluginSmokeTest { } else { fixture.resolve("build.gradle.kts") } - val pluginBlock = if (pluginId == "org.harvestcircle.build.kmp-shared") kmpPlugins else "id(\"$pluginId\")" + val pluginBlock = + when (pluginId) { + "org.harvestcircle.build.kmp-shared" -> kmpPlugins + "org.harvestcircle.build.rust-ffi" -> + "id(\"org.harvestcircle.build.desktop-app\")\nid(\"$pluginId\")" + else -> "id(\"$pluginId\")" + } buildFile.writeText("plugins { $pluginBlock }\n") val runner = @@ -57,6 +68,9 @@ class ConventionPluginSmokeTest { assertTrue(result.output.contains("verifyProductCoordinates"), result.output) assertTrue(runner.build().output.contains("Reusing configuration cache")) } + if (pluginId == "org.harvestcircle.build.rust-ffi") { + assertTrue(runner.build().output.contains("Reusing configuration cache")) + } } } @@ -161,6 +175,104 @@ class ConventionPluginSmokeTest { assertTrue(result.output.contains("No Kotlin tests found under src/test/kotlin"), result.output) } + @Test + fun rustPluginRejectsAnUnsupportedNativeTarget() { + val fixture = createTempDirectory("harvestcircle-rust-target-") + prepareRustBuild(fixture) + + val result = + GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withPluginClasspath() + .withArguments(":app:desktop:tasks", "-PnativeOs=plan9", "-PnativeArch=mips", "--stacktrace") + .buildAndFail() + + assertTrue(result.output.contains("Unsupported native desktop host: plan9/mips"), result.output) + } + + @Test + fun rustPluginRejectsStaleGeneratedCompatibilityKotlin() { + val fixture = createTempDirectory("harvestcircle-rust-stale-") + prepareRustBuild( + fixture, + """ + tasks.named<org.harvestcircle.buildlogic.plugins.tasks.VerifyGeneratedCompatibilityExpectations>( + "verifyGeneratedSources", + ) { + generatedFile.set(layout.projectDirectory.file("stale.kt")) + } + """.trimIndent(), + ) + fixture.resolve("app/desktop/stale.kt").writeText("// stale\n") + + val result = + GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withPluginClasspath() + .withArguments( + ":app:desktop:verifyGeneratedSources", + "-x", + ":app:desktop:generateCompatibilityExpectations", + "--stacktrace", + ) + .buildAndFail() + + assertTrue(result.output.contains("Generated Kotlin compatibility expectations is stale"), result.output) + } + + @Test + fun rustPluginRejectsNativeByteAndProvenanceMismatches() { + listOf( + Triple("bytes", "release", "staged"), + Triple("provenance", "same", "same"), + ).forEach { (caseName, releaseBytes, stagedBytes) -> + val fixture = createTempDirectory("harvestcircle-rust-$caseName-") + prepareRustBuild( + fixture, + """ + tasks.named<org.harvestcircle.buildlogic.plugins.tasks.VerifyReleaseNativeLibrary>( + "verifyReleaseNativeLibrary", + ) { + releaseLibrary.set(layout.projectDirectory.file("native/release/libfixture.dylib")) + stagedDirectory.set(layout.projectDirectory.dir("native/staged")) + expectedName.set("libfixture.dylib") + expectedBuildEvidence.set(listOf("required-proof")) + } + """.trimIndent(), + ) + fixture + .resolve("app/desktop/native/release") + .createDirectories() + .resolve("libfixture.dylib") + .writeText(releaseBytes) + fixture + .resolve("app/desktop/native/staged/darwin-aarch64") + .createDirectories() + .resolve("libfixture.dylib") + .writeText(stagedBytes) + + val result = + GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withPluginClasspath() + .withArguments( + ":app:desktop:verifyReleaseNativeLibrary", + "-x", + ":app:desktop:stageReleaseNativeLibrary", + "--stacktrace", + ) + .buildAndFail() + + val expected = + if (caseName == "bytes") { + "Staged native library does not match the Cargo release artifact" + } else { + "Release native library is missing build provenance evidence" + } + assertTrue(result.output.contains(expected), result.output) + } + } + private fun prepareDesktopBuild( fixture: java.nio.file.Path, withUnitTest: Boolean, @@ -180,6 +292,23 @@ class ConventionPluginSmokeTest { ) } + private fun prepareRustBuild( + fixture: java.nio.file.Path, + extraBuildLogic: String = "", + ) { + prepareDesktopBuild(fixture, withUnitTest = true) + fixture.resolve("app/desktop/build.gradle.kts").writeText( + """ + plugins { + id("org.harvestcircle.build.desktop-app") + id("org.harvestcircle.build.rust-ffi") + } + + $extraBuildLogic + """.trimIndent() + "\n", + ) + } + private val kmpCatalog = """ [versions] diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleConventionPlugins.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleConventionPlugins.kt @@ -3,10 +3,6 @@ package org.harvestcircle.buildlogic.plugins import org.gradle.api.Plugin import org.gradle.api.Project -public class HarvestCircleRustFfiPlugin : Plugin<Project> { - override fun apply(target: Project) = Unit -} - public class HarvestCirclePackagingPlugin : Plugin<Project> { override fun apply(target: Project) = Unit } diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt @@ -57,6 +57,11 @@ public class HarvestCircleRootPlugin : Plugin<Project> { "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt", ), ) + task.rustPluginFile.set( + target.layout.projectDirectory.file( + "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt", + ), + ) task.uniFfiConfigFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_ffi/uniffi.toml")) task.productBuildFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_product/build.rs")) task.ffiConsumerFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_ffi/src/commands.rs")) diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt @@ -0,0 +1,227 @@ +package org.harvestcircle.buildlogic.plugins + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.tasks.Jar +import org.harvestcircle.buildlogic.contracts.FfiCompatibilityBaseline +import org.harvestcircle.buildlogic.contracts.ProductCoordinates +import org.harvestcircle.buildlogic.contracts.resolveNativeTarget +import org.harvestcircle.buildlogic.plugins.tasks.CargoBuildTask +import org.harvestcircle.buildlogic.plugins.tasks.GenerateCompatibilityExpectations +import org.harvestcircle.buildlogic.plugins.tasks.GenerateUniFfiKotlinTask +import org.harvestcircle.buildlogic.plugins.tasks.StageReleaseNativeLibrary +import org.harvestcircle.buildlogic.plugins.tasks.VerifyGeneratedCompatibilityExpectations +import org.harvestcircle.buildlogic.plugins.tasks.VerifyReleaseNativeLibrary +import org.harvestcircle.buildlogic.plugins.tasks.VerifyUniFfiBindings +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import java.security.MessageDigest + +public abstract class HarvestCircleRustFfiExtension { + public abstract val nativeOsName: Property<String> + public abstract val nativeArchitecture: Property<String> + public abstract val libraryName: Property<String> + public abstract val jnaPlatformPrefix: Property<String> + public abstract val debugLibrary: RegularFileProperty + public abstract val releaseLibrary: RegularFileProperty + public abstract val sourceCommit: Property<String> + public abstract val sourceDirty: Property<String> + public abstract val radrootsRevision: Property<String> + public abstract val sourceDateEpoch: Property<String> +} + +public class HarvestCircleRustFfiPlugin : Plugin<Project> { + override fun apply(target: Project) { + require(target.plugins.hasPlugin("org.harvestcircle.build.desktop-app")) { + "HarvestCircle Rust FFI convention requires the desktop application convention" + } + val catalog = target.extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + val productCoordinates = + ProductCoordinates.load( + target.rootProject.layout.projectDirectory.file("config/product/harvestcircle-v1.properties").asFile, + ) + val baselineFile = + target.rootProject.layout.projectDirectory.file("core/compatibility/harvestcircle-ffi-v4.properties") + val baseline = FfiCompatibilityBaseline.load(baselineFile.asFile) + val rustRoot = target.rootProject.layout.projectDirectory.dir("core") + val rustManifest = rustRoot.file("Cargo.toml") + val cargoTargetRoot = + target.providers.environmentVariable("CARGO_TARGET_DIR").orNull?.let(target::file) + ?: rustRoot.dir("target").asFile + val immutableArguments = + if (target.providers.gradleProperty("radrootsOffline").map(String::toBooleanStrict).orElse(false).get()) { + listOf("--frozen", "--offline") + } else { + listOf("--locked") + } + val osName = target.providers.gradleProperty("nativeOs").getOrElse(System.getProperty("os.name")) + val architecture = target.providers.gradleProperty("nativeArch").getOrElse(System.getProperty("os.arch")) + val nativeTarget = resolveNativeTarget(osName, architecture, productCoordinates["ffi.cdylib_name"]) + val debugLibraryFile = target.file(cargoTargetRoot).resolve("debug/${nativeTarget.libraryName}") + val releaseLibraryFile = target.file(cargoTargetRoot).resolve("release/${nativeTarget.libraryName}") + val environmentPrefix = productCoordinates["environment.prefix"] + fun productEnvironment(suffix: String): String = environmentPrefix + suffix + val sourceCommit = target.providers.environmentVariable(productEnvironment("BUILD_SOURCE_COMMIT")).orElse("unknown") + val sourceDirty = target.providers.environmentVariable(productEnvironment("BUILD_SOURCE_DIRTY")).orElse("unknown") + val radrootsRevision = + target.providers.environmentVariable(productEnvironment("BUILD_RADROOTS_REVISION")).orElse("unknown") + val rustToolchain = + target.providers.environmentVariable(productEnvironment("BUILD_RUST_TOOLCHAIN")).orElse("1.97.1") + val javaToolchain = + target.providers.environmentVariable(productEnvironment("BUILD_JAVA_TOOLCHAIN")).orElse(System.getProperty("java.version")) + val kotlinToolchain = + target.providers + .environmentVariable(productEnvironment("BUILD_KOTLIN_TOOLCHAIN")) + .orElse(catalog.findVersion("kotlin").get().requiredVersion) + val sourceDateEpoch = target.providers.environmentVariable("SOURCE_DATE_EPOCH").orElse("0") + val buildEnvironment = + target.providers.provider { + linkedMapOf( + productEnvironment("BUILD_SOURCE_COMMIT") to sourceCommit.get(), + productEnvironment("BUILD_SOURCE_DIRTY") to sourceDirty.get(), + productEnvironment("BUILD_RADROOTS_REVISION") to radrootsRevision.get(), + productEnvironment("BUILD_RUST_TOOLCHAIN") to rustToolchain.get(), + productEnvironment("BUILD_JAVA_TOOLCHAIN") to javaToolchain.get(), + productEnvironment("BUILD_KOTLIN_TOOLCHAIN") to kotlinToolchain.get(), + "SOURCE_DATE_EPOCH" to sourceDateEpoch.get(), + ) + } + val provenanceDigest = + buildEnvironment.map { environment -> + environment.entries.joinToString("\n") { (key, value) -> "$key=$value" }.sha256() + } + val rustSources = + target.fileTree(rustRoot) { tree -> + tree.include("Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "compatibility/**", "crates/**") + tree.exclude("target/**") + } + + val buildDebug = + target.tasks.register("buildRustCoreDebug", CargoBuildTask::class.java) { task -> + task.workingDirectory.set(rustRoot) + task.manifestFile.set(rustManifest) + task.rustSources.from(rustSources) + task.packageName.set("harvestcircle_ffi") + task.release.set(false) + task.immutableArguments.set(immutableArguments) + task.buildEnvironment.set(buildEnvironment) + task.libraryFile.set(debugLibraryFile) + } + val buildRelease = + target.tasks.register("buildRustCoreRelease", CargoBuildTask::class.java) { task -> + task.workingDirectory.set(rustRoot) + task.manifestFile.set(rustManifest) + task.rustSources.from(rustSources) + task.packageName.set("harvestcircle_ffi") + task.release.set(true) + task.immutableArguments.set(immutableArguments) + task.buildEnvironment.set(buildEnvironment) + task.libraryFile.set(releaseLibraryFile) + } + val generatedUniFfi = target.layout.buildDirectory.dir("generated/uniffi/kotlin") + val generatedCompatibility = target.layout.buildDirectory.dir("generated/compatibility/kotlin") + val compatibilityFile = + generatedCompatibility.map { + it.file("org/harvestcircle/application/generated/NativeCompatibilityExpectations.kt") + } + val generateCompatibility = + target.tasks.register( + "generateCompatibilityExpectations", + GenerateCompatibilityExpectations::class.java, + ) { task -> + task.baselineFile.set(baselineFile) + task.outputFile.set(compatibilityFile) + } + val verifyGeneratedSources = + target.tasks.register( + "verifyGeneratedSources", + VerifyGeneratedCompatibilityExpectations::class.java, + ) { task -> + task.dependsOn(generateCompatibility) + task.baselineFile.set(baselineFile) + task.generatedFile.set(compatibilityFile) + } + val generateUniFfi = + target.tasks.register("generateUniFfiKotlin", GenerateUniFfiKotlinTask::class.java) { task -> + task.dependsOn(buildDebug) + task.workingDirectory.set(rustRoot) + task.manifestFile.set(rustManifest) + task.configFile.set(rustRoot.file("crates/harvestcircle_ffi/uniffi.toml")) + task.nativeLibrary.set(debugLibraryFile) + task.immutableArguments.set(immutableArguments) + task.outputDirectory.set(generatedUniFfi) + } + val verifyBindings = + target.tasks.register("verifyUniFfiBindings", VerifyUniFfiBindings::class.java) { task -> + task.dependsOn(generateUniFfi) + task.generatedDirectory.set(generatedUniFfi) + task.expectedPackage.set(productCoordinates["ffi.kotlin_package"]) + } + val stagedRelease = target.layout.buildDirectory.dir("generated/uniffi/release-native-resources") + val stageRelease = + target.tasks.register("stageReleaseNativeLibrary", StageReleaseNativeLibrary::class.java) { task -> + task.dependsOn(buildRelease) + task.releaseLibrary.set(releaseLibraryFile) + task.platformPrefix.set(nativeTarget.jnaPrefix) + task.outputDirectory.set(stagedRelease) + } + val verifyRelease = + target.tasks.register("verifyReleaseNativeLibrary", VerifyReleaseNativeLibrary::class.java) { task -> + task.dependsOn(stageRelease) + task.releaseLibrary.set(releaseLibraryFile) + task.stagedDirectory.set(stagedRelease) + task.expectedName.set(nativeTarget.libraryName) + task.expectedBuildEvidence.set(provenanceDigest.map(::listOf)) + } + target.tasks.register("releaseNativeResourcesJar", Jar::class.java) { task -> + task.dependsOn(verifyRelease) + task.archiveClassifier.set("release-native-resources") + task.from(stagedRelease) + } + + val desktopExtension = target.extensions.getByType(HarvestCircleDesktopAppExtension::class.java) + desktopExtension.generatedKotlinSources.from(generatedUniFfi, generatedCompatibility) + target.tasks.named("compileKotlin", KotlinCompile::class.java) { task -> + task.dependsOn(generateUniFfi, generateCompatibility) + } + target.tasks.withType(Test::class.java).configureEach { task -> + task.dependsOn(buildDebug) + val nativeTestData = target.layout.buildDirectory.dir("native-test-data").get().asFile.absolutePath + task.environment(productEnvironment("DEVELOPMENT_DATA_DIR"), nativeTestData) + task.systemProperty("${productCoordinates["product.slug"]}.development.data.dir", nativeTestData) + task.systemProperty("jna.library.path", debugLibraryFile.parentFile.absolutePath) + } + target.tasks.withType(JavaExec::class.java).configureEach { task -> + task.dependsOn(buildDebug) + task.systemProperty("${productCoordinates["product.slug"]}.development", "true") + task.systemProperty("jna.library.path", debugLibraryFile.parentFile.absolutePath) + } + target.tasks.named("check") { task -> task.dependsOn(verifyGeneratedSources) } + + target.extensions.create("harvestCircleRustFfi", HarvestCircleRustFfiExtension::class.java).apply { + nativeOsName.set(osName) + nativeArchitecture.set(architecture) + libraryName.set(nativeTarget.libraryName) + jnaPlatformPrefix.set(nativeTarget.jnaPrefix) + debugLibrary.set(debugLibraryFile) + releaseLibrary.set(releaseLibraryFile) + this.sourceCommit.set(sourceCommit) + this.sourceDirty.set(sourceDirty) + this.radrootsRevision.set(radrootsRevision) + this.sourceDateEpoch.set(sourceDateEpoch) + } + check(baseline["product.coordinate_digest"] == productCoordinates.digest) { + "FFI compatibility product-coordinate digest is stale" + } + } +} + +private fun String.sha256(): String = + MessageDigest + .getInstance("SHA-256") + .digest(toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte) } diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinateConsumers.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinateConsumers.kt @@ -23,6 +23,10 @@ abstract class VerifyProductCoordinateConsumers : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val rustPluginFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) abstract val uniFfiConfigFile: RegularFileProperty @get:InputFile @@ -43,7 +47,9 @@ abstract class VerifyProductCoordinateConsumers : DefaultTask() { val desktopBuild = desktopBuildFile.get().asFile.readText() + "\n" + - desktopPluginFile.get().asFile.readText() + desktopPluginFile.get().asFile.readText() + + "\n" + + rustPluginFile.get().asFile.readText() listOf( "product.name", "product.slug", diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/RustFfiTasks.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/RustFfiTasks.kt @@ -0,0 +1,259 @@ +package org.harvestcircle.buildlogic.plugins.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +import org.harvestcircle.buildlogic.contracts.FfiCompatibilityBaseline +import org.harvestcircle.buildlogic.contracts.GeneratedKotlin +import javax.inject.Inject + +@DisableCachingByDefault(because = "Cargo owns its incremental and artifact caches") +public abstract class CargoBuildTask + @Inject + constructor( + private val execOperations: ExecOperations, + ) : DefaultTask() { + @get:Internal + public abstract val workingDirectory: DirectoryProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val manifestFile: RegularFileProperty + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val rustSources: ConfigurableFileCollection + + @get:Input + public abstract val packageName: Property<String> + + @get:Input + public abstract val release: Property<Boolean> + + @get:Input + public abstract val immutableArguments: ListProperty<String> + + @get:Input + public abstract val buildEnvironment: MapProperty<String, String> + + @get:OutputFile + public abstract val libraryFile: RegularFileProperty + + @TaskAction + public fun build() { + execOperations.exec { spec -> + spec.workingDir(workingDirectory.get().asFile) + spec.environment(buildEnvironment.get()) + spec.commandLine( + buildList { + add("cargo") + add("build") + if (release.get()) add("--release") + addAll(listOf("--manifest-path", manifestFile.get().asFile.absolutePath, "-p", packageName.get())) + addAll(immutableArguments.get()) + }, + ) + }.assertNormalExitValue() + } + } + +@DisableCachingByDefault(because = "UniFFI bindgen is a Cargo process over a native library") +public abstract class GenerateUniFfiKotlinTask + @Inject + constructor( + private val execOperations: ExecOperations, + private val fileSystemOperations: FileSystemOperations, + ) : DefaultTask() { + @get:Internal + public abstract val workingDirectory: DirectoryProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val manifestFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val configFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val nativeLibrary: RegularFileProperty + + @get:Input + public abstract val immutableArguments: ListProperty<String> + + @get:OutputDirectory + public abstract val outputDirectory: DirectoryProperty + + @TaskAction + public fun generate() { + fileSystemOperations.delete { it.delete(outputDirectory) } + execOperations.exec { spec -> + spec.workingDir(workingDirectory.get().asFile) + spec.commandLine( + buildList { + addAll( + listOf( + "cargo", + "run", + "--manifest-path", + manifestFile.get().asFile.absolutePath, + "-p", + "harvestcircle_uniffi_bindgen", + ), + ) + addAll(immutableArguments.get()) + addAll( + listOf( + "--", + "generate", + "--library", + "--language", + "kotlin", + "--metadata-no-deps", + "--no-format", + "--config", + configFile.get().asFile.absolutePath, + "--out-dir", + outputDirectory.get().asFile.absolutePath, + nativeLibrary.get().asFile.absolutePath, + ), + ) + }, + ) + }.assertNormalExitValue() + } + } + +@CacheableTask +public abstract class GenerateCompatibilityExpectations : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val baselineFile: RegularFileProperty + + @get:OutputFile + public abstract val outputFile: RegularFileProperty + + @TaskAction + public fun generate() { + val output = outputFile.get().asFile + output.parentFile.mkdirs() + output.writeText(GeneratedKotlin.compatibilityExpectations(FfiCompatibilityBaseline.load(baselineFile.get().asFile))) + } +} + +@CacheableTask +public abstract class VerifyGeneratedCompatibilityExpectations : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val baselineFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val generatedFile: RegularFileProperty + + @TaskAction + public fun verify() { + val expected = GeneratedKotlin.compatibilityExpectations(FfiCompatibilityBaseline.load(baselineFile.get().asFile)) + GeneratedKotlin.requireFresh( + generatedFile.get().asFile.takeIf { it.isFile }?.readText(), + expected, + "Generated Kotlin compatibility expectations", + ) + } +} + +@CacheableTask +public abstract class VerifyUniFfiBindings : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val generatedDirectory: DirectoryProperty + + @get:Input + public abstract val expectedPackage: Property<String> + + @TaskAction + public fun verify() { + val kotlinFiles = generatedDirectory.asFileTree.files.filter { it.isFile && it.extension == "kt" } + if (kotlinFiles.size != 1) throw GradleException("Expected exactly one generated UniFFI Kotlin source") + if (!kotlinFiles.single().readText().contains("package ${expectedPackage.get()}")) { + throw GradleException("Generated UniFFI Kotlin package does not match the runtime contract") + } + } +} + +@CacheableTask +public abstract class StageReleaseNativeLibrary + @Inject + constructor( + private val fileSystemOperations: FileSystemOperations, + ) : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val releaseLibrary: RegularFileProperty + + @get:Input + public abstract val platformPrefix: Property<String> + + @get:OutputDirectory + public abstract val outputDirectory: DirectoryProperty + + @TaskAction + public fun stage() { + fileSystemOperations.delete { it.delete(outputDirectory) } + fileSystemOperations.copy { spec -> + spec.from(releaseLibrary) + spec.into(outputDirectory.dir(platformPrefix)) + } + } + } + +@CacheableTask +public abstract class VerifyReleaseNativeLibrary : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val releaseLibrary: RegularFileProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val stagedDirectory: DirectoryProperty + + @get:Input + public abstract val expectedName: Property<String> + + @get:Input + public abstract val expectedBuildEvidence: ListProperty<String> + + @TaskAction + public fun verify() { + val files = stagedDirectory.asFileTree.files.filter { it.isFile } + if (files.size != 1) throw GradleException("Release resources must contain exactly one native library") + if (files.single().name != expectedName.get()) throw GradleException("Unexpected release native library name") + if (!files.single().readBytes().contentEquals(releaseLibrary.get().asFile.readBytes())) { + throw GradleException("Staged native library does not match the Cargo release artifact") + } + val binary = releaseLibrary.get().asFile.readBytes().toString(Charsets.ISO_8859_1) + expectedBuildEvidence.get().forEach { evidence -> + require(binary.contains(evidence)) { "Release native library is missing build provenance evidence" } + } + } +}