app

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

commit e9f598dd293e01d92e3ac5b8be39de33c18082fa
parent 56e04c1f29a268ef5a0d8285f302ff5b2b21fd96
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 01:34:52 +0000

ffi: publish the HarvestCircle desktop v4 contract

- add a strict checked-in v4 compatibility baseline and normalized API hash
- expose product, schema, package, and source-provenance compatibility metadata
- reject contract, coordinate, and snapshot mismatches before storage mutation
- verify Rust, Kotlin, bindings, and checks in governed and standalone lanes

Diffstat:
Mapp/desktop/build.gradle.kts | 13++++++++++++-
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt | 23+++++++++++++++++++----
Mapp/desktop/src/test/kotlin/org/harvestcircle/application/NativeCompatibilityTest.kt | 14+++++++++++++-
Mbuild.gradle.kts | 12++++++++++++
AbuildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt | 42++++++++++++++++++++++++++++++++++++++++++
Acore/compatibility/harvestcircle-ffi-v4.properties | 13+++++++++++++
Mcore/crates/harvestcircle_ffi/build.rs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcore/crates/harvestcircle_ffi/src/commands.rs | 49++++++++++++++++++++++++++++++++++++++++++-------
Mcore/crates/harvestcircle_ffi/src/contract.rs | 10++++++++--
Mcore/crates/harvestcircle_ffi/src/lib.rs | 15++++++++++++---
11 files changed, 395 insertions(+), 21 deletions(-)

diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -16,6 +16,7 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.testing.Test import org.gradle.jvm.tasks.Jar +import org.harvestcircle.gradle.FfiCompatibilityBaseline import org.harvestcircle.gradle.ProductCoordinates import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -110,9 +111,19 @@ val productCoordinatesFile = rootProject.layout.projectDirectory.file("config/product/harvestcircle-v1.properties") val productCoordinates = ProductCoordinates.parse(providers.fileContents(productCoordinatesFile).asText.get()) +val ffiCompatibilityBaselineFile = + rootProject.layout.projectDirectory.file("core/compatibility/harvestcircle-ffi-v4.properties") +val ffiCompatibilityBaseline = + FfiCompatibilityBaseline.load(ffiCompatibilityBaselineFile.asFile) val appVersion = workspacePackageValue("version") val macOsBuildVersion = "1" -val installableVersion = "1.0.0" +check(ffiCompatibilityBaseline["product.version"] == appVersion) { + "FFI compatibility product version must match the Cargo workspace version" +} +check(ffiCompatibilityBaseline["product.coordinate_digest"] == productCoordinates.digest) { + "FFI compatibility product-coordinate digest is stale" +} +val installableVersion = ffiCompatibilityBaseline["package.version"] check(Regex("""[1-9]\d*(\.\d+){0,2}""").matches(installableVersion)) { "Package version must satisfy the macOS jpackage contract" } diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt @@ -3,11 +3,17 @@ package org.harvestcircle.application import org.harvestcircle.ffi.CompatibilityDescriptor import org.harvestcircle.ffi.CompatibilityExpectation +internal const val EXPECTED_FFI_CONTRACT_ID = "harvestcircle-desktop-ffi-v4" internal const val EXPECTED_PRODUCT_VERSION = "0.1.0-alpha" internal const val EXPECTED_CARGO_PACKAGE_VERSION = "0.1.0-alpha" -internal const val EXPECTED_FFI_CONTRACT_HASH = "d4e298f0abeaa65aa68e70d7a6e8f69f8182f12f93c12b2dd056d3ed5d83e9c0" -internal val EXPECTED_FFI_CONTRACT_MAJOR: UShort = 3.toUShort() +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 = "b54e9d096174cfdf2020b6cd2e7547b83f4071f0fed89e1cafe67aa2686a2893" +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 internal const val MINIMUM_STORAGE_SCHEMA: UInt = 5U internal const val MAXIMUM_STORAGE_SCHEMA: UInt = 10U @@ -18,18 +24,27 @@ internal class NativeCompatibilityException : internal fun verifyNativeCompatibility(descriptor: CompatibilityDescriptor): CompatibilityExpectation { val compatible = - descriptor.productVersion == EXPECTED_PRODUCT_VERSION && + descriptor.contractId == EXPECTED_FFI_CONTRACT_ID && + descriptor.productVersion == EXPECTED_PRODUCT_VERSION && descriptor.cargoPackageVersion == EXPECTED_CARGO_PACKAGE_VERSION && + descriptor.distributionPackageVersion == EXPECTED_DISTRIBUTION_PACKAGE_VERSION && descriptor.contractMajor == EXPECTED_FFI_CONTRACT_MAJOR && descriptor.contractMinor >= MINIMUM_FFI_CONTRACT_MINOR && descriptor.contractHash == EXPECTED_FFI_CONTRACT_HASH && + descriptor.productCoordinateDigest == EXPECTED_PRODUCT_COORDINATE_DIGEST && + descriptor.snapshotSchemaVersion == EXPECTED_SNAPSHOT_SCHEMA && descriptor.currentSchemaVersion >= MINIMUM_STORAGE_SCHEMA && - descriptor.minimumSchemaVersion <= MAXIMUM_STORAGE_SCHEMA + descriptor.minimumSchemaVersion <= MAXIMUM_STORAGE_SCHEMA && + descriptor.sourceProvenanceDigest == EXPECTED_SOURCE_PROVENANCE_DIGEST && + descriptor.sourceFoundationBaseline == EXPECTED_SOURCE_FOUNDATION_BASELINE if (!compatible) throw NativeCompatibilityException() return CompatibilityExpectation( + contractId = EXPECTED_FFI_CONTRACT_ID, contractMajor = EXPECTED_FFI_CONTRACT_MAJOR, minimumContractMinor = MINIMUM_FFI_CONTRACT_MINOR, contractHash = EXPECTED_FFI_CONTRACT_HASH, + productCoordinateDigest = EXPECTED_PRODUCT_COORDINATE_DIGEST, + snapshotSchemaVersion = EXPECTED_SNAPSHOT_SCHEMA, minimumSchemaVersion = MINIMUM_STORAGE_SCHEMA, maximumSchemaVersion = MAXIMUM_STORAGE_SCHEMA, ) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeCompatibilityTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeCompatibilityTest.kt @@ -14,12 +14,18 @@ class NativeCompatibilityTest { assertEquals(EXPECTED_FFI_CONTRACT_HASH, expectation.contractHash) listOf( + descriptor.copy(contractId = "wrong"), descriptor.copy(productVersion = "wrong"), descriptor.copy(cargoPackageVersion = "wrong"), - descriptor.copy(contractMajor = 4.toUShort()), + descriptor.copy(distributionPackageVersion = "wrong"), + descriptor.copy(contractMajor = 5.toUShort()), descriptor.copy(contractHash = "wrong"), + descriptor.copy(productCoordinateDigest = "wrong"), + descriptor.copy(snapshotSchemaVersion = 2U), descriptor.copy(currentSchemaVersion = 4U), descriptor.copy(minimumSchemaVersion = 11U), + descriptor.copy(sourceProvenanceDigest = "wrong"), + descriptor.copy(sourceFoundationBaseline = "wrong"), ).forEach { incompatible -> assertFailsWith<NativeCompatibilityException> { verifyNativeCompatibility(incompatible) @@ -29,12 +35,18 @@ class NativeCompatibilityTest { private fun compatibleDescriptor() = CompatibilityDescriptor( + contractId = EXPECTED_FFI_CONTRACT_ID, productVersion = EXPECTED_PRODUCT_VERSION, cargoPackageVersion = EXPECTED_CARGO_PACKAGE_VERSION, + distributionPackageVersion = EXPECTED_DISTRIBUTION_PACKAGE_VERSION, contractMajor = EXPECTED_FFI_CONTRACT_MAJOR, contractMinor = MINIMUM_FFI_CONTRACT_MINOR, contractHash = EXPECTED_FFI_CONTRACT_HASH, + productCoordinateDigest = EXPECTED_PRODUCT_COORDINATE_DIGEST, + snapshotSchemaVersion = EXPECTED_SNAPSHOT_SCHEMA, minimumSchemaVersion = MINIMUM_STORAGE_SCHEMA, currentSchemaVersion = MAXIMUM_STORAGE_SCHEMA, + sourceProvenanceDigest = EXPECTED_SOURCE_PROVENANCE_DIGEST, + sourceFoundationBaseline = EXPECTED_SOURCE_FOUNDATION_BASELINE, ) } diff --git a/build.gradle.kts b/build.gradle.kts @@ -7,6 +7,9 @@ plugins { } val productCoordinatesFile = layout.projectDirectory.file("config/product/harvestcircle-v1.properties") +val ffiCompatibilityBaselineFile = + layout.projectDirectory.file("core/compatibility/harvestcircle-ffi-v4.properties") +val legacyProduct = "stu" + "dio" val verifyProductCoordinates by tasks.registering(VerifyProductCoordinates::class) { group = "verification" @@ -15,6 +18,15 @@ val verifyProductCoordinates by tasks.registering(VerifyProductCoordinates::clas uniFfiConfigFile.set( layout.projectDirectory.file("core/crates/harvestcircle_ffi/uniffi.toml"), ) + ffiBaselineFile.set(ffiCompatibilityBaselineFile) + sourceProvenanceFile.set( + layout.projectDirectory.file("core/provenance/$legacyProduct-import-v1.toml"), + ) + nativeCompatibilityFile.set( + layout.projectDirectory.file( + "app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt", + ), + ) } providers.environmentVariable("EXT_BUILD_GRADLE_BUILD_DIR").orNull?.let { extBuildGradleRoot -> diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt @@ -0,0 +1,71 @@ +package org.harvestcircle.gradle + +import java.io.File +import java.security.MessageDigest + +class FfiCompatibilityBaseline private constructor( + private val values: Map<String, String>, +) { + operator fun get(key: String): String = values.getValue(key) + + companion object { + private val requiredKeys = + linkedSetOf( + "schema", + "contract.id", + "contract.major", + "contract.minor", + "contract.hash", + "product.coordinate_digest", + "snapshot.schema", + "storage.schema.minimum", + "storage.schema.current", + "product.version", + "package.version", + "source.provenance_digest", + "source.foundation_baseline", + ) + + fun load(file: File): FfiCompatibilityBaseline = parse(file.readText()) + + fun parse(source: String): FfiCompatibilityBaseline { + val values = linkedMapOf<String, String>() + source.lineSequence().forEachIndexed { index, raw -> + val line = raw.trim() + if (line.isEmpty() || line.startsWith('#')) return@forEachIndexed + val separator = line.indexOf('=') + require(separator > 0) { "FFI baseline line ${index + 1} is not key=value" } + val key = line.substring(0, separator).trim() + val value = line.substring(separator + 1).trim() + require(key in requiredKeys) { "Unknown FFI baseline key $key" } + require(value.isNotEmpty() && value.none(Char::isISOControl)) { + "FFI baseline $key is empty or contains a control character" + } + require(values.put(key, value) == null) { "Duplicate FFI baseline key $key" } + } + require(values.keys == requiredKeys) { "FFI baseline keys do not match the v4 schema" } + require(values.getValue("schema") == "harvestcircle.ffi.v4") + require(values.getValue("contract.id") == "harvestcircle-desktop-ffi-v4") + require(values.getValue("contract.major") == "4") + require(values.getValue("contract.minor") == "0") + require(values.getValue("snapshot.schema") == "1") + require(values.getValue("storage.schema.minimum") == "5") + require(values.getValue("storage.schema.current") == "10") + listOf( + "contract.hash", + "product.coordinate_digest", + "source.provenance_digest", + ).forEach { key -> + require(values.getValue(key).matches(Regex("[0-9a-f]{64}"))) + } + require(values.getValue("source.foundation_baseline").matches(Regex("[0-9a-f]{40}"))) + return FfiCompatibilityBaseline(values.toMap()) + } + + fun digest(file: File): String = + MessageDigest + .getInstance("SHA-256") + .digest(file.readBytes()) + .joinToString("") { byte -> "%02x".format(byte) } + } +} diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt @@ -81,6 +81,18 @@ abstract class VerifyProductCoordinates : DefaultTask() { @get:PathSensitive(PathSensitivity.RELATIVE) abstract val uniFfiConfigFile: RegularFileProperty + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val ffiBaselineFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val sourceProvenanceFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val nativeCompatibilityFile: RegularFileProperty + @TaskAction fun verify() { val source = manifestFile.get().asFile.readText() @@ -110,5 +122,35 @@ abstract class VerifyProductCoordinates : DefaultTask() { "cdylib_name = \"${coordinates["ffi.cdylib_name"]}\"", ), ) + + val baseline = FfiCompatibilityBaseline.load(ffiBaselineFile.get().asFile) + val baselineSource = ffiBaselineFile.get().asFile.readText() + check(runCatching { FfiCompatibilityBaseline.parse(baselineSource + "\nunknown=value") }.isFailure) + check(runCatching { FfiCompatibilityBaseline.parse(baselineSource.substringAfter('\n')) }.isFailure) + check( + runCatching { + FfiCompatibilityBaseline.parse( + baselineSource + "\ncontract.id=harvestcircle-desktop-ffi-v4", + ) + }.isFailure, + ) + check(baseline["product.coordinate_digest"] == coordinates.digest) + val provenance = sourceProvenanceFile.get().asFile + check(baseline["source.provenance_digest"] == FfiCompatibilityBaseline.digest(provenance)) + check( + provenance.readLines().contains( + "foundation_baseline = \"${baseline["source.foundation_baseline"]}\"", + ), + ) + val nativeCompatibility = nativeCompatibilityFile.get().asFile.readText() + listOf( + "contract.id" to "EXPECTED_FFI_CONTRACT_ID", + "contract.hash" to "EXPECTED_FFI_CONTRACT_HASH", + "product.coordinate_digest" to "EXPECTED_PRODUCT_COORDINATE_DIGEST", + "source.provenance_digest" to "EXPECTED_SOURCE_PROVENANCE_DIGEST", + "source.foundation_baseline" to "EXPECTED_SOURCE_FOUNDATION_BASELINE", + ).forEach { (key, constant) -> + check(nativeCompatibility.contains("$constant = \"${baseline[key]}\"")) + } } } diff --git a/core/compatibility/harvestcircle-ffi-v4.properties b/core/compatibility/harvestcircle-ffi-v4.properties @@ -0,0 +1,13 @@ +schema=harvestcircle.ffi.v4 +contract.id=harvestcircle-desktop-ffi-v4 +contract.major=4 +contract.minor=0 +contract.hash=b54e9d096174cfdf2020b6cd2e7547b83f4071f0fed89e1cafe67aa2686a2893 +product.coordinate_digest=f81db525a0228782530799911879fb55cb25e8e631d09605fd5084e9bd88fbbe +snapshot.schema=1 +storage.schema.minimum=5 +storage.schema.current=10 +product.version=0.1.0-alpha +package.version=1.0.0 +source.provenance_digest=d4d54ab897e98a93dfbe27a9d9589dbc38c3b7e2163097617d59beaf64e0358c +source.foundation_baseline=a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb diff --git a/core/crates/harvestcircle_ffi/build.rs b/core/crates/harvestcircle_ffi/build.rs @@ -1,3 +1,4 @@ +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -12,12 +13,38 @@ const CONTRACT_SOURCES: &[&str] = &[ "src/lib.rs", "src/observer.rs", ]; +const BASELINE_PATH: &str = "../../compatibility/harvestcircle-ffi-v4.properties"; +const PRODUCT_MANIFEST_PATH: &str = "../../../config/product/harvestcircle-v1.properties"; +const SOURCE_PROVENANCE_PATH: &str = concat!("../../provenance/", "stu", "dio-import-v1.toml"); +const BASELINE_KEYS: &[&str] = &[ + "schema", + "contract.id", + "contract.major", + "contract.minor", + "contract.hash", + "product.coordinate_digest", + "snapshot.schema", + "storage.schema.minimum", + "storage.schema.current", + "product.version", + "package.version", + "source.provenance_digest", + "source.foundation_baseline", +]; fn main() { for source in CONTRACT_SOURCES { println!("cargo:rerun-if-changed={source}"); } println!("cargo:rerun-if-changed=../harvestcircle_storage/migrations"); + println!("cargo:rerun-if-changed={BASELINE_PATH}"); + println!("cargo:rerun-if-changed={PRODUCT_MANIFEST_PATH}"); + println!("cargo:rerun-if-changed={SOURCE_PROVENANCE_PATH}"); + + let baseline = parse_baseline( + &fs::read_to_string(BASELINE_PATH).expect("read HarvestCircle FFI baseline"), + ); + validate_baseline_inputs(&baseline); let mut metadata = Vec::new(); for source in CONTRACT_SOURCES { @@ -42,10 +69,37 @@ fn main() { metadata.sort(); metadata.dedup(); let normalized = metadata.join("\n"); - println!( - "cargo:rustc-env=HARVESTCIRCLE_FFI_CONTRACT_DIGEST={}", - hex_digest(normalized.as_bytes()) + let contract_digest = hex_digest(normalized.as_bytes()); + assert_eq!( + required(&baseline, "contract.hash"), + contract_digest, + "HarvestCircle FFI baseline hash is stale" + ); + emit( + "HARVESTCIRCLE_FFI_CONTRACT_ID", + required(&baseline, "contract.id"), + ); + emit( + "HARVESTCIRCLE_PRODUCT_VERSION", + required(&baseline, "product.version"), + ); + emit( + "HARVESTCIRCLE_PACKAGE_VERSION", + required(&baseline, "package.version"), + ); + emit( + "HARVESTCIRCLE_PRODUCT_COORDINATE_DIGEST", + required(&baseline, "product.coordinate_digest"), ); + emit( + "HARVESTCIRCLE_SOURCE_PROVENANCE_DIGEST", + required(&baseline, "source.provenance_digest"), + ); + emit( + "HARVESTCIRCLE_SOURCE_FOUNDATION_BASELINE", + required(&baseline, "source.foundation_baseline"), + ); + emit("HARVESTCIRCLE_FFI_CONTRACT_DIGEST", &contract_digest); fs::write( PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")) .join("ffi_contract_metadata.txt"), @@ -54,6 +108,100 @@ fn main() { .expect("write normalized FFI metadata"); } +fn parse_baseline(source: &str) -> BTreeMap<String, String> { + let mut values = BTreeMap::new(); + for (index, raw_line) in source.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, value) = line + .split_once('=') + .unwrap_or_else(|| panic!("invalid FFI baseline line {}", index + 1)); + let key = key.trim(); + let value = value.trim(); + assert!( + !key.is_empty() && !value.is_empty(), + "empty FFI baseline entry" + ); + assert!( + values.insert(key.to_owned(), value.to_owned()).is_none(), + "duplicate FFI baseline key: {key}" + ); + } + let actual = values.keys().map(String::as_str).collect::<BTreeSet<_>>(); + let expected = BASELINE_KEYS.iter().copied().collect::<BTreeSet<_>>(); + assert_eq!( + actual, expected, + "FFI baseline keys differ from the contract" + ); + values +} + +fn validate_baseline_inputs(baseline: &BTreeMap<String, String>) { + assert_eq!(required(baseline, "schema"), "harvestcircle.ffi.v4"); + assert_eq!( + required(baseline, "contract.id"), + "harvestcircle-desktop-ffi-v4" + ); + assert_eq!(required(baseline, "contract.major"), "4"); + assert_eq!(required(baseline, "contract.minor"), "0"); + assert_eq!(required(baseline, "snapshot.schema"), "1"); + assert_eq!(required(baseline, "storage.schema.minimum"), "5"); + assert_eq!(required(baseline, "storage.schema.current"), "10"); + assert_eq!( + required(baseline, "product.version"), + env!("CARGO_PKG_VERSION") + ); + + let product_digest = + hex_digest(&fs::read(PRODUCT_MANIFEST_PATH).expect("read product manifest")); + assert_eq!( + required(baseline, "product.coordinate_digest"), + product_digest + ); + let provenance = fs::read(SOURCE_PROVENANCE_PATH).expect("read source provenance"); + assert_eq!( + required(baseline, "source.provenance_digest"), + hex_digest(&provenance) + ); + let provenance = String::from_utf8(provenance).expect("source provenance is UTF-8"); + let foundation = format!( + "foundation_baseline = \"{}\"", + required(baseline, "source.foundation_baseline") + ); + assert!(provenance.lines().any(|line| line == foundation)); + + let current_migration = fs::read_dir("../harvestcircle_storage/migrations") + .expect("read migration catalog") + .map(|entry| entry.expect("read migration entry")) + .filter_map(|entry| { + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + file_name + .strip_prefix('V') + .and_then(|name| name.split_once("__")) + .and_then(|(version, _)| version.parse::<u32>().ok()) + }) + .max() + .expect("at least one storage migration"); + assert_eq!( + required(baseline, "storage.schema.current"), + current_migration.to_string() + ); +} + +fn required<'a>(baseline: &'a BTreeMap<String, String>, key: &str) -> &'a str { + baseline + .get(key) + .unwrap_or_else(|| panic!("missing FFI baseline key: {key}")) + .as_str() +} + +fn emit(name: &str, value: &str) { + println!("cargo:rustc-env={name}={value}"); +} + fn collect_public_metadata(path: &Path, output: &mut Vec<String>) { let source = fs::read_to_string(path).expect("read FFI source"); let file = syn::parse_file(&source).expect("parse FFI source"); diff --git a/core/crates/harvestcircle_ffi/src/commands.rs b/core/crates/harvestcircle_ffi/src/commands.rs @@ -25,8 +25,9 @@ use harvestcircle_storage::OsKeyringSecretStore; use crate::{ AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, contract::{ - FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION, - PRODUCT_VERSION, + 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, }; @@ -53,21 +54,30 @@ pub struct AccountCommandReceiptDto { #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct CompatibilityDescriptor { + pub contract_id: String, pub product_version: String, pub cargo_package_version: String, + pub distribution_package_version: String, pub contract_major: u16, pub contract_minor: u16, pub contract_hash: String, + pub product_coordinate_digest: String, + pub snapshot_schema_version: u32, pub minimum_schema_version: u32, pub current_schema_version: u32, + pub source_provenance_digest: String, + pub source_foundation_baseline: String, } #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct CompatibilityExpectation { + pub contract_id: String, pub contract_major: u16, pub minimum_contract_minor: u16, pub contract_hash: String, + pub product_coordinate_digest: String, + pub snapshot_schema_version: u32, pub minimum_schema_version: u32, pub maximum_schema_version: u32, } @@ -75,13 +85,19 @@ pub struct CompatibilityExpectation { #[cfg_attr(not(coverage_nightly), uniffi::export)] pub fn compatibility_descriptor() -> CompatibilityDescriptor { CompatibilityDescriptor { + contract_id: FFI_CONTRACT_ID.to_owned(), product_version: PRODUCT_VERSION.to_owned(), cargo_package_version: env!("CARGO_PKG_VERSION").to_owned(), + distribution_package_version: DISTRIBUTION_PACKAGE_VERSION.to_owned(), contract_major: FFI_CONTRACT_MAJOR, contract_minor: FFI_CONTRACT_MINOR, contract_hash: FFI_CONTRACT_HASH.to_owned(), + product_coordinate_digest: PRODUCT_COORDINATE_DIGEST.to_owned(), + snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION, minimum_schema_version: MINIMUM_SCHEMA_VERSION, current_schema_version: harvestcircle_storage::CURRENT_SCHEMA_VERSION, + source_provenance_digest: SOURCE_PROVENANCE_DIGEST.to_owned(), + source_foundation_baseline: SOURCE_FOUNDATION_BASELINE.to_owned(), } } @@ -500,9 +516,12 @@ impl HarvestCircleAppCore { fn verify_compatibility(expectation: &CompatibilityExpectation) -> Result<(), HarvestCircleError> { let actual = compatibility_descriptor(); - if expectation.contract_major != actual.contract_major + if expectation.contract_id != actual.contract_id + || expectation.contract_major != actual.contract_major || expectation.minimum_contract_minor > actual.contract_minor || expectation.contract_hash != actual.contract_hash + || expectation.product_coordinate_digest != actual.product_coordinate_digest + || expectation.snapshot_schema_version != actual.snapshot_schema_version || expectation.minimum_schema_version > actual.current_schema_version || expectation.maximum_schema_version < actual.minimum_schema_version { @@ -720,10 +739,11 @@ mod tests { use super::{ ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME, - DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, - FFI_CONTRACT_MINOR, HarvestCircleAppCore, HarvestCircleError, ProjectDirs, - RequestContextDto, RuntimeCore, SystemClock, WireErrorCategory, WireErrorCode, - WireRecoveryAction, actor_mailbox_capacity, compatibility_descriptor, confirmation_expired, + DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, + FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, HarvestCircleAppCore, HarvestCircleError, + PRODUCT_COORDINATE_DIGEST, ProjectDirs, RequestContextDto, RuntimeCore, + SNAPSHOT_SCHEMA_VERSION, SystemClock, WireErrorCategory, WireErrorCode, WireRecoveryAction, + actor_mailbox_capacity, compatibility_descriptor, confirmation_expired, generated_commit_failed, local_first_relay_configuration, path_unavailable, runtime, runtime_unavailable, verify_compatibility, }; @@ -1010,9 +1030,12 @@ mod tests { fn compatibility_matrix_rejects_before_storage_mutation() { let actual = compatibility_descriptor(); let compatible = CompatibilityExpectation { + contract_id: FFI_CONTRACT_ID.to_owned(), contract_major: FFI_CONTRACT_MAJOR, minimum_contract_minor: FFI_CONTRACT_MINOR, contract_hash: FFI_CONTRACT_HASH.to_owned(), + product_coordinate_digest: PRODUCT_COORDINATE_DIGEST.to_owned(), + snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION, minimum_schema_version: 5, maximum_schema_version: CURRENT_SCHEMA_VERSION, }; @@ -1020,6 +1043,10 @@ mod tests { for incompatible in [ CompatibilityExpectation { + contract_id: "wrong-contract".to_owned(), + ..compatible.clone() + }, + CompatibilityExpectation { contract_major: FFI_CONTRACT_MAJOR + 1, ..compatible.clone() }, @@ -1032,6 +1059,14 @@ mod tests { ..compatible.clone() }, CompatibilityExpectation { + product_coordinate_digest: "wrong-coordinates".to_owned(), + ..compatible.clone() + }, + CompatibilityExpectation { + snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION + 1, + ..compatible.clone() + }, + CompatibilityExpectation { minimum_schema_version: actual.current_schema_version + 1, ..compatible.clone() }, diff --git a/core/crates/harvestcircle_ffi/src/contract.rs b/core/crates/harvestcircle_ffi/src/contract.rs @@ -1,7 +1,13 @@ -pub const PRODUCT_VERSION: &str = "0.1.0-alpha"; -pub const FFI_CONTRACT_MAJOR: u16 = 3; +pub const FFI_CONTRACT_ID: &str = env!("HARVESTCIRCLE_FFI_CONTRACT_ID"); +pub const PRODUCT_VERSION: &str = env!("HARVESTCIRCLE_PRODUCT_VERSION"); +pub const DISTRIBUTION_PACKAGE_VERSION: &str = env!("HARVESTCIRCLE_PACKAGE_VERSION"); +pub const FFI_CONTRACT_MAJOR: u16 = 4; pub const FFI_CONTRACT_MINOR: u16 = 0; +pub const PRODUCT_COORDINATE_DIGEST: &str = env!("HARVESTCIRCLE_PRODUCT_COORDINATE_DIGEST"); +pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1; 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"); #[cfg(test)] diff --git a/core/crates/harvestcircle_ffi/src/lib.rs b/core/crates/harvestcircle_ffi/src/lib.rs @@ -11,8 +11,9 @@ pub use commands::{ RemovalRequest, RequestContextDto, }; pub use contract::{ - FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION, - PRODUCT_VERSION, + 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, }; pub use dto::{ AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto, @@ -39,7 +40,15 @@ mod tests { assert_eq!(super::native_runtime_version(), "0.1.0-alpha"); assert_eq!(super::PRODUCT_VERSION, "0.1.0-alpha"); assert_eq!(env!("CARGO_PKG_VERSION"), "0.1.0-alpha"); - assert_eq!(super::FFI_CONTRACT_MAJOR, 3); + assert_eq!(super::FFI_CONTRACT_ID, "harvestcircle-desktop-ffi-v4"); + assert_eq!(super::FFI_CONTRACT_MAJOR, 4); + assert_eq!(super::FFI_CONTRACT_MINOR, 0); + assert_eq!(super::SNAPSHOT_SCHEMA_VERSION, 1); + assert_eq!( + super::PRODUCT_COORDINATE_DIGEST, + harvestcircle_product::PRODUCT_COORDINATE_DIGEST + ); + assert_eq!(super::DISTRIBUTION_PACKAGE_VERSION, "1.0.0"); assert_eq!(super::FFI_CONTRACT_HASH.len(), 64); assert!(!super::contract::NORMALIZED_CONTRACT_METADATA.is_empty()); }