app

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

commit 6405b6a32ac418c00d75f7d9808ef4bb10aeaa57
parent 37cf617f79609d683786574f4b6d9b1cadc3ca9b
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 17:02:23 +0000

build: canonicalize product and provenance digests

- parse and validate product and provenance manifests semantically
- share deterministic digest vectors across Rust and Gradle consumers
- refresh compatibility coordinates and native digest expectations
- enforce governed line endings and expose the baseline verification task

Diffstat:
M.gitattributes | 1+
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt | 4++--
Mbuild.gradle.kts | 6++++++
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt | 7-------
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt | 54+++++++++++++++++++++++++++++++++++++++++++++++-------
AbuildSrc/src/main/kotlin/org/harvestcircle/gradle/SourceProvenance.kt | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/Cargo.lock | 5+++--
Mcore/Cargo.toml | 1+
Mcore/compatibility/harvestcircle-ffi-v4.properties | 4++--
Mcore/crates/harvestcircle_ffi/Cargo.toml | 1+
Mcore/crates/harvestcircle_ffi/build.rs | 18+++++++++++-------
Mcore/crates/harvestcircle_product/Cargo.toml | 4++++
Mcore/crates/harvestcircle_product/build.rs | 9++-------
Mcore/crates/harvestcircle_product/src/lib.rs | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcore/crates/harvestcircle_product/src/parser.rs | 31++++++++++++++++++++++++++++++-
Acore/crates/harvestcircle_product/src/provenance.rs | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
16 files changed, 444 insertions(+), 40 deletions(-)

diff --git a/.gitattributes b/.gitattributes @@ -1,2 +1,3 @@ +* text=auto eol=lf *.bat text eol=crlf *.sh text eol=lf diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt @@ -7,8 +7,8 @@ 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_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_PRODUCT_COORDINATE_DIGEST = "93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223" +internal const val EXPECTED_SOURCE_PROVENANCE_DIGEST = "db238195b4a5938a8d4d9ac5681c4b125e65c57aa8133ad03e59da4e4bd062bc" internal const val EXPECTED_SOURCE_FOUNDATION_BASELINE = "a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb" internal const val EXPECTED_FFI_CONTRACT_HASH = "565f25d8a3ddf418b06a320c92284455ec4d8b82886cde8609a93bbb2486c3a4" internal val EXPECTED_FFI_CONTRACT_MAJOR: UShort = 4.toUShort() diff --git a/build.gradle.kts b/build.gradle.kts @@ -33,6 +33,12 @@ val verifyProductCoordinates by tasks.registering(VerifyProductCoordinates::clas ) } +val verifyCompatibilityBaseline by tasks.registering { + group = "verification" + description = "Validates the generated-code and native compatibility baseline." + dependsOn(verifyProductCoordinates) +} + val verifyVerificationLanes by tasks.registering(VerifyVerificationLanes::class) { group = "verification" description = "Validates forge-agnostic verification lanes and least-privilege policy." diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt @@ -1,7 +1,6 @@ package org.harvestcircle.gradle import java.io.File -import java.security.MessageDigest class FfiCompatibilityBaseline private constructor( private val values: Map<String, String>, @@ -61,11 +60,5 @@ class FfiCompatibilityBaseline private constructor( 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 @@ -40,6 +40,7 @@ class ProductCoordinates private constructor( fun load(file: File): ProductCoordinates = parse(file.readText()) fun parse(source: String): ProductCoordinates { + require(!source.startsWith('\uFEFF')) { "Product coordinates must not contain a UTF-8 BOM" } val parsed = linkedMapOf<String, String>() source.lineSequence().forEachIndexed { index, raw -> val line = raw.trim() @@ -49,7 +50,7 @@ class ProductCoordinates private constructor( val key = line.substring(0, separator).trim() val value = line.substring(separator + 1).trim() require(key in required) { "Unknown product coordinate $key" } - require(value.isNotEmpty() && value.none(Char::isISOControl)) { + 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" } @@ -62,10 +63,16 @@ class ProductCoordinates private constructor( "Product coordinate $key does not match the approved value" } } + val canonical = + buildString { + required.keys.forEach { key -> + append(key).append('=').append(parsed.getValue(key)).append('\n') + } + } val digest = MessageDigest .getInstance("SHA-256") - .digest(source.toByteArray()) + .digest(canonical.toByteArray(Charsets.UTF_8)) .joinToString("") { byte -> "%02x".format(byte) } return ProductCoordinates(parsed.toMap(), digest) } @@ -100,6 +107,19 @@ abstract class VerifyProductCoordinates : DefaultTask() { 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( + source.replace("\n", "\r\n"), + source.trimEnd(), + "# comment\n$source", + source.lineSequence().joinToString("\n") { line -> + if (line.isBlank() || line.startsWith('#')) line else line.replaceFirst("=", " = ") + }, + ) + equivalentSources.forEach { equivalent -> + 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 + "\nunknown=value") }.isFailure) check(runCatching { ProductCoordinates.parse(source.substringAfter('\n')) }.isFailure) @@ -135,12 +155,32 @@ abstract class VerifyProductCoordinates : DefaultTask() { }.isFailure, ) check(baseline["product.coordinate_digest"] == coordinates.digest) - val provenance = sourceProvenanceFile.get().asFile - check(baseline["source.provenance_digest"] == FfiCompatibilityBaseline.digest(provenance)) + val provenanceSource = sourceProvenanceFile.get().asFile.readText() + val provenance = SourceProvenance.parse(provenanceSource) + check(baseline["source.provenance_digest"] == provenance.digest) + check(provenance.foundationBaseline == baseline["source.foundation_baseline"]) + val equivalentProvenance = + listOf( + provenanceSource.replace("\n", "\r\n"), + provenanceSource.trimEnd(), + "# comment\n$provenanceSource", + provenanceSource.replace( + "component = \"domain\"\ncommit = \"a4d7deebec3e2ce2c1daa455de6d79857839aed0\"", + "commit = \"a4d7deebec3e2ce2c1daa455de6d79857839aed0\"\ncomponent = \"domain\"", + ), + ) + equivalentProvenance.forEach { equivalent -> + check(SourceProvenance.parse(equivalent).digest == provenance.digest) + } + check(runCatching { SourceProvenance.parse("\uFEFF$provenanceSource") }.isFailure) + check(runCatching { SourceProvenance.parse("unknown = \"value\"\n$provenanceSource") }.isFailure) check( - provenance.readLines().contains( - "foundation_baseline = \"${baseline["source.foundation_baseline"]}\"", - ), + SourceProvenance.parse( + provenanceSource.replace( + "a4d7deebec3e2ce2c1daa455de6d79857839aed0", + "b4d7deebec3e2ce2c1daa455de6d79857839aed0", + ), + ).digest != provenance.digest, ) val nativeCompatibility = nativeCompatibilityFile.get().asFile.readText() listOf( diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/SourceProvenance.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/SourceProvenance.kt @@ -0,0 +1,108 @@ +package org.harvestcircle.gradle + +import java.io.File +import java.security.MessageDigest + +class SourceProvenance private constructor( + private val root: Map<String, String>, + private val imports: List<Map<String, String>>, +) { + val foundationBaseline: String + get() = root.getValue("foundation_baseline") + + val canonical: String + get() = + buildString { + rootKeys.forEach { key -> + append(key).append('=').append(root.getValue(key)).append('\n') + } + imports.forEach { entry -> + append("import.component=").append(entry.getValue("component")).append('\n') + append("import.commit=").append(entry.getValue("commit")).append('\n') + } + } + + val digest: String + get() = canonical.sha256() + + companion object { + private val rootKeys = + linkedSetOf( + "schema", + "source_product", + "source_repository", + "foundation_baseline", + "canonical_radroots_repository", + "canonical_radroots_revision", + ) + private val importKeys = linkedSetOf("component", "commit") + private val assignment = Regex("^([A-Za-z0-9_]+)\\s*=\\s*\"([^\"]*)\"\\s*(?:#.*)?$") + + fun load(file: File): SourceProvenance = parse(file.readText()) + + fun parse(source: String): SourceProvenance { + require(!source.startsWith('\uFEFF')) { "Source provenance must not contain a UTF-8 BOM" } + val root = linkedMapOf<String, String>() + val imports = mutableListOf<Map<String, String>>() + var currentImport: LinkedHashMap<String, String>? = null + + fun completeImport() { + currentImport?.let { entry -> + require(entry.keys == importKeys) { "Source provenance import keys do not match the contract" } + imports += entry.toMap() + } + currentImport = null + } + + source.lineSequence().forEachIndexed { index, raw -> + val line = raw.trim() + if (line.isEmpty() || line.startsWith('#')) return@forEachIndexed + if (line == "[[import]]") { + completeImport() + currentImport = linkedMapOf() + return@forEachIndexed + } + val match = assignment.matchEntire(line) + require(match != null) { "Source provenance line ${index + 1} is not a supported TOML string assignment" } + val key = match.groupValues[1] + val value = match.groupValues[2] + require(value.isNotEmpty() && value.none(Char::isISOControl)) { + "Source provenance $key is empty or contains a control character" + } + val target = currentImport ?: root + val allowed = if (currentImport == null) rootKeys else importKeys + require(key in allowed) { "Unknown source provenance key $key" } + require(target.put(key, value) == null) { "Duplicate source provenance key $key" } + } + completeImport() + + require(root.keys == rootKeys) { "Source provenance root keys do not match the contract" } + require(root.getValue("schema") == "harvestcircle.source_provenance.v1") + listOf("foundation_baseline", "canonical_radroots_revision").forEach { key -> + require(root.getValue(key).isCanonicalHex(40)) { "Source provenance $key is not canonical" } + } + require(imports.isNotEmpty()) { "Source provenance imports must not be empty" } + imports.forEach { entry -> + require(entry.getValue("commit").isCanonicalHex(40)) { + "Source provenance import commit is not canonical" + } + } + require(imports.map { it.getValue("component") }.distinct().size == imports.size) { + "Source provenance components must be unique" + } + return SourceProvenance( + root.toMap(), + imports.sortedBy { it.getValue("component") }, + ) + } + } +} + +private fun String.sha256(): String = + MessageDigest + .getInstance("SHA-256") + .digest(toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte) } + +private fun String.isCanonicalHex(width: Int): Boolean = + length == width && all { character -> character in '0'..'9' || character in 'a'..'f' } diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -1123,6 +1123,7 @@ name = "harvestcircle_product" version = "0.1.0-alpha" dependencies = [ "sha2", + "toml", ] [[package]] @@ -3397,9 +3398,9 @@ dependencies = [ [[package]] name = "zcheapstr" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "769381976ea05c1f0abde0ca527b02e415bccbdbad148534a2b0c1901c9ab934" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" dependencies = [ "serde", ] diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -48,4 +48,5 @@ quote = { version = "1" } rustix = { version = "1", features = ["fs", "process", "std"] } sha2 = { version = "0.10", default-features = false } syn = { version = "2", features = ["full", "parsing", "visit", "visit-mut"] } +toml = { version = "1.1.4" } uuid = { version = "1.22.0", features = ["v4", "v7"] } diff --git a/core/compatibility/harvestcircle-ffi-v4.properties b/core/compatibility/harvestcircle-ffi-v4.properties @@ -3,11 +3,11 @@ contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 contract.minor=0 contract.hash=565f25d8a3ddf418b06a320c92284455ec4d8b82886cde8609a93bbb2486c3a4 -product.coordinate_digest=f81db525a0228782530799911879fb55cb25e8e631d09605fd5084e9bd88fbbe +product.coordinate_digest=93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223 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.provenance_digest=db238195b4a5938a8d4d9ac5681c4b125e65c57aa8133ad03e59da4e4bd062bc source.foundation_baseline=a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb diff --git a/core/crates/harvestcircle_ffi/Cargo.toml b/core/crates/harvestcircle_ffi/Cargo.toml @@ -27,6 +27,7 @@ tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", uniffi = "=0.32.0" [build-dependencies] +harvestcircle_product.workspace = true quote.workspace = true sha2.workspace = true syn.workspace = true diff --git a/core/crates/harvestcircle_ffi/build.rs b/core/crates/harvestcircle_ffi/build.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; +use harvestcircle_product::{parser as product_manifest, provenance as source_provenance}; use quote::ToTokens; use sha2::{Digest, Sha256}; use syn::{ImplItem, Item, Visibility}; @@ -231,23 +232,26 @@ fn validate_baseline_inputs(baseline: &BTreeMap<String, String>) { env!("CARGO_PKG_VERSION") ); + let product_source = + fs::read_to_string(PRODUCT_MANIFEST_PATH).expect("read product manifest as UTF-8"); let product_digest = - hex_digest(&fs::read(PRODUCT_MANIFEST_PATH).expect("read product manifest")); + product_manifest::digest(&product_source).expect("canonicalize product manifest"); assert_eq!( required(baseline, "product.coordinate_digest"), product_digest ); - let provenance = fs::read(SOURCE_PROVENANCE_PATH).expect("read source provenance"); + let provenance_source = + fs::read_to_string(SOURCE_PROVENANCE_PATH).expect("read source provenance as UTF-8"); + let provenance = + source_provenance::parse(&provenance_source).expect("canonicalize source provenance"); assert_eq!( required(baseline, "source.provenance_digest"), - hex_digest(&provenance) + provenance.digest() ); - let provenance = String::from_utf8(provenance).expect("source provenance is UTF-8"); - let foundation = format!( - "foundation_baseline = \"{}\"", + assert_eq!( + provenance.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") diff --git a/core/crates/harvestcircle_product/Cargo.toml b/core/crates/harvestcircle_product/Cargo.toml @@ -15,5 +15,9 @@ include = ["build.rs", "src/**", "Cargo.toml"] [build-dependencies] sha2.workspace = true +[dependencies] +sha2.workspace = true +toml.workspace = true + [lints] workspace = true diff --git a/core/crates/harvestcircle_product/build.rs b/core/crates/harvestcircle_product/build.rs @@ -2,22 +2,17 @@ use std::fmt::Write as _; use std::fs; use std::path::PathBuf; -use sha2::{Digest, Sha256}; - #[path = "src/parser.rs"] mod parser; -use parser::{REQUIRED, parse}; +use parser::{REQUIRED, digest, parse}; 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 = Sha256::digest(source.as_bytes()) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::<String>(); + 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 { diff --git a/core/crates/harvestcircle_product/src/lib.rs b/core/crates/harvestcircle_product/src/lib.rs @@ -1,13 +1,14 @@ -//! Validated, generated HarvestCircle product coordinates. +//! Validated, generated HarvestCircle product coordinates and provenance. -include!(concat!(env!("OUT_DIR"), "/product_coordinates.rs")); +pub mod parser; +pub mod provenance; -#[cfg(test)] -mod parser; +include!(concat!(env!("OUT_DIR"), "/product_coordinates.rs")); #[cfg(test)] mod tests { - use super::parser::{REQUIRED, parse}; + use super::parser::{REQUIRED, canonicalize, digest, parse}; + use super::provenance; use super::{ DESKTOP_APPLICATION_ID, DEVELOPMENT_DATA_DIR_ENVIRONMENT, FFI_CDYLIB_NAME, KOTLIN_ROOT_NAMESPACE, PRODUCT_COORDINATE_DIGEST, PRODUCT_NAME, @@ -45,5 +46,76 @@ mod tests { assert!( parse(&source.replace("product.slug=harvestcircle", "product.slug=other")).is_err() ); + assert!(parse(&format!("\u{feff}{source}")).is_err()); + } + + #[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"); + assert_eq!( + expected, + "93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223" + ); + assert_eq!(digest(&source.replace('\n', "\r\n")).unwrap(), expected); + assert_eq!( + digest(&format!("\n# comment\n{source}\n")).unwrap(), + expected + ); + assert_eq!( + digest( + &source + .lines() + .map(|line| line.replacen('=', " = ", 1)) + .collect::<Vec<_>>() + .join("\n") + ) + .unwrap(), + expected + ); + assert!(canonicalize(&format!("\u{feff}{source}")).is_err()); + } + + #[test] + fn provenance_digest_is_semantic_and_strict() { + let source = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../provenance") + .join(format!("{}-import-v1.toml", ["stu", "dio"].concat())), + ) + .expect("read canonical provenance fixture"); + let expected = provenance::digest(&source).expect("canonical provenance digest"); + assert_eq!( + expected, + "db238195b4a5938a8d4d9ac5681c4b125e65c57aa8133ad03e59da4e4bd062bc" + ); + assert_eq!( + provenance::digest(&source.replace('\n', "\r\n")).unwrap(), + expected + ); + assert_eq!(provenance::digest(source.trim_end()).unwrap(), expected); + assert_eq!( + provenance::digest(&format!("# comment\n{source}")).unwrap(), + expected + ); + let reordered = source.replace( + "component = \"domain\"\ncommit = \"a4d7deebec3e2ce2c1daa455de6d79857839aed0\"", + "commit = \"a4d7deebec3e2ce2c1daa455de6d79857839aed0\"\ncomponent = \"domain\"", + ); + assert_eq!(provenance::digest(&reordered).unwrap(), expected); + assert!(provenance::digest(&format!("\u{feff}{source}")).is_err()); + assert!(provenance::digest(&format!("unknown = \"value\"\n{source}")).is_err()); + assert_ne!( + provenance::digest(&source.replace( + "a4d7deebec3e2ce2c1daa455de6d79857839aed0", + "b4d7deebec3e2ce2c1daa455de6d79857839aed0" + )) + .unwrap(), + expected + ); } } diff --git a/core/crates/harvestcircle_product/src/parser.rs b/core/crates/harvestcircle_product/src/parser.rs @@ -1,5 +1,7 @@ use std::collections::BTreeMap; +use sha2::{Digest, Sha256}; + pub const REQUIRED: &[(&str, &str)] = &[ ("schema", "harvestcircle.product.v1"), ("product.name", "HarvestCircle"), @@ -24,6 +26,9 @@ pub const REQUIRED: &[(&str, &str)] = &[ ]; pub fn parse(source: &str) -> Result<BTreeMap<String, String>, String> { + if source.starts_with('\u{feff}') { + return Err("product coordinates must not contain a UTF-8 BOM".to_owned()); + } let mut parsed = BTreeMap::new(); for (index, raw) in source.lines().enumerate() { let line = raw.trim(); @@ -35,7 +40,11 @@ pub fn parse(source: &str) -> Result<BTreeMap<String, String>, String> { .ok_or_else(|| format!("line {} is not key=value", index + 1))?; let key = key.trim(); let value = value.trim(); - if key.is_empty() || value.is_empty() || value.chars().any(char::is_control) { + if key.is_empty() + || value.is_empty() + || key.chars().any(char::is_control) + || value.chars().any(char::is_control) + { return Err(format!("line {} has an invalid key or value", index + 1)); } if !REQUIRED.iter().any(|(required, _)| *required == key) { @@ -58,3 +67,23 @@ pub fn parse(source: &str) -> Result<BTreeMap<String, String>, String> { } Ok(parsed) } + +pub fn canonicalize(source: &str) -> Result<String, String> { + let parsed = parse(source)?; + let mut canonical = String::new(); + for (key, _) in REQUIRED { + canonical.push_str(key); + canonical.push('='); + canonical.push_str(parsed.get(*key).expect("required coordinate was validated")); + canonical.push('\n'); + } + Ok(canonical) +} + +pub fn digest(source: &str) -> Result<String, String> { + let canonical = canonicalize(source)?; + Ok(Sha256::digest(canonical.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} diff --git a/core/crates/harvestcircle_product/src/provenance.rs b/core/crates/harvestcircle_product/src/provenance.rs @@ -0,0 +1,149 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use sha2::{Digest, Sha256}; +use toml::Value; + +const ROOT_KEYS: &[&str] = &[ + "schema", + "source_product", + "source_repository", + "foundation_baseline", + "canonical_radroots_repository", + "canonical_radroots_revision", +]; +const IMPORT_KEYS: &[&str] = &["component", "commit"]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SourceProvenance { + root: BTreeMap<String, String>, + imports: Vec<BTreeMap<String, String>>, +} + +impl SourceProvenance { + pub fn foundation_baseline(&self) -> &str { + self.root + .get("foundation_baseline") + .expect("validated provenance has a foundation baseline") + } + + pub fn canonical(&self) -> String { + let mut canonical = String::new(); + for key in ROOT_KEYS { + canonical.push_str(key); + canonical.push('='); + canonical.push_str(self.root.get(*key).expect("validated provenance root key")); + canonical.push('\n'); + } + for import in &self.imports { + canonical.push_str("import.component="); + canonical.push_str(import.get("component").expect("validated import component")); + canonical.push('\n'); + canonical.push_str("import.commit="); + canonical.push_str(import.get("commit").expect("validated import commit")); + canonical.push('\n'); + } + canonical + } + + pub fn digest(&self) -> String { + Sha256::digest(self.canonical().as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } +} + +pub fn parse(source: &str) -> Result<SourceProvenance, String> { + if source.starts_with('\u{feff}') { + return Err("source provenance must not contain a UTF-8 BOM".to_owned()); + } + let table = toml::from_str::<toml::Table>(source).map_err(|error| error.to_string())?; + let expected_root = ROOT_KEYS + .iter() + .copied() + .chain(std::iter::once("import")) + .collect::<BTreeSet<_>>(); + let actual_root = table.keys().map(String::as_str).collect::<BTreeSet<_>>(); + if actual_root != expected_root { + return Err("source provenance root keys do not match the contract".to_owned()); + } + + let mut root = BTreeMap::new(); + for key in ROOT_KEYS { + let value = required_string(&table, key)?; + validate_public_value(key, value)?; + root.insert((*key).to_owned(), value.to_owned()); + } + if root.get("schema").map(String::as_str) != Some("harvestcircle.source_provenance.v1") { + return Err("source provenance schema is not supported".to_owned()); + } + for key in ["foundation_baseline", "canonical_radroots_revision"] { + if !is_lower_hex(root.get(key).expect("required revision"), 40) { + return Err(format!( + "source provenance {key} is not a canonical revision" + )); + } + } + + let import_values = table + .get("import") + .and_then(Value::as_array) + .ok_or_else(|| "source provenance imports must be an array of tables".to_owned())?; + if import_values.is_empty() { + return Err("source provenance imports must not be empty".to_owned()); + } + let expected_import = IMPORT_KEYS.iter().copied().collect::<BTreeSet<_>>(); + let mut components = BTreeSet::new(); + let mut imports = Vec::new(); + for value in import_values { + let import = value + .as_table() + .ok_or_else(|| "source provenance import must be a table".to_owned())?; + let actual = import.keys().map(String::as_str).collect::<BTreeSet<_>>(); + if actual != expected_import { + return Err("source provenance import keys do not match the contract".to_owned()); + } + let component = required_string(import, "component")?; + let commit = required_string(import, "commit")?; + validate_public_value("component", component)?; + if !is_lower_hex(commit, 40) { + return Err("source provenance import commit is not canonical".to_owned()); + } + if !components.insert(component.to_owned()) { + return Err(format!("duplicate source provenance component {component}")); + } + imports.push(BTreeMap::from([ + ("component".to_owned(), component.to_owned()), + ("commit".to_owned(), commit.to_owned()), + ])); + } + imports.sort_by(|left, right| left.get("component").cmp(&right.get("component"))); + Ok(SourceProvenance { root, imports }) +} + +pub fn digest(source: &str) -> Result<String, String> { + Ok(parse(source)?.digest()) +} + +fn required_string<'a>(table: &'a toml::Table, key: &str) -> Result<&'a str, String> { + table + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| format!("source provenance {key} must be a string")) +} + +fn validate_public_value(key: &str, value: &str) -> Result<(), String> { + if value.is_empty() || value.chars().any(char::is_control) { + return Err(format!( + "source provenance {key} is empty or contains a control character" + )); + } + Ok(()) +} + +fn is_lower_hex(value: &str, width: usize) -> bool { + value.len() == width + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +}