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:
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())