app

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

commit 1b3f190e3c463fb7eecf46aaf93727057f1ab057
parent 73a10af2e54ce2459c8824913adb7cf68b410d10
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 03:38:57 +0000

release: add passive build provenance

- embed host-supplied source, parent, toolchain, epoch, FFI, and schema evidence
- expose a platform-neutral safe build information contract through UniFFI
- reject unknown or dirty release inputs and verify the native provenance digest
- cover deterministic diagnostics, bindings, standalone checks, build, and host package

Diffstat:
Mapp/desktop/build.gradle.kts | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt | 2+-
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt | 3+++
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt | 23+++++++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt | 2++
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/application/BuildInfo.kt | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeContracts.kt | 2++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt | 2++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt | 26++++++++++++++++++++++++++
Mcore/compatibility/harvestcircle-ffi-v4.properties | 2+-
Mcore/crates/harvestcircle_ffi/build.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/harvestcircle_ffi/src/commands.rs | 47+++++++++++++++++++++++++++++++++++++++++++++--
Mcore/crates/harvestcircle_ffi/src/contract.rs | 8++++++++
Mcore/crates/harvestcircle_ffi/src/lib.rs | 23+++++++++++++++++++++--
14 files changed, 374 insertions(+), 7 deletions(-)

diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -4,6 +4,7 @@ import org.gradle.api.GradleException import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Delete import org.gradle.api.tasks.Exec @@ -22,6 +23,7 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.File +import java.security.MessageDigest import java.util.jar.JarFile plugins { @@ -195,8 +197,50 @@ 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 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()}", + "SOURCE_DATE_EPOCH=${buildSourceDateEpoch.get()}", + ).joinToString("\n").let { input -> + MessageDigest + .getInstance("SHA-256") + .digest(input.toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte) } + } + } + +fun Exec.injectBuildProvenance() { + environment("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("SOURCE_DATE_EPOCH", buildSourceDateEpoch.get()) + inputs.property("buildSourceCommit", buildSourceCommit) + inputs.property("buildSourceDirty", buildSourceDirty) + inputs.property("buildRadrootsRevision", buildRadrootsRevision) + inputs.property("buildRustToolchain", buildRustToolchain) + inputs.property("buildJavaToolchain", buildJavaToolchain) + inputs.property("buildKotlinToolchain", buildKotlinToolchain) + inputs.property("buildSourceDateEpoch", buildSourceDateEpoch) +} val buildRustCoreDebug by tasks.registering(Exec::class) { + injectBuildProvenance() workingDir(rustCoreSource) commandLine( "cargo", @@ -212,6 +256,7 @@ val buildRustCoreDebug by tasks.registering(Exec::class) { } val buildRustCoreRelease by tasks.registering(Exec::class) { + injectBuildProvenance() workingDir(rustCoreSource) commandLine( "cargo", @@ -308,6 +353,9 @@ abstract class VerifyReleaseNativeLibrary : DefaultTask() { @get:Input abstract val expectedName: Property<String> + @get:Input + abstract val expectedBuildEvidence: ListProperty<String> + @TaskAction fun verify() { val files = stagedDirectory.asFileTree.files.filter { it.isFile } @@ -320,6 +368,15 @@ abstract class VerifyReleaseNativeLibrary : DefaultTask() { if (!files.single().readBytes().contentEquals(releaseLibrary.get().asFile.readBytes())) { throw GradleException("Staged native library does not match the Cargo release artifact") } + val binary = + releaseLibrary + .get() + .asFile + .readBytes() + .toString(Charsets.ISO_8859_1) + expectedBuildEvidence.get().forEach { evidence -> + require(binary.contains(evidence)) { "Release native library is missing build provenance evidence" } + } } } val verifyReleaseNativeLibrary by tasks.registering(VerifyReleaseNativeLibrary::class) { @@ -327,6 +384,7 @@ val verifyReleaseNativeLibrary by tasks.registering(VerifyReleaseNativeLibrary:: releaseLibrary.set(rustReleaseLibrary) stagedDirectory.set(generatedReleaseNativeResources) expectedName.set(rustLibraryName) + expectedBuildEvidence.set(listOf(buildProvenanceDigest.get())) } val releaseNativeResourcesJar by tasks.registering(Jar::class) { dependsOn(verifyReleaseNativeLibrary) @@ -803,8 +861,42 @@ val verifyMacOsNotarization by tasks.registering(VerifyMacOsNotarization::class) dependsOn(verifyMacOsPackage) diskImage.set(layout.buildDirectory.file("compose/binaries/main/dmg/$applicationName-$installableVersion.dmg")) } + +abstract class VerifyReleaseBuildProvenance : DefaultTask() { + @get:Input + abstract val sourceCommit: Property<String> + + @get:Input + abstract val sourceDirty: Property<String> + + @get:Input + abstract val radrootsRevision: Property<String> + + @get:Input + abstract val sourceDateEpoch: Property<String> + + @TaskAction + fun verify() { + require(Regex("[0-9a-f]{40}").matches(sourceCommit.get())) { + "Release source commit provenance is unknown or malformed" + } + require(sourceDirty.get() == "false") { "Release provenance reports a dirty or unknown source tree" } + require(Regex("[0-9a-f]{40}").matches(radrootsRevision.get())) { + "Release Radroots revision provenance is unknown or malformed" + } + require(sourceDateEpoch.get().toULongOrNull()?.let { it > 0UL } == true) { + "Release SOURCE_DATE_EPOCH provenance is unknown or malformed" + } + } +} +val verifyReleaseBuildProvenance by tasks.registering(VerifyReleaseBuildProvenance::class) { + sourceCommit.set(buildSourceCommit) + sourceDirty.set(buildSourceDirty) + radrootsRevision.set(buildRadrootsRevision) + sourceDateEpoch.set(buildSourceDateEpoch) +} tasks.register("releaseReadiness") { - dependsOn("checkLicense", "dependencyCheckAnalyze", verifyHostPackage) + dependsOn("checkLicense", "dependencyCheckAnalyze", verifyHostPackage, verifyReleaseBuildProvenance) if (isMacOsHost) { dependsOn(verifyMacOsDeveloperIdSignature, verifyMacOsNotarization) } diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt @@ -10,7 +10,7 @@ internal const val EXPECTED_DISTRIBUTION_PACKAGE_VERSION = "1.0.0" internal const val EXPECTED_PRODUCT_COORDINATE_DIGEST = "f81db525a0228782530799911879fb55cb25e8e631d09605fd5084e9bd88fbbe" internal const val EXPECTED_SOURCE_PROVENANCE_DIGEST = "d4d54ab897e98a93dfbe27a9d9589dbc38c3b7e2163097617d59beaf64e0358c" internal const val EXPECTED_SOURCE_FOUNDATION_BASELINE = "a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb" -internal const val EXPECTED_FFI_CONTRACT_HASH = "ca5c229f2fc6d93e355dc1278f204f7ac06ac3af0c5c8afef30ba0aa7d42c564" +internal const val EXPECTED_FFI_CONTRACT_HASH = "565f25d8a3ddf418b06a320c92284455ec4d8b82886cde8609a93bbb2486c3a4" internal val EXPECTED_FFI_CONTRACT_MAJOR: UShort = 4.toUShort() internal val MINIMUM_FFI_CONTRACT_MINOR: UShort = 0.toUShort() internal const val EXPECTED_SNAPSHOT_SCHEMA: UInt = 1U diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt @@ -22,11 +22,14 @@ import org.harvestcircle.ffi.ShutdownReceiptDto import org.harvestcircle.ffi.SnapshotChangeDto import org.harvestcircle.ffi.compatibilityDescriptor import org.harvestcircle.ffi.generateOperationIdV7 +import org.harvestcircle.ffi.buildInfo as nativeBuildInfo class NativeHarvestCircleRuntime internal constructor( private val native: NativeCorePort, private val handleIds: NativeHandleIdSource = GeneratedNativeHandleIdSource, ) : HarvestCircleRuntime { + override val buildInfo: BuildInfo = nativeBuildInfo().toBuildInfo() + private val recoveryMutex = Mutex() private val recoveryHandles = mutableMapOf<RecoveryRequestId, NativeGeneratedRecoveryHandle>() private val removalMutex = Mutex() diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt @@ -3,6 +3,7 @@ package org.harvestcircle.application import org.harvestcircle.ffi.ActiveIdentityDto import org.harvestcircle.ffi.AppLifecycleDto import org.harvestcircle.ffi.AppSnapshotDto +import org.harvestcircle.ffi.BuildInfoDto import org.harvestcircle.ffi.HarvestCircleException import org.harvestcircle.ffi.IdentityCommandReceiptDto import org.harvestcircle.ffi.IdentityDto @@ -20,6 +21,28 @@ import org.harvestcircle.ffi.WireErrorCategory import org.harvestcircle.ffi.WireErrorCode import org.harvestcircle.ffi.WireRecoveryAction +internal fun BuildInfoDto.toBuildInfo(): BuildInfo = + BuildInfo( + sourceCommit = sourceCommit, + sourceDirty = + when (sourceDirty) { + "false" -> BuildDirtyState.Clean + "true" -> BuildDirtyState.Dirty + else -> BuildDirtyState.Unknown + }, + radrootsRevision = radrootsRevision, + rustToolchain = rustToolchain, + javaToolchain = javaToolchain, + kotlinToolchain = kotlinToolchain, + provenanceDigest = provenanceDigest, + sourceDateEpoch = sourceDateEpoch, + ffiContractId = ffiContractId, + ffiContractHash = ffiContractHash, + snapshotSchemaVersion = snapshotSchemaVersion, + minimumStorageSchemaVersion = minimumStorageSchemaVersion, + currentStorageSchemaVersion = currentStorageSchemaVersion, + ) + internal fun RequestContext.toNative(): RequestContextDto = RequestContextDto( requestId = operationId.value, diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt @@ -134,6 +134,8 @@ private class ApplicationRuntime( private val shutdownClosed: Boolean = true, private val shutdownGate: CompletableDeferred<Unit>? = null, ) : HarvestCircleRuntime { + override val buildInfo: BuildInfo = BuildInfo.unknown() + var closed = false override suspend fun bootstrap(): ApplicationSnapshot = applicationSnapshot(SnapshotRevision(1UL)) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/BuildInfo.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/BuildInfo.kt @@ -0,0 +1,70 @@ +package org.harvestcircle.application + +data class BuildInfo( + val sourceCommit: String, + val sourceDirty: BuildDirtyState, + val radrootsRevision: String, + val rustToolchain: String, + val javaToolchain: String, + val kotlinToolchain: String, + val provenanceDigest: String, + val sourceDateEpoch: ULong, + val ffiContractId: String, + val ffiContractHash: String, + val snapshotSchemaVersion: UInt, + val minimumStorageSchemaVersion: UInt, + val currentStorageSchemaVersion: UInt, +) { + companion object { + fun unknown(): BuildInfo = + BuildInfo( + sourceCommit = UNKNOWN_PROVENANCE, + sourceDirty = BuildDirtyState.Unknown, + radrootsRevision = UNKNOWN_PROVENANCE, + rustToolchain = UNKNOWN_PROVENANCE, + javaToolchain = UNKNOWN_PROVENANCE, + kotlinToolchain = UNKNOWN_PROVENANCE, + provenanceDigest = UNKNOWN_PROVENANCE, + sourceDateEpoch = 0UL, + ffiContractId = UNKNOWN_PROVENANCE, + ffiContractHash = UNKNOWN_PROVENANCE, + snapshotSchemaVersion = 0U, + minimumStorageSchemaVersion = 0U, + currentStorageSchemaVersion = 0U, + ) + } + + val releaseReady: Boolean + get() = + sourceCommit != UNKNOWN_PROVENANCE && + radrootsRevision != UNKNOWN_PROVENANCE && + sourceDirty == BuildDirtyState.Clean && + rustToolchain != UNKNOWN_PROVENANCE && + javaToolchain != UNKNOWN_PROVENANCE && + kotlinToolchain != UNKNOWN_PROVENANCE && + provenanceDigest != UNKNOWN_PROVENANCE + + fun safeDiagnostics(): Map<String, String> = + linkedMapOf( + "sourceCommit" to sourceCommit, + "sourceDirty" to sourceDirty.name.lowercase(), + "radrootsRevision" to radrootsRevision, + "rustToolchain" to rustToolchain, + "javaToolchain" to javaToolchain, + "kotlinToolchain" to kotlinToolchain, + "provenanceDigest" to provenanceDigest, + "sourceDateEpoch" to sourceDateEpoch.toString(), + "ffiContractId" to ffiContractId, + "ffiContractHash" to ffiContractHash, + "snapshotSchemaVersion" to snapshotSchemaVersion.toString(), + "storageSchemaRange" to "$minimumStorageSchemaVersion..$currentStorageSchemaVersion", + ) +} + +enum class BuildDirtyState { + Clean, + Dirty, + Unknown, +} + +const val UNKNOWN_PROVENANCE = "unknown" diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeContracts.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeContracts.kt @@ -87,6 +87,8 @@ data class ShutdownReceipt( ) interface HarvestCircleRuntime { + val buildInfo: BuildInfo + suspend fun bootstrap(): ApplicationSnapshot fun currentSnapshot(): ApplicationSnapshot diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt @@ -200,6 +200,8 @@ private class FakePresenterRuntime( private val bootstrapGate: CompletableDeferred<Unit>? = null, private val executeGate: CompletableDeferred<Unit>? = null, ) : HarvestCircleRuntime { + override val buildInfo: BuildInfo = BuildInfo.unknown() + private val changes = MutableSharedFlow<ApplicationChange>(extraBufferCapacity = 8) private var current = snapshot(0UL) var nextFailure: ApplicationProblem? = null diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt @@ -8,6 +8,32 @@ import kotlin.test.assertTrue class RuntimeContractsTest { @Test + fun buildInfoRejectsUnknownAndDirtyReleaseInputsAndKeepsDiagnosticsSafe() { + val clean = + BuildInfo( + sourceCommit = "a".repeat(40), + sourceDirty = BuildDirtyState.Clean, + radrootsRevision = "b".repeat(40), + rustToolchain = "1.97.1", + javaToolchain = "21.0.11", + kotlinToolchain = "2.4.10", + provenanceDigest = "d".repeat(64), + sourceDateEpoch = 1_700_000_000UL, + ffiContractId = "harvestcircle-desktop-ffi-v4", + ffiContractHash = "c".repeat(64), + snapshotSchemaVersion = 1U, + minimumStorageSchemaVersion = 5U, + currentStorageSchemaVersion = 10U, + ) + assertTrue(clean.releaseReady) + assertEquals("1700000000", clean.safeDiagnostics()["sourceDateEpoch"]) + assertFalse(clean.safeDiagnostics().values.any { it.contains("nsec1") }) + assertFalse(clean.copy(sourceDirty = BuildDirtyState.Dirty).releaseReady) + assertFalse(clean.copy(sourceCommit = UNKNOWN_PROVENANCE).releaseReady) + assertFalse(BuildInfo.unknown().releaseReady) + } + + @Test fun identifiersAndCommandInputsValidateAndRedact() { val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) assertEquals("01".repeat(32), identityId.value) diff --git a/core/compatibility/harvestcircle-ffi-v4.properties b/core/compatibility/harvestcircle-ffi-v4.properties @@ -2,7 +2,7 @@ schema=harvestcircle.ffi.v4 contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 contract.minor=0 -contract.hash=ca5c229f2fc6d93e355dc1278f204f7ac06ac3af0c5c8afef30ba0aa7d42c564 +contract.hash=565f25d8a3ddf418b06a320c92284455ec4d8b82886cde8609a93bbb2486c3a4 product.coordinate_digest=f81db525a0228782530799911879fb55cb25e8e631d09605fd5084e9bd88fbbe snapshot.schema=1 storage.schema.minimum=5 diff --git a/core/crates/harvestcircle_ffi/build.rs b/core/crates/harvestcircle_ffi/build.rs @@ -33,6 +33,17 @@ const BASELINE_KEYS: &[&str] = &[ ]; fn main() { + for name in [ + "HARVESTCIRCLE_BUILD_SOURCE_COMMIT", + "HARVESTCIRCLE_BUILD_SOURCE_DIRTY", + "HARVESTCIRCLE_BUILD_RADROOTS_REVISION", + "HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN", + "HARVESTCIRCLE_BUILD_JAVA_TOOLCHAIN", + "HARVESTCIRCLE_BUILD_KOTLIN_TOOLCHAIN", + "SOURCE_DATE_EPOCH", + ] { + println!("cargo:rerun-if-env-changed={name}"); + } for source in CONTRACT_SOURCES { println!("cargo:rerun-if-changed={source}"); } @@ -100,6 +111,53 @@ fn main() { required(&baseline, "source.foundation_baseline"), ); emit("HARVESTCIRCLE_FFI_CONTRACT_DIGEST", &contract_digest); + let mut build_provenance = Vec::new(); + for (output, input, default) in [ + ( + "HARVESTCIRCLE_BUILD_SOURCE_COMMIT", + "HARVESTCIRCLE_BUILD_SOURCE_COMMIT", + "unknown", + ), + ( + "HARVESTCIRCLE_BUILD_SOURCE_DIRTY", + "HARVESTCIRCLE_BUILD_SOURCE_DIRTY", + "unknown", + ), + ( + "HARVESTCIRCLE_BUILD_RADROOTS_REVISION", + "HARVESTCIRCLE_BUILD_RADROOTS_REVISION", + "unknown", + ), + ( + "HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN", + "HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN", + "1.97.1", + ), + ( + "HARVESTCIRCLE_BUILD_JAVA_TOOLCHAIN", + "HARVESTCIRCLE_BUILD_JAVA_TOOLCHAIN", + "unknown", + ), + ( + "HARVESTCIRCLE_BUILD_KOTLIN_TOOLCHAIN", + "HARVESTCIRCLE_BUILD_KOTLIN_TOOLCHAIN", + "unknown", + ), + ( + "HARVESTCIRCLE_BUILD_SOURCE_DATE_EPOCH", + "SOURCE_DATE_EPOCH", + "0", + ), + ] { + let value = std::env::var(input).unwrap_or_else(|_| default.to_owned()); + assert_build_value(input, &value); + emit(output, &value); + build_provenance.push(format!("{input}={value}")); + } + emit( + "HARVESTCIRCLE_BUILD_PROVENANCE_DIGEST", + &hex_digest(build_provenance.join("\n").as_bytes()), + ); fs::write( PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")) .join("ffi_contract_metadata.txt"), @@ -108,6 +166,25 @@ fn main() { .expect("write normalized FFI metadata"); } +fn assert_build_value(name: &str, value: &str) { + assert!(!value.is_empty() && value.len() <= 128, "invalid {name}"); + assert!( + value.bytes().all(|byte| byte.is_ascii_graphic()), + "invalid {name}" + ); + if name == "HARVESTCIRCLE_BUILD_SOURCE_DIRTY" { + assert!( + matches!(value, "true" | "false" | "unknown"), + "invalid {name}" + ); + } + if name == "SOURCE_DATE_EPOCH" { + let _: u64 = value + .parse() + .expect("SOURCE_DATE_EPOCH must be an unsigned integer"); + } +} + fn parse_baseline(source: &str) -> BTreeMap<String, String> { let mut values = BTreeMap::new(); for (index, raw_line) in source.lines().enumerate() { diff --git a/core/crates/harvestcircle_ffi/src/commands.rs b/core/crates/harvestcircle_ffi/src/commands.rs @@ -25,8 +25,11 @@ use harvestcircle_storage::OsKeyringSecretStore; use crate::{ AppSnapshotDto, IdentityDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, contract::{ - DISTRIBUTION_PACKAGE_VERSION, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR, - FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION, PRODUCT_COORDINATE_DIGEST, PRODUCT_VERSION, + BUILD_JAVA_TOOLCHAIN, BUILD_KOTLIN_TOOLCHAIN, BUILD_PROVENANCE_DIGEST, + BUILD_RADROOTS_REVISION, BUILD_RUST_TOOLCHAIN, BUILD_SOURCE_COMMIT, + BUILD_SOURCE_DATE_EPOCH, BUILD_SOURCE_DIRTY, DISTRIBUTION_PACKAGE_VERSION, + FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, + MINIMUM_SCHEMA_VERSION, PRODUCT_COORDINATE_DIGEST, PRODUCT_VERSION, SNAPSHOT_SCHEMA_VERSION, SOURCE_FOUNDATION_BASELINE, SOURCE_PROVENANCE_DIGEST, }, dto::error_policy, @@ -88,6 +91,46 @@ pub struct CompatibilityExpectation { pub maximum_schema_version: u32, } +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] +pub struct BuildInfoDto { + pub source_commit: String, + pub source_dirty: String, + pub radroots_revision: String, + pub rust_toolchain: String, + pub java_toolchain: String, + pub kotlin_toolchain: String, + pub provenance_digest: String, + pub source_date_epoch: u64, + pub ffi_contract_id: String, + pub ffi_contract_hash: String, + pub snapshot_schema_version: u32, + pub minimum_storage_schema_version: u32, + pub current_storage_schema_version: u32, +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +#[must_use] +pub fn build_info() -> BuildInfoDto { + BuildInfoDto { + source_commit: BUILD_SOURCE_COMMIT.to_owned(), + source_dirty: BUILD_SOURCE_DIRTY.to_owned(), + radroots_revision: BUILD_RADROOTS_REVISION.to_owned(), + rust_toolchain: BUILD_RUST_TOOLCHAIN.to_owned(), + java_toolchain: BUILD_JAVA_TOOLCHAIN.to_owned(), + kotlin_toolchain: BUILD_KOTLIN_TOOLCHAIN.to_owned(), + provenance_digest: BUILD_PROVENANCE_DIGEST.to_owned(), + source_date_epoch: BUILD_SOURCE_DATE_EPOCH + .parse() + .expect("validated build epoch"), + ffi_contract_id: FFI_CONTRACT_ID.to_owned(), + ffi_contract_hash: FFI_CONTRACT_HASH.to_owned(), + snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION, + minimum_storage_schema_version: MINIMUM_SCHEMA_VERSION, + current_storage_schema_version: harvestcircle_storage::CURRENT_SCHEMA_VERSION, + } +} + #[cfg_attr(not(coverage_nightly), uniffi::export)] pub fn compatibility_descriptor() -> CompatibilityDescriptor { CompatibilityDescriptor { diff --git a/core/crates/harvestcircle_ffi/src/contract.rs b/core/crates/harvestcircle_ffi/src/contract.rs @@ -9,6 +9,14 @@ pub const MINIMUM_SCHEMA_VERSION: u32 = 5; pub const SOURCE_PROVENANCE_DIGEST: &str = env!("HARVESTCIRCLE_SOURCE_PROVENANCE_DIGEST"); pub const SOURCE_FOUNDATION_BASELINE: &str = env!("HARVESTCIRCLE_SOURCE_FOUNDATION_BASELINE"); pub const FFI_CONTRACT_HASH: &str = env!("HARVESTCIRCLE_FFI_CONTRACT_DIGEST"); +pub const BUILD_SOURCE_COMMIT: &str = env!("HARVESTCIRCLE_BUILD_SOURCE_COMMIT"); +pub const BUILD_SOURCE_DIRTY: &str = env!("HARVESTCIRCLE_BUILD_SOURCE_DIRTY"); +pub const BUILD_RADROOTS_REVISION: &str = env!("HARVESTCIRCLE_BUILD_RADROOTS_REVISION"); +pub const BUILD_RUST_TOOLCHAIN: &str = env!("HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN"); +pub const BUILD_JAVA_TOOLCHAIN: &str = env!("HARVESTCIRCLE_BUILD_JAVA_TOOLCHAIN"); +pub const BUILD_KOTLIN_TOOLCHAIN: &str = env!("HARVESTCIRCLE_BUILD_KOTLIN_TOOLCHAIN"); +pub const BUILD_PROVENANCE_DIGEST: &str = env!("HARVESTCIRCLE_BUILD_PROVENANCE_DIGEST"); +pub const BUILD_SOURCE_DATE_EPOCH: &str = env!("HARVESTCIRCLE_BUILD_SOURCE_DATE_EPOCH"); #[cfg(test)] pub(crate) const NORMALIZED_CONTRACT_METADATA: &str = diff --git a/core/crates/harvestcircle_ffi/src/lib.rs b/core/crates/harvestcircle_ffi/src/lib.rs @@ -7,8 +7,9 @@ mod dto; mod observer; pub use commands::{ - GeneratedRecoveryRequest, HarvestCircleAppCore, HarvestCircleError, IdentityCommandReceiptDto, - RelayBootstrapInputDto, RemovalRequest, RequestContextDto, + BuildInfoDto, GeneratedRecoveryRequest, HarvestCircleAppCore, HarvestCircleError, + IdentityCommandReceiptDto, RelayBootstrapInputDto, RemovalRequest, RequestContextDto, + build_info, }; pub use contract::{ DISTRIBUTION_PACKAGE_VERSION, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR, @@ -62,5 +63,23 @@ mod tests { assert!(!super::contract::NORMALIZED_CONTRACT_METADATA.is_empty()); let operation_id = super::generate_operation_id_v7(); assert!(harvestcircle_application::DurableRequestId::parse(operation_id).is_ok()); + let build = super::build_info(); + assert_eq!(build.ffi_contract_id, super::FFI_CONTRACT_ID); + assert_eq!(build.ffi_contract_hash, super::FFI_CONTRACT_HASH); + assert_eq!(build.provenance_digest.len(), 64); + assert_eq!( + build.current_storage_schema_version, + harvestcircle_storage::CURRENT_SCHEMA_VERSION + ); + for value in [ + build.source_commit, + build.radroots_revision, + build.rust_toolchain, + build.java_toolchain, + build.kotlin_toolchain, + ] { + assert!(!value.is_empty()); + assert!(!value.contains(['\n', '\r'])); + } } }