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