commit 3f30b4a4ff80c3e569f62713d8ea8dd3bd9de9e4 parent cdf1ddf3ca4dd716fc58b46cfe3efb769e96bc6b Author: triesap <tyson@radroots.org> Date: Mon, 10 Aug 2026 20:34:29 +0000 build: isolate deterministic build contracts - define Gradle-free product, compatibility, and provenance parsers - centralize native target classification and generated Kotlin renderers - cover canonicalization, stale input, and unsupported host failures - prove contract runtime classpaths exclude the Gradle API Diffstat:
9 files changed, 509 insertions(+), 15 deletions(-)
diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/BuildLogicContract.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/BuildLogicContract.kt @@ -1,4 +0,0 @@ -package org.harvestcircle.buildlogic.contracts - -/** Marker for the Gradle-independent HarvestCircle build-contract module. */ -public object BuildLogicContract diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/Digests.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/Digests.kt @@ -0,0 +1,12 @@ +package org.harvestcircle.buildlogic.contracts + +import java.security.MessageDigest + +internal fun String.sha256(): String = + MessageDigest + .getInstance("SHA-256") + .digest(toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte) } + +internal fun String.isCanonicalHex(width: Int): Boolean = + length == width && all { character -> character in '0'..'9' || character in 'a'..'f' } diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/FfiCompatibilityBaseline.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/FfiCompatibilityBaseline.kt @@ -0,0 +1,59 @@ +package org.harvestcircle.buildlogic.contracts + +public class FfiCompatibilityBaseline private constructor( + private val values: Map<String, String>, +) { + public operator fun get(key: String): String = values.getValue(key) + + public companion object { + private val requiredKeys = + linkedSetOf( + "schema", + "contract.id", + "contract.major", + "contract.minor", + "contract.hash", + "product.coordinate_digest", + "snapshot.schema", + "storage.schema.minimum", + "storage.schema.current", + "product.version", + "package.version", + "source.provenance_digest", + "source.foundation_baseline", + ) + + public fun parse(source: String): FfiCompatibilityBaseline { + require(!source.startsWith('\uFEFF')) { "FFI baseline must not contain a UTF-8 BOM" } + val values = linkedMapOf<String, String>() + source.lineSequence().forEachIndexed { index, raw -> + val line = raw.trim() + if (line.isEmpty() || line.startsWith('#')) return@forEachIndexed + val separator = line.indexOf('=') + require(separator > 0) { "FFI baseline line ${index + 1} is not key=value" } + val key = line.substring(0, separator).trim() + val value = line.substring(separator + 1).trim() + require(key in requiredKeys) { "Unknown FFI baseline key $key" } + require(value.isNotEmpty() && value.none(Char::isISOControl)) { + "FFI baseline $key is empty or contains a control character" + } + require(values.put(key, value) == null) { "Duplicate FFI baseline key $key" } + } + require(values.keys == requiredKeys) { "FFI baseline keys do not match the v4 schema" } + require(values.getValue("schema") == "harvestcircle.ffi.v4") + require(values.getValue("contract.id") == "harvestcircle-desktop-ffi-v4") + require(values.getValue("contract.major") == "4") + require(values.getValue("contract.minor") == "1") + require(values.getValue("snapshot.schema") == "1") + require(values.getValue("storage.schema.minimum") == "5") + require(values.getValue("storage.schema.current") == "10") + listOf("contract.hash", "product.coordinate_digest", "source.provenance_digest").forEach { key -> + require(values.getValue(key).isCanonicalHex(64)) + } + require(values.getValue("source.foundation_baseline").isCanonicalHex(40)) + require(values.getValue("product.version").matches(Regex("[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?"))) + require(values.getValue("package.version").matches(Regex("[1-9][0-9]*(?:\\.[0-9]+){0,2}"))) + return FfiCompatibilityBaseline(values.toMap()) + } + } +} diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/GeneratedKotlin.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/GeneratedKotlin.kt @@ -0,0 +1,74 @@ +package org.harvestcircle.buildlogic.contracts + +public data class DesktopBuildMetadataValues( + val productVersion: String, + val distributionPackageVersion: String, + val gradleToolchain: String, + val javaToolchain: String, + val kotlinToolchain: String, + val composeMultiplatformVersion: String, +) + +public object GeneratedKotlin { + public fun desktopBuildMetadata(values: DesktopBuildMetadataValues): String = + """ + |// @generated by the HarvestCircle Gradle build. Do not edit. + |package org.harvestcircle.application.generated + | + |internal object DesktopBuildMetadata { + | const val productVersion = ${values.productVersion.quoted()} + | const val distributionPackageVersion = ${values.distributionPackageVersion.quoted()} + | const val gradleToolchain = ${values.gradleToolchain.quoted()} + | const val javaToolchain = ${values.javaToolchain.quoted()} + | const val kotlinToolchain = ${values.kotlinToolchain.quoted()} + | const val composeMultiplatformVersion = ${values.composeMultiplatformVersion.quoted()} + |} + """.trimMargin() + "\n" + + public fun compatibilityExpectations(baseline: FfiCompatibilityBaseline): String = + """ + |// @generated by the HarvestCircle Gradle build. Do not edit. + |package org.harvestcircle.application.generated + | + |internal object NativeCompatibilityExpectations { + | const val ffiContractId = ${baseline["contract.id"].quoted()} + | const val productVersion = ${baseline["product.version"].quoted()} + | const val cargoPackageVersion = ${baseline["product.version"].quoted()} + | const val distributionPackageVersion = ${baseline["package.version"].quoted()} + | const val productCoordinateDigest = ${baseline["product.coordinate_digest"].quoted()} + | const val sourceProvenanceDigest = ${baseline["source.provenance_digest"].quoted()} + | const val sourceFoundationBaseline = ${baseline["source.foundation_baseline"].quoted()} + | const val ffiContractHash = ${baseline["contract.hash"].quoted()} + | val ffiContractMajor: UShort = ${baseline["contract.major"]}.toUShort() + | val minimumFfiContractMinor: UShort = ${baseline["contract.minor"]}.toUShort() + | const val snapshotSchema: UInt = ${baseline["snapshot.schema"]}U + | const val minimumStorageSchema: UInt = ${baseline["storage.schema.minimum"]}U + | const val maximumStorageSchema: UInt = ${baseline["storage.schema.current"]}U + |} + """.trimMargin() + "\n" + + public fun requireFresh( + actual: String?, + expected: String, + label: String, + ) { + require(actual != null) { "$label is missing" } + require(actual == expected) { "$label is stale" } + } +} + +private fun String.quoted(): String = + buildString { + append('"') + this@quoted.forEach { character -> + when (character) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(character) + } + } + append('"') + } diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/NativeTarget.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/NativeTarget.kt @@ -0,0 +1,30 @@ +package org.harvestcircle.buildlogic.contracts + +public data class NativeTarget( + val libraryName: String, + val jnaPrefix: String, +) + +public 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 -> error("Unsupported native desktop host: $osName/$architecture") + } +} diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/ProductCoordinates.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/ProductCoordinates.kt @@ -0,0 +1,95 @@ +package org.harvestcircle.buildlogic.contracts + +public class ProductCoordinates private constructor( + private val values: Map<String, String>, + public val canonical: String, +) { + public val digest: String = canonical.sha256() + + public operator fun get(key: String): String = values.getValue(key) + + public companion object { + public const val SCHEMA: String = "harvestcircle.product.v1" + + public val requiredKeys: List<String> = + listOf( + "schema", + "product.name", + "product.slug", + "kotlin.root_namespace", + "desktop.application_id", + "desktop.bundle_id", + "desktop.main_class", + "ffi.kotlin_package", + "ffi.cdylib_name", + "database.qualifier", + "database.organization", + "database.application", + "database.filename", + "keyring.service", + "environment.prefix", + "vendor.name", + "copyright.notice", + ) + + public fun parse(source: String): ProductCoordinates { + require(!source.startsWith('\uFEFF')) { "Product coordinates must not contain a UTF-8 BOM" } + val parsed = linkedMapOf<String, String>() + source.lineSequence().forEachIndexed { index, raw -> + val line = raw.trim() + if (line.isEmpty() || line.startsWith('#')) return@forEachIndexed + val separator = line.indexOf('=') + require(separator > 0) { "Product coordinate line ${index + 1} is not key=value" } + val key = line.substring(0, separator).trim() + val value = line.substring(separator + 1).trim() + require(key in requiredKeys) { "Unknown product coordinate $key" } + require(value.isNotEmpty() && value.none(Char::isISOControl)) { + "Product coordinate $key is empty or contains a control character" + } + require(parsed.put(key, value) == null) { "Duplicate product coordinate $key" } + } + require(parsed.keys == requiredKeys.toSet()) { "Product coordinate keys do not match the required schema" } + parsed.forEach(::validateCoordinate) + val canonical = requiredKeys.joinToString(separator = "\n", postfix = "\n") { key -> "$key=${parsed.getValue(key)}" } + return ProductCoordinates(parsed.toMap(), canonical) + } + + private fun validateCoordinate( + key: String, + value: String, + ) { + val valid = + when (key) { + "schema" -> value == SCHEMA + "product.name", "vendor.name", "copyright.notice" -> value.length <= 160 + "product.slug", "ffi.cdylib_name", "database.qualifier", "database.organization", + "database.application", + -> value.isLowerIdentifier() + "kotlin.root_namespace", "desktop.application_id", "desktop.bundle_id", "desktop.main_class", + "ffi.kotlin_package", "keyring.service", + -> value.isDottedIdentifier() + "database.filename" -> + value.endsWith(".sqlite3") && ".." !in value && + value.all { it.isAsciiLetterOrDigit() || it in "._-" } + "environment.prefix" -> + value.firstOrNull() in 'A'..'Z' && value.endsWith('_') && + value.all { it in 'A'..'Z' || it.isDigit() || it == '_' } + else -> false + } + require(valid) { "Product coordinate $key has an invalid value" } + } + } +} + +private fun String.isLowerIdentifier(): Boolean = + firstOrNull() in 'a'..'z' && all { it in 'a'..'z' || it.isDigit() || it == '_' } + +private fun String.isDottedIdentifier(): Boolean = + split('.').all { segment -> + segment.firstOrNull()?.let { it.isAsciiLetter() || it == '_' } == true && + segment.all { it.isAsciiLetterOrDigit() || it == '_' } + } + +internal fun Char.isAsciiLetter(): Boolean = this in 'a'..'z' || this in 'A'..'Z' + +internal fun Char.isAsciiLetterOrDigit(): Boolean = isAsciiLetter() || isDigit() diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/SourceProvenance.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/SourceProvenance.kt @@ -0,0 +1,79 @@ +package org.harvestcircle.buildlogic.contracts + +public class SourceProvenance private constructor( + private val root: Map<String, String>, + private val imports: List<Map<String, String>>, +) { + public val foundationBaseline: String get() = root.getValue("foundation_baseline") + public val canonical: String + get() = + buildString { + rootKeys.forEach { key -> append(key).append('=').append(root.getValue(key)).append('\n') } + imports.forEach { entry -> + append("import.component=").append(entry.getValue("component")).append('\n') + append("import.commit=").append(entry.getValue("commit")).append('\n') + } + } + public val digest: String get() = canonical.sha256() + + public companion object { + private val rootKeys = + linkedSetOf( + "schema", + "source_product", + "source_repository", + "foundation_baseline", + "canonical_radroots_repository", + "canonical_radroots_revision", + ) + private val importKeys = linkedSetOf("component", "commit") + private val assignment = Regex("^([A-Za-z0-9_]+)\\s*=\\s*\"([^\"]*)\"\\s*(?:#.*)?$") + + public fun parse(source: String): SourceProvenance { + require(!source.startsWith('\uFEFF')) { "Source provenance must not contain a UTF-8 BOM" } + val root = linkedMapOf<String, String>() + val imports = mutableListOf<Map<String, String>>() + var currentImport: LinkedHashMap<String, String>? = null + fun completeImport() { + currentImport?.let { entry -> + require(entry.keys == importKeys) { "Source provenance import keys do not match the contract" } + imports += entry.toMap() + } + currentImport = null + } + source.lineSequence().forEachIndexed { index, raw -> + val line = raw.trim() + if (line.isEmpty() || line.startsWith('#')) return@forEachIndexed + if (line == "[[import]]") { + completeImport() + currentImport = linkedMapOf() + return@forEachIndexed + } + val match = requireNotNull(assignment.matchEntire(line)) { + "Source provenance line ${index + 1} is not a supported TOML string assignment" + } + val key = match.groupValues[1] + val value = match.groupValues[2] + require(value.isNotEmpty() && value.none(Char::isISOControl)) { + "Source provenance $key is empty or contains a control character" + } + val target = currentImport ?: root + val allowed = if (currentImport == null) rootKeys else importKeys + require(key in allowed) { "Unknown source provenance key $key" } + require(target.put(key, value) == null) { "Duplicate source provenance key $key" } + } + completeImport() + require(root.keys == rootKeys) { "Source provenance root keys do not match the contract" } + require(root.getValue("schema") == "harvestcircle.source_provenance.v1") + listOf("foundation_baseline", "canonical_radroots_revision").forEach { key -> + require(root.getValue(key).isCanonicalHex(40)) { "Source provenance $key is not canonical" } + } + require(imports.isNotEmpty()) { "Source provenance imports must not be empty" } + imports.forEach { require(it.getValue("commit").isCanonicalHex(40)) } + require(imports.map { it.getValue("component") }.distinct().size == imports.size) { + "Source provenance components must be unique" + } + return SourceProvenance(root.toMap(), imports.sortedBy { it.getValue("component") }) + } + } +} diff --git a/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt b/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt @@ -0,0 +1,160 @@ +package org.harvestcircle.buildlogic.contracts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BuildContractsTest { + @Test + fun productCoordinatesAreCanonicalAndMatchTheMigrationAdapterFixture() { + val coordinates = ProductCoordinates.parse(productCoordinates) + + assertEquals("HarvestCircle", coordinates["product.name"]) + assertEquals( + "93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223", + coordinates.digest, + ) + assertEquals(coordinates.digest, ProductCoordinates.parse(productCoordinates.replace("\n", "\r\n")).digest) + assertEquals(coordinates.digest, ProductCoordinates.parse("# comment\n$productCoordinates").digest) + assertFails { ProductCoordinates.parse("\uFEFF$productCoordinates") } + assertFails { ProductCoordinates.parse(productCoordinates + "schema=harvestcircle.product.v1\n") } + assertFails { ProductCoordinates.parse(productCoordinates + "unknown=value\n") } + assertFails { ProductCoordinates.parse(productCoordinates.replace("harvestcircle.sqlite3", "../other.sqlite3")) } + } + + @Test + fun ffiBaselineRejectsMalformedStaleAndUnknownValues() { + val baseline = FfiCompatibilityBaseline.parse(ffiBaseline) + + assertEquals("harvestcircle-desktop-ffi-v4", baseline["contract.id"]) + assertFails { FfiCompatibilityBaseline.parse("\uFEFF$ffiBaseline") } + assertFails { FfiCompatibilityBaseline.parse(ffiBaseline + "unknown=value\n") } + assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("contract.hash=${"a".repeat(64)}", "contract.hash=bad")) } + assertFails { FfiCompatibilityBaseline.parse(ffiBaseline.replace("contract.minor=1", "contract.minor=2")) } + } + + @Test + fun provenanceCanonicalizesImportOrderAndRejectsMalformedSources() { + val canonical = SourceProvenance.parse(sourceProvenance) + val reordered = + sourceProvenance.replace( + "component = \"domain\"\ncommit = \"${"b".repeat(40)}\"", + "commit = \"${"b".repeat(40)}\"\ncomponent = \"domain\"", + ) + + assertEquals(canonical.digest, SourceProvenance.parse(sourceProvenance.replace("\n", "\r\n")).digest) + assertEquals(canonical.digest, SourceProvenance.parse(reordered).digest) + assertEquals("a".repeat(40), canonical.foundationBaseline) + assertFails { SourceProvenance.parse("\uFEFF$sourceProvenance") } + assertFails { SourceProvenance.parse("unknown = \"value\"\n$sourceProvenance") } + assertFails { SourceProvenance.parse(sourceProvenance.replace("b".repeat(40), "BAD")) } + } + + @Test + fun nativeTargetMatrixIsExplicitAndFailsClosed() { + val expected = + mapOf( + ("macOS" to "arm64") to NativeTarget("libharvestcircle_ffi.dylib", "darwin-aarch64"), + ("Mac OS X" to "amd64") to NativeTarget("libharvestcircle_ffi.dylib", "darwin-x86-64"), + ("Windows 11" to "aarch64") to NativeTarget("harvestcircle_ffi.dll", "win32-aarch64"), + ("Windows" to "x86_64") to NativeTarget("harvestcircle_ffi.dll", "win32-x86-64"), + ("Linux" to "arm64") to NativeTarget("libharvestcircle_ffi.so", "linux-aarch64"), + ("linux" to "amd64") to NativeTarget("libharvestcircle_ffi.so", "linux-x86-64"), + ) + + expected.forEach { (host, target) -> + assertEquals(target, resolveNativeTarget(host.first, host.second, "harvestcircle_ffi")) + } + assertFails { resolveNativeTarget("Solaris", "sparc", "harvestcircle_ffi") } + assertFails { resolveNativeTarget("Linux", "riscv64", "harvestcircle_ffi") } + } + + @Test + fun generatedKotlinIsDeterministicEscapedAndFreshnessChecked() { + val metadata = + GeneratedKotlin.desktopBuildMetadata( + DesktopBuildMetadataValues( + productVersion = "0.1.0-alpha", + distributionPackageVersion = "1.0.0", + gradleToolchain = "9.5.0", + javaToolchain = "21", + kotlinToolchain = "2.4.10\"test", + composeMultiplatformVersion = "1.11.1", + ), + ) + val compatibility = GeneratedKotlin.compatibilityExpectations(FfiCompatibilityBaseline.parse(ffiBaseline)) + + assertTrue(metadata.startsWith("// @generated by the HarvestCircle Gradle build. Do not edit.\n")) + assertTrue(metadata.contains("2.4.10\\\"test")) + assertTrue(metadata.endsWith("\n")) + assertTrue(compatibility.contains("const val ffiContractHash = \"${"a".repeat(64)}\"")) + GeneratedKotlin.requireFresh(metadata, metadata, "metadata") + assertFails { GeneratedKotlin.requireFresh(null, metadata, "metadata") } + assertFails { GeneratedKotlin.requireFresh("$metadata// stale", metadata, "metadata") } + } + + @Test + fun contractsHaveNoGradleApiOnTheirRuntimeClasspath() { + assertFalse(runCatching { Class.forName("org.gradle.api.Project") }.isSuccess) + } + + private val productCoordinates = + """ + schema=harvestcircle.product.v1 + + product.name=HarvestCircle + product.slug=harvestcircle + + kotlin.root_namespace=org.harvestcircle + desktop.application_id=org.harvestcircle.desktop + desktop.bundle_id=org.harvestcircle.desktop + desktop.main_class=org.harvestcircle.desktop.MainKt + + ffi.kotlin_package=org.harvestcircle.ffi + ffi.cdylib_name=harvestcircle_ffi + + database.qualifier=org + database.organization=harvestcircle + database.application=desktop + database.filename=harvestcircle.sqlite3 + + keyring.service=org.harvestcircle.desktop.nostr + environment.prefix=HARVESTCIRCLE_ + + vendor.name=Radroots Labs + copyright.notice=Copyright © 2026 HarvestCircle contributors + """.trimIndent() + "\n" + + private val ffiBaseline = + """ + schema=harvestcircle.ffi.v4 + contract.id=harvestcircle-desktop-ffi-v4 + contract.major=4 + contract.minor=1 + contract.hash=${"a".repeat(64)} + product.coordinate_digest=${"b".repeat(64)} + snapshot.schema=1 + storage.schema.minimum=5 + storage.schema.current=10 + product.version=0.1.0-alpha + package.version=1.0.0 + source.provenance_digest=${"c".repeat(64)} + source.foundation_baseline=${"d".repeat(40)} + """.trimIndent() + "\n" + + private val sourceProvenance = + """ + schema = "harvestcircle.source_provenance.v1" + source_product = "HarvestCircle" + source_repository = "https://example.invalid/harvestcircle" + foundation_baseline = "${"a".repeat(40)}" + canonical_radroots_repository = "https://example.invalid/radroots" + canonical_radroots_revision = "${"c".repeat(40)}" + + [[import]] + component = "domain" + commit = "${"b".repeat(40)}" + """.trimIndent() + "\n" +} diff --git a/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildLogicContractTest.kt b/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildLogicContractTest.kt @@ -1,11 +0,0 @@ -package org.harvestcircle.buildlogic.contracts - -import kotlin.test.Test -import kotlin.test.assertNotNull - -class BuildLogicContractTest { - @Test - fun contractModuleLoadsWithoutGradleApi() { - assertNotNull(BuildLogicContract) - } -}