app

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

commit f00a0c900f960d0df4013d5794e7715b7c560af5
parent 6405b6a32ac418c00d75f7d9808ef4bb10aeaa57
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 17:10:30 +0000

product: make the coordinate manifest authoritative

- retain only product schema keys and semantic constraints in parsers
- generate Rust constants from the checked-in coordinate manifest
- derive Gradle packaging and environment identities from parsed values
- verify valid mutations propagate without introducing alternate authorities

Diffstat:
Mapp/desktop/build.gradle.kts | 59+++++++++++++++++++++++++++++++++++------------------------
Mbuild.gradle.kts | 13+++++++++++++
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt | 12++++++++++--
AbuildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinateConsumers.kt | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/VerificationLanes.kt | 42++++++++++++++++++++++++++++++++----------
Mcore/crates/harvestcircle_product/build.rs | 34+++-------------------------------
Mcore/crates/harvestcircle_product/src/lib.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcore/crates/harvestcircle_product/src/parser.rs | 156++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
9 files changed, 462 insertions(+), 147 deletions(-)

diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -131,11 +131,15 @@ check(Regex("""[1-9]\d*(\.\d+){0,2}""").matches(installableVersion)) { "Package version must satisfy the macOS jpackage contract" } val applicationName = productCoordinates["product.name"] +val productSlug = productCoordinates["product.slug"] val bundleId = productCoordinates["desktop.bundle_id"] val desktopMainClass = productCoordinates["desktop.main_class"] val ffiKotlinPackage = productCoordinates["ffi.kotlin_package"] +val environmentPrefix = productCoordinates["environment.prefix"] + +fun productEnvironment(suffix: String) = environmentPrefix + suffix val developmentDataDirectoryEnvironment = - productCoordinates["environment.prefix"] + "DEVELOPMENT_DATA_DIR" + productEnvironment("DEVELOPMENT_DATA_DIR") val copyrightNotice = productCoordinates["copyright.notice"] val vendorName = productCoordinates["vendor.name"] group = productCoordinates["desktop.application_id"] @@ -197,22 +201,28 @@ val rustLibraryName = nativeTarget.libraryName val rustDebugLibrary = file(cargoTargetRoot).resolve("debug/$rustLibraryName") val rustReleaseLibrary = file(cargoTargetRoot).resolve("release/$rustLibraryName") val jnaPlatformPrefix = nativeTarget.jnaPrefix -val buildSourceCommit = providers.environmentVariable("HARVESTCIRCLE_BUILD_SOURCE_COMMIT").orElse("unknown") -val buildSourceDirty = providers.environmentVariable("HARVESTCIRCLE_BUILD_SOURCE_DIRTY").orElse("unknown") -val buildRadrootsRevision = providers.environmentVariable("HARVESTCIRCLE_BUILD_RADROOTS_REVISION").orElse("unknown") -val buildRustToolchain = providers.environmentVariable("HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN").orElse("1.97.1") -val buildJavaToolchain = providers.environmentVariable("HARVESTCIRCLE_BUILD_JAVA_TOOLCHAIN").orElse(System.getProperty("java.version")) -val buildKotlinToolchain = providers.environmentVariable("HARVESTCIRCLE_BUILD_KOTLIN_TOOLCHAIN").orElse(libs.versions.kotlin.get()) +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( - "HARVESTCIRCLE_BUILD_SOURCE_COMMIT=${buildSourceCommit.get()}", - "HARVESTCIRCLE_BUILD_SOURCE_DIRTY=${buildSourceDirty.get()}", - "HARVESTCIRCLE_BUILD_RADROOTS_REVISION=${buildRadrootsRevision.get()}", - "HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN=${buildRustToolchain.get()}", - "HARVESTCIRCLE_BUILD_JAVA_TOOLCHAIN=${buildJavaToolchain.get()}", - "HARVESTCIRCLE_BUILD_KOTLIN_TOOLCHAIN=${buildKotlinToolchain.get()}", + "$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 @@ -223,12 +233,12 @@ val buildProvenanceDigest = } fun Exec.injectBuildProvenance() { - environment("HARVESTCIRCLE_BUILD_SOURCE_COMMIT", buildSourceCommit.get()) - environment("HARVESTCIRCLE_BUILD_SOURCE_DIRTY", buildSourceDirty.get()) - environment("HARVESTCIRCLE_BUILD_RADROOTS_REVISION", buildRadrootsRevision.get()) - environment("HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN", buildRustToolchain.get()) - environment("HARVESTCIRCLE_BUILD_JAVA_TOOLCHAIN", buildJavaToolchain.get()) - environment("HARVESTCIRCLE_BUILD_KOTLIN_TOOLCHAIN", buildKotlinToolchain.get()) + 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) @@ -718,6 +728,7 @@ tasks.withType<Test>().configureEach { tasks.named("check") { dependsOn(rootProject.tasks.named("verifyProductCoordinates")) + dependsOn(rootProject.tasks.named("verifyProductCoordinateConsumers")) } kotlin { @@ -749,7 +760,7 @@ tasks.withType<Test>().configureEach { developmentDataDirectoryEnvironment, nativeTestData, ) - systemProperty("harvestcircle.development.data.dir", nativeTestData) + systemProperty("$productSlug.development.data.dir", nativeTestData) systemProperty( "jna.library.path", rustDebugLibrary.parentFile.absolutePath, @@ -757,7 +768,7 @@ tasks.withType<Test>().configureEach { } tasks.withType<JavaExec>().configureEach { dependsOn(buildRustCoreDebug) - systemProperty("harvestcircle.development", "true") + systemProperty("$productSlug.development", "true") systemProperty( "jna.library.path", rustDebugLibrary.parentFile.absolutePath, @@ -794,13 +805,13 @@ compose.desktop { packageName = applicationName packageVersion = installableVersion - description = "HarvestCircle $appVersion" + description = "$applicationName $appVersion" copyright = copyrightNotice vendor = vendorName macOS { bundleID = bundleId - iconFile.set(project.file("src/main/resources/icons/harvestcircle.icns")) + iconFile.set(project.file("src/main/resources/icons/$productSlug.icns")) packageName = applicationName dockName = applicationName packageBuildVersion = macOsBuildVersion @@ -813,7 +824,7 @@ val verifyMacOsDistribution by tasks.registering(VerifyMacOsDistribution::class) dependsOn("createDistributable") appDirectory.set(layout.buildDirectory.dir("compose/binaries/main/app/$applicationName.app")) releaseLibrary.set(rustReleaseLibrary) - iconSource.set(layout.projectDirectory.file("src/main/resources/icons/harvestcircle.icns")) + iconSource.set(layout.projectDirectory.file("src/main/resources/icons/$productSlug.icns")) expectedBundleId.set(bundleId) expectedPackageVersion.set(installableVersion) expectedBuildVersion.set(macOsBuildVersion) diff --git a/build.gradle.kts b/build.gradle.kts @@ -1,4 +1,5 @@ import org.harvestcircle.gradle.VerifyFoundationBoundaries +import org.harvestcircle.gradle.VerifyProductCoordinateConsumers import org.harvestcircle.gradle.VerifyProductCoordinates import org.harvestcircle.gradle.VerifyVerificationLanes @@ -39,10 +40,22 @@ val verifyCompatibilityBaseline by tasks.registering { dependsOn(verifyProductCoordinates) } +val verifyProductCoordinateConsumers by tasks.registering(VerifyProductCoordinateConsumers::class) { + group = "verification" + description = "Validates that build and runtime identities consume the product manifest." + manifestFile.set(productCoordinatesFile) + desktopBuildFile.set(layout.projectDirectory.file("app/desktop/build.gradle.kts")) + uniFfiConfigFile.set(layout.projectDirectory.file("core/crates/harvestcircle_ffi/uniffi.toml")) + productBuildFile.set(layout.projectDirectory.file("core/crates/harvestcircle_product/build.rs")) + ffiConsumerFile.set(layout.projectDirectory.file("core/crates/harvestcircle_ffi/src/commands.rs")) + keyringConsumerFile.set(layout.projectDirectory.file("core/crates/harvestcircle_storage/src/os_keyring.rs")) +} + val verifyVerificationLanes by tasks.registering(VerifyVerificationLanes::class) { group = "verification" description = "Validates forge-agnostic verification lanes and least-privilege policy." policyFile.set(verificationLanesFile) + productManifestFile.set(productCoordinatesFile) repositoryRoot.set(layout.projectDirectory) } diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt @@ -270,10 +270,18 @@ private class FoundationBoundaryAudit( ) { findings += "$provenancePath: exact source provenance changed" } + val productCoordinates = + runCatching { + ProductCoordinates.parse(text("config/product/harvestcircle-v1.properties")) + }.getOrElse { error -> + findings += "config/product/harvestcircle-v1.properties: ${error.message}" + null + } val uniFfi = text("core/crates/harvestcircle_ffi/uniffi.toml") if (!uniFfi.contains("[crates.harvestcircle_ffi.bindings.kotlin]") || - !uniFfi.contains("package_name = \"org.harvestcircle.ffi\"") || - !uniFfi.contains("cdylib_name = \"harvestcircle_ffi\"") + productCoordinates == null || + !uniFfi.contains("package_name = \"${productCoordinates["ffi.kotlin_package"]}\"") || + !uniFfi.contains("cdylib_name = \"${productCoordinates["ffi.cdylib_name"]}\"") ) { findings += "core/crates/harvestcircle_ffi/uniffi.toml: final FFI identity changed" } diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinateConsumers.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinateConsumers.kt @@ -0,0 +1,84 @@ +package org.harvestcircle.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +abstract class VerifyProductCoordinateConsumers : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val manifestFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val desktopBuildFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val uniFfiConfigFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val productBuildFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val ffiConsumerFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val keyringConsumerFile: RegularFileProperty + + @TaskAction + fun verify() { + val coordinates = ProductCoordinates.load(manifestFile.get().asFile) + val desktopBuild = desktopBuildFile.get().asFile.readText() + listOf( + "product.name", + "product.slug", + "desktop.application_id", + "desktop.bundle_id", + "desktop.main_class", + "ffi.kotlin_package", + "ffi.cdylib_name", + "environment.prefix", + "vendor.name", + "copyright.notice", + ).forEach { key -> + check(desktopBuild.contains("productCoordinates[\"$key\"]")) { + "Desktop build does not consume product coordinate $key" + } + } + listOf( + "desktop.application_id", + "desktop.bundle_id", + "desktop.main_class", + "database.filename", + "keyring.service", + "environment.prefix", + ).forEach { key -> + check(!desktopBuild.contains("\"${coordinates[key]}\"")) { + "Desktop build duplicates the approved value for $key" + } + } + + val uniFfi = uniFfiConfigFile.get().asFile.readText() + check(uniFfi.contains("package_name = \"${coordinates["ffi.kotlin_package"]}\"")) + check(uniFfi.contains("cdylib_name = \"${coordinates["ffi.cdylib_name"]}\"")) + + val productBuild = productBuildFile.get().asFile.readText() + check(productBuild.contains("generate_rust_constants(&source)")) + val ffiConsumer = ffiConsumerFile.get().asFile.readText() + listOf( + "DATABASE_APPLICATION", + "DATABASE_FILENAME", + "DATABASE_ORGANIZATION", + "DATABASE_QUALIFIER", + "DEVELOPMENT_DATA_DIR_ENVIRONMENT", + ).forEach { constant -> check(ffiConsumer.contains(constant)) } + check(keyringConsumerFile.get().asFile.readText().contains("harvestcircle_product::KEYRING_SERVICE")) + } +} diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt @@ -16,25 +16,26 @@ class ProductCoordinates private constructor( operator fun get(key: String): String = values.getValue(key) companion object { - val required: Map<String, String> = - linkedMapOf( - "schema" to "harvestcircle.product.v1", - "product.name" to "HarvestCircle", - "product.slug" to "harvestcircle", - "kotlin.root_namespace" to "org.harvestcircle", - "desktop.application_id" to "org.harvestcircle.desktop", - "desktop.bundle_id" to "org.harvestcircle.desktop", - "desktop.main_class" to "org.harvestcircle.desktop.MainKt", - "ffi.kotlin_package" to "org.harvestcircle.ffi", - "ffi.cdylib_name" to "harvestcircle_ffi", - "database.qualifier" to "org", - "database.organization" to "harvestcircle", - "database.application" to "desktop", - "database.filename" to "harvestcircle.sqlite3", - "keyring.service" to "org.harvestcircle.desktop.nostr", - "environment.prefix" to "HARVESTCIRCLE_", - "vendor.name" to "Radroots Labs", - "copyright.notice" to "Copyright © 2026 HarvestCircle contributors", + const val schema = "harvestcircle.product.v1" + 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", ) fun load(file: File): ProductCoordinates = parse(file.readText()) @@ -49,23 +50,19 @@ class ProductCoordinates private constructor( 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 required) { "Unknown product coordinate $key" } + require(key in requiredKeys) { "Unknown product coordinate $key" } require(key.isNotEmpty() && key.none(Char::isISOControl) && 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 == required.keys) { + require(parsed.keys == requiredKeys.toSet()) { "Product coordinate keys do not match the required schema" } - required.forEach { (key, expected) -> - require(parsed.getValue(key) == expected) { - "Product coordinate $key does not match the approved value" - } - } + parsed.forEach(::validateCoordinate) val canonical = buildString { - required.keys.forEach { key -> + requiredKeys.forEach { key -> append(key).append('=').append(parsed.getValue(key)).append('\n') } } @@ -76,6 +73,54 @@ class ProductCoordinates private constructor( .joinToString("") { byte -> "%02x".format(byte) } return ProductCoordinates(parsed.toMap(), digest) } + + 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()?.let { it in 'A'..'Z' } == true && + 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()?.let { it in 'a'..'z' } == true && + 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 == '_' } + } + + private fun Char.isAsciiLetter(): Boolean = this in 'a'..'z' || this in 'A'..'Z' + + private fun Char.isAsciiLetterOrDigit(): Boolean = isAsciiLetter() || isDigit() } } @@ -104,8 +149,6 @@ abstract class VerifyProductCoordinates : DefaultTask() { fun verify() { val source = manifestFile.get().asFile.readText() val coordinates = ProductCoordinates.parse(source) - check(coordinates["product.name"] == "HarvestCircle") - check(coordinates["desktop.application_id"] == "org.harvestcircle.desktop") check(coordinates.digest.matches(Regex("[0-9a-f]{64}"))) val equivalentSources = listOf( @@ -120,16 +163,20 @@ abstract class VerifyProductCoordinates : DefaultTask() { check(ProductCoordinates.parse(equivalent).digest == coordinates.digest) } check(runCatching { ProductCoordinates.parse("\uFEFF$source") }.isFailure) - check(runCatching { ProductCoordinates.parse(source + "\nschema=harvestcircle.product.v1") }.isFailure) + check(runCatching { ProductCoordinates.parse(source + "\nschema=${ProductCoordinates.schema}") }.isFailure) check(runCatching { ProductCoordinates.parse(source + "\nunknown=value") }.isFailure) check(runCatching { ProductCoordinates.parse(source.substringAfter('\n')) }.isFailure) + check(runCatching { ProductCoordinates.parse(source.replaceCoordinate("product.slug", "INVALID")) }.isFailure) check( runCatching { - ProductCoordinates.parse( - source.replace("product.slug=harvestcircle", "product.slug=other"), - ) + ProductCoordinates.parse(source.replaceCoordinate("database.filename", "../other.sqlite3")) }.isFailure, ) + validCoordinateMutations.forEach { (key, replacement) -> + val mutated = ProductCoordinates.parse(source.replaceCoordinate(key, replacement)) + check(mutated[key] == replacement) + check(mutated.digest != coordinates.digest) + } val uniFfiConfig = uniFfiConfigFile.get().asFile.readText() check( @@ -193,4 +240,34 @@ abstract class VerifyProductCoordinates : DefaultTask() { check(nativeCompatibility.contains("$constant = \"${baseline[key]}\"")) } } + + private fun String.replaceCoordinate( + key: String, + replacement: String, + ): String = + lineSequence().joinToString("\n") { line -> + if (line.substringBefore('=', missingDelimiterValue = "") == key) "$key=$replacement" else line + } + + private val validCoordinateMutations = + linkedMapOf( + "product.name" to "Harvest Circle Test", + "product.slug" to "harvestcircle_test", + "kotlin.root_namespace" to "org.example", + "desktop.application_id" to "org.example.desktop", + "desktop.bundle_id" to "org.example.bundle", + "desktop.main_class" to "org.example.MainKt", + "ffi.kotlin_package" to "org.example.ffi", + "ffi.cdylib_name" to "example_ffi", + "database.qualifier" to "com", + "database.organization" to "example", + "database.application" to "test", + "database.filename" to "example.sqlite3", + "keyring.service" to "org.example.desktop.nostr", + "environment.prefix" to "EXAMPLE_", + "vendor.name" to "Example Cooperative", + "copyright.notice" to "Copyright Example contributors", + ).also { mutations -> + check(mutations.size + 1 == ProductCoordinates.requiredKeys.size) + } } diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/VerificationLanes.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/VerificationLanes.kt @@ -10,7 +10,7 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction object VerificationLanes { - private val expected = + private fun expected(environmentPrefix: String) = linkedMapOf( "schema" to "harvestcircle.verification-lanes.v1", "orchestration" to "github-actions", @@ -24,9 +24,9 @@ object VerificationLanes { "package.workflow" to ".github/workflows/package.yml", "package.permissions" to "contents:read", "package.credentials" to "none", - "provenance.commit" to "HARVESTCIRCLE_BUILD_SOURCE_COMMIT", - "provenance.dirty" to "HARVESTCIRCLE_BUILD_SOURCE_DIRTY", - "provenance.radroots" to "HARVESTCIRCLE_BUILD_RADROOTS_REVISION", + "provenance.commit" to environmentPrefix + "BUILD_SOURCE_COMMIT", + "provenance.dirty" to environmentPrefix + "BUILD_SOURCE_DIRTY", + "provenance.radroots" to environmentPrefix + "BUILD_RADROOTS_REVISION", "provenance.epoch" to "SOURCE_DATE_EPOCH", "signing.command" to "make signing-check", "signing.runner" to "macos", @@ -38,7 +38,11 @@ object VerificationLanes { "notarization.credentials" to "notarization", ) - fun parse(source: String): Map<String, String> { + fun parse( + source: String, + environmentPrefix: String, + ): Map<String, String> { + val expected = expected(environmentPrefix) val parsed = linkedMapOf<String, String>() source.trimEnd('\n', '\r').lineSequence().forEachIndexed { index, raw -> val line = raw.trim() @@ -64,18 +68,36 @@ abstract class VerifyVerificationLanes : DefaultTask() { @get:PathSensitive(PathSensitivity.RELATIVE) abstract val policyFile: RegularFileProperty + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val productManifestFile: RegularFileProperty + @get:Internal abstract val repositoryRoot: DirectoryProperty @TaskAction fun verify() { val source = policyFile.get().asFile.readText() - val policy = VerificationLanes.parse(source) + val environmentPrefix = + ProductCoordinates.load(productManifestFile.get().asFile)["environment.prefix"] + val policy = VerificationLanes.parse(source, environmentPrefix) check(policy.size == 24) - check(runCatching { VerificationLanes.parse(source + "source.permissions=write") }.isFailure) - check(runCatching { VerificationLanes.parse(source.replace("contents:read", "contents:write")) }.isFailure) - check(runCatching { VerificationLanes.parse(source.replace("credentials=none", "credentials=all")) }.isFailure) - check(runCatching { VerificationLanes.parse(source.replace("source.runner=linux", "source.runner=macos")) }.isFailure) + check(runCatching { VerificationLanes.parse(source + "source.permissions=write", environmentPrefix) }.isFailure) + check( + runCatching { + VerificationLanes.parse(source.replace("contents:read", "contents:write"), environmentPrefix) + }.isFailure, + ) + check( + runCatching { + VerificationLanes.parse(source.replace("credentials=none", "credentials=all"), environmentPrefix) + }.isFailure, + ) + check( + runCatching { + VerificationLanes.parse(source.replace("source.runner=linux", "source.runner=macos"), environmentPrefix) + }.isFailure, + ) val root = repositoryRoot.get().asFile.toPath() val sourceWorkflow = root.resolve(policy.getValue("source.workflow")).toFile().readText() val packageWorkflow = root.resolve(policy.getValue("package.workflow")).toFile().readText() diff --git a/core/crates/harvestcircle_product/build.rs b/core/crates/harvestcircle_product/build.rs @@ -1,45 +1,17 @@ -use std::fmt::Write as _; use std::fs; use std::path::PathBuf; #[path = "src/parser.rs"] mod parser; -use parser::{REQUIRED, digest, parse}; +use parser::generate_rust_constants; const MANIFEST_PATH: &str = "../../../config/product/harvestcircle-v1.properties"; fn main() { println!("cargo:rerun-if-changed={MANIFEST_PATH}"); let source = fs::read_to_string(MANIFEST_PATH).expect("read HarvestCircle product manifest"); - let coordinates = parse(&source).expect("validate HarvestCircle product manifest"); - let digest = digest(&source).expect("canonicalize HarvestCircle product manifest"); - - let mut generated = String::from("// @generated by harvestcircle_product/build.rs\n"); - for (key, _) in REQUIRED { - let constant = key.replace(['.', '-'], "_").to_ascii_uppercase(); - writeln!( - generated, - "pub const {constant}: &str = {:?};", - coordinates.get(*key).expect("required coordinate") - ) - .expect("write coordinate constant"); - } - writeln!( - generated, - "pub const PRODUCT_COORDINATE_DIGEST: &str = {digest:?};" - ) - .expect("write coordinate digest"); - writeln!( - generated, - "pub const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = {:?};", - format!( - "{}DEVELOPMENT_DATA_DIR", - coordinates - .get("environment.prefix") - .expect("environment prefix") - ) - ) - .expect("write derived development environment coordinate"); + let generated = + generate_rust_constants(&source).expect("validate HarvestCircle product manifest"); fs::write(out_file("product_coordinates.rs"), generated) .expect("write generated product coordinates"); diff --git a/core/crates/harvestcircle_product/src/lib.rs b/core/crates/harvestcircle_product/src/lib.rs @@ -7,7 +7,7 @@ include!(concat!(env!("OUT_DIR"), "/product_coordinates.rs")); #[cfg(test)] mod tests { - use super::parser::{REQUIRED, canonicalize, digest, parse}; + use super::parser::{REQUIRED_KEYS, canonicalize, digest, generate_rust_constants, parse}; use super::provenance; use super::{ DESKTOP_APPLICATION_ID, DEVELOPMENT_DATA_DIR_ENVIRONMENT, FFI_CDYLIB_NAME, @@ -34,29 +34,67 @@ mod tests { #[test] fn parser_rejects_missing_duplicate_unknown_and_changed_coordinates() { - let source = REQUIRED - .iter() - .map(|(key, value)| format!("{key}={value}")) - .collect::<Vec<_>>() - .join("\n"); - assert!(parse(&source).is_ok()); + let source = include_str!("../../../../config/product/harvestcircle-v1.properties"); + assert!(parse(source).is_ok()); assert!(parse(&source.replacen("schema=harvestcircle.product.v1\n", "", 1)).is_err()); assert!(parse(&format!("{source}\nschema=harvestcircle.product.v1")).is_err()); assert!(parse(&format!("{source}\nunknown=value")).is_err()); assert!( - parse(&source.replace("product.slug=harvestcircle", "product.slug=other")).is_err() + parse(&source.replace("product.slug=harvestcircle", "product.slug=OTHER")).is_err() + ); + assert!( + parse(&source.replace( + "database.filename=harvestcircle.sqlite3", + "database.filename=../other.sqlite3" + )) + .is_err() ); assert!(parse(&format!("\u{feff}{source}")).is_err()); } #[test] + fn every_coordinate_value_is_manifest_owned_and_generated() { + let source = include_str!("../../../../config/product/harvestcircle-v1.properties"); + let mutations = [ + ("product.name", "Harvest Circle Test"), + ("product.slug", "harvestcircle_test"), + ("kotlin.root_namespace", "org.example"), + ("desktop.application_id", "org.example.desktop"), + ("desktop.bundle_id", "org.example.bundle"), + ("desktop.main_class", "org.example.MainKt"), + ("ffi.kotlin_package", "org.example.ffi"), + ("ffi.cdylib_name", "example_ffi"), + ("database.qualifier", "com"), + ("database.organization", "example"), + ("database.application", "test"), + ("database.filename", "example.sqlite3"), + ("keyring.service", "org.example.desktop.nostr"), + ("environment.prefix", "EXAMPLE_"), + ("vendor.name", "Example Cooperative"), + ("copyright.notice", "Copyright Example contributors"), + ]; + assert_eq!(mutations.len() + 1, REQUIRED_KEYS.len()); + for (key, replacement) in mutations { + let original = parse(source).unwrap().remove(key).unwrap(); + let mutated = source.replace( + &format!("{key}={original}"), + &format!("{key}={replacement}"), + ); + let parsed = parse(&mutated).expect("valid manifest-owned coordinate mutation"); + assert_eq!(parsed.get(key).map(String::as_str), Some(replacement)); + assert_ne!(digest(&mutated).unwrap(), digest(source).unwrap()); + assert!( + generate_rust_constants(&mutated) + .unwrap() + .contains(&format!("= {replacement:?};")) + ); + } + } + + #[test] fn product_digest_is_semantic_and_line_ending_independent() { - let source = REQUIRED - .iter() - .map(|(key, value)| format!("{key}={value}")) - .collect::<Vec<_>>() - .join("\n"); - let expected = digest(&source).expect("canonical product digest"); + let source = include_str!("../../../../config/product/harvestcircle-v1.properties"); + let expected = digest(source).expect("canonical product digest"); assert_eq!( expected, "93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223" diff --git a/core/crates/harvestcircle_product/src/parser.rs b/core/crates/harvestcircle_product/src/parser.rs @@ -2,27 +2,25 @@ use std::collections::BTreeMap; use sha2::{Digest, Sha256}; -pub const REQUIRED: &[(&str, &str)] = &[ - ("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", - ), +pub const SCHEMA: &str = "harvestcircle.product.v1"; +pub const REQUIRED_KEYS: &[&str] = &[ + "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", ]; pub fn parse(source: &str) -> Result<BTreeMap<String, String>, String> { @@ -47,23 +45,18 @@ pub fn parse(source: &str) -> Result<BTreeMap<String, String>, String> { { return Err(format!("line {} has an invalid key or value", index + 1)); } - if !REQUIRED.iter().any(|(required, _)| *required == key) { + if !REQUIRED_KEYS.contains(&key) { return Err(format!("unknown coordinate {key}")); } if parsed.insert(key.to_owned(), value.to_owned()).is_some() { return Err(format!("duplicate coordinate {key}")); } } - for (key, expected) in REQUIRED { - match parsed.get(*key) { - Some(actual) if actual == expected => {} - Some(_) => { - return Err(format!( - "coordinate {key} does not match the approved value" - )); - } - None => return Err(format!("missing coordinate {key}")), - } + for key in REQUIRED_KEYS { + let value = parsed + .get(*key) + .ok_or_else(|| format!("missing coordinate {key}"))?; + validate_coordinate(key, value)?; } Ok(parsed) } @@ -71,7 +64,7 @@ pub fn parse(source: &str) -> Result<BTreeMap<String, String>, String> { pub fn canonicalize(source: &str) -> Result<String, String> { let parsed = parse(source)?; let mut canonical = String::new(); - for (key, _) in REQUIRED { + for key in REQUIRED_KEYS { canonical.push_str(key); canonical.push('='); canonical.push_str(parsed.get(*key).expect("required coordinate was validated")); @@ -80,6 +73,103 @@ pub fn canonicalize(source: &str) -> Result<String, String> { Ok(canonical) } +pub fn generate_rust_constants(source: &str) -> Result<String, String> { + use std::fmt::Write as _; + + let coordinates = parse(source)?; + let digest = digest(source)?; + let mut generated = String::from("// @generated by harvestcircle_product/build.rs\n"); + for key in REQUIRED_KEYS { + let constant = key.replace(['.', '-'], "_").to_ascii_uppercase(); + writeln!( + generated, + "pub const {constant}: &str = {:?};", + coordinates.get(*key).expect("required coordinate") + ) + .map_err(|error| error.to_string())?; + } + writeln!( + generated, + "pub const PRODUCT_COORDINATE_DIGEST: &str = {digest:?};" + ) + .map_err(|error| error.to_string())?; + writeln!( + generated, + "pub const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = {:?};", + format!( + "{}DEVELOPMENT_DATA_DIR", + coordinates + .get("environment.prefix") + .expect("environment prefix") + ) + ) + .map_err(|error| error.to_string())?; + Ok(generated) +} + +fn validate_coordinate(key: &str, value: &str) -> Result<(), String> { + let valid = match key { + "schema" => value == SCHEMA, + "product.name" | "vendor.name" | "copyright.notice" => value.len() <= 160, + "product.slug" + | "ffi.cdylib_name" + | "database.qualifier" + | "database.organization" + | "database.application" => is_lower_identifier(value), + "kotlin.root_namespace" + | "desktop.application_id" + | "desktop.bundle_id" + | "desktop.main_class" + | "ffi.kotlin_package" + | "keyring.service" => is_dotted_identifier(value), + "database.filename" => { + value.ends_with(".sqlite3") + && !value.contains("..") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + } + "environment.prefix" => { + value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_uppercase()) + && value.ends_with('_') + && value + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_') + } + _ => false, + }; + if valid { + Ok(()) + } else { + Err(format!("coordinate {key} has an invalid value")) + } +} + +fn is_lower_identifier(value: &str) -> bool { + value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn is_dotted_identifier(value: &str) -> bool { + value.split('.').all(|segment| { + segment + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }) +} + pub fn digest(source: &str) -> Result<String, String> { let canonical = canonicalize(source)?; Ok(Sha256::digest(canonical.as_bytes())