build.rs (10299B)
1 use std::collections::{BTreeMap, BTreeSet}; 2 use std::fs; 3 use std::path::{Path, PathBuf}; 4 5 use harvestcircle_product::{parser as product_manifest, provenance as source_provenance}; 6 use quote::ToTokens; 7 use sha2::{Digest, Sha256}; 8 use syn::{ImplItem, Item, Visibility}; 9 10 const CONTRACT_SOURCES: &[&str] = &[ 11 "src/commands.rs", 12 "src/contract.rs", 13 "src/dto.rs", 14 "src/lib.rs", 15 "src/observer.rs", 16 ]; 17 const BASELINE_PATH: &str = "../../compatibility/harvestcircle-ffi-v4.properties"; 18 const PRODUCT_MANIFEST_PATH: &str = "../../../config/product/harvestcircle-v1.properties"; 19 const SOURCE_PROVENANCE_PATH: &str = "../../provenance/harvestcircle-v1.toml"; 20 const STORAGE_CONTRACT_PATH: &str = "../harvestcircle_storage/src/contract.rs"; 21 const BASELINE_KEYS: &[&str] = &[ 22 "schema", 23 "contract.id", 24 "contract.major", 25 "contract.minor", 26 "contract.hash", 27 "product.coordinate_digest", 28 "snapshot.schema", 29 "storage.schema.minimum", 30 "storage.schema.current", 31 "product.version", 32 "package.version", 33 "source.provenance_digest", 34 "source.foundation_baseline", 35 ]; 36 37 fn main() { 38 for name in [ 39 "HARVESTCIRCLE_BUILD_SOURCE_COMMIT", 40 "HARVESTCIRCLE_BUILD_SOURCE_DIRTY", 41 "HARVESTCIRCLE_BUILD_RADROOTS_REVISION", 42 "HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN", 43 "HARVESTCIRCLE_BUILD_JAVA_TOOLCHAIN", 44 "HARVESTCIRCLE_BUILD_KOTLIN_TOOLCHAIN", 45 "SOURCE_DATE_EPOCH", 46 ] { 47 println!("cargo:rerun-if-env-changed={name}"); 48 } 49 for source in CONTRACT_SOURCES { 50 println!("cargo:rerun-if-changed={source}"); 51 } 52 println!("cargo:rerun-if-changed={STORAGE_CONTRACT_PATH}"); 53 println!("cargo:rerun-if-changed={BASELINE_PATH}"); 54 println!("cargo:rerun-if-changed={PRODUCT_MANIFEST_PATH}"); 55 println!("cargo:rerun-if-changed={SOURCE_PROVENANCE_PATH}"); 56 57 let baseline = parse_baseline( 58 &fs::read_to_string(BASELINE_PATH).expect("read HarvestCircle FFI baseline"), 59 ); 60 validate_baseline_inputs(&baseline); 61 62 let mut metadata = Vec::new(); 63 for source in CONTRACT_SOURCES { 64 collect_public_metadata(Path::new(source), &mut metadata); 65 } 66 metadata.push(format!( 67 "storage-contract:{}", 68 hex_digest(&fs::read(STORAGE_CONTRACT_PATH).expect("read storage contract")) 69 )); 70 metadata.sort(); 71 metadata.dedup(); 72 let normalized = metadata.join("\n"); 73 let contract_digest = hex_digest(normalized.as_bytes()); 74 assert_eq!( 75 required(&baseline, "contract.hash"), 76 contract_digest, 77 "HarvestCircle FFI baseline hash is stale" 78 ); 79 emit( 80 "HARVESTCIRCLE_FFI_CONTRACT_ID", 81 required(&baseline, "contract.id"), 82 ); 83 emit( 84 "HARVESTCIRCLE_PRODUCT_VERSION", 85 required(&baseline, "product.version"), 86 ); 87 emit( 88 "HARVESTCIRCLE_PACKAGE_VERSION", 89 required(&baseline, "package.version"), 90 ); 91 emit( 92 "HARVESTCIRCLE_PRODUCT_COORDINATE_DIGEST", 93 required(&baseline, "product.coordinate_digest"), 94 ); 95 emit( 96 "HARVESTCIRCLE_SOURCE_PROVENANCE_DIGEST", 97 required(&baseline, "source.provenance_digest"), 98 ); 99 emit( 100 "HARVESTCIRCLE_SOURCE_FOUNDATION_BASELINE", 101 required(&baseline, "source.foundation_baseline"), 102 ); 103 emit("HARVESTCIRCLE_FFI_CONTRACT_DIGEST", &contract_digest); 104 let provenance_source = 105 fs::read_to_string(SOURCE_PROVENANCE_PATH).expect("read source provenance as UTF-8"); 106 let provenance = 107 source_provenance::parse(&provenance_source).expect("canonicalize source provenance"); 108 let mut build_provenance = Vec::new(); 109 for (output, input, default) in [ 110 ( 111 "HARVESTCIRCLE_BUILD_SOURCE_COMMIT", 112 "HARVESTCIRCLE_BUILD_SOURCE_COMMIT", 113 provenance.foundation_baseline(), 114 ), 115 ( 116 "HARVESTCIRCLE_BUILD_SOURCE_DIRTY", 117 "HARVESTCIRCLE_BUILD_SOURCE_DIRTY", 118 "unknown", 119 ), 120 ( 121 "HARVESTCIRCLE_BUILD_RADROOTS_REVISION", 122 "HARVESTCIRCLE_BUILD_RADROOTS_REVISION", 123 provenance.canonical_radroots_revision(), 124 ), 125 ( 126 "HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN", 127 "HARVESTCIRCLE_BUILD_RUST_TOOLCHAIN", 128 "1.97.1", 129 ), 130 ( 131 "HARVESTCIRCLE_BUILD_JAVA_TOOLCHAIN", 132 "HARVESTCIRCLE_BUILD_JAVA_TOOLCHAIN", 133 "unknown", 134 ), 135 ( 136 "HARVESTCIRCLE_BUILD_KOTLIN_TOOLCHAIN", 137 "HARVESTCIRCLE_BUILD_KOTLIN_TOOLCHAIN", 138 "unknown", 139 ), 140 ( 141 "HARVESTCIRCLE_BUILD_SOURCE_DATE_EPOCH", 142 "SOURCE_DATE_EPOCH", 143 "0", 144 ), 145 ] { 146 let value = std::env::var(input).unwrap_or_else(|_| default.to_owned()); 147 assert_build_value(input, &value); 148 emit(output, &value); 149 build_provenance.push(format!("{input}={value}")); 150 } 151 emit( 152 "HARVESTCIRCLE_BUILD_PROVENANCE_DIGEST", 153 &hex_digest(build_provenance.join("\n").as_bytes()), 154 ); 155 fs::write( 156 PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")) 157 .join("ffi_contract_metadata.txt"), 158 normalized, 159 ) 160 .expect("write normalized FFI metadata"); 161 } 162 163 fn assert_build_value(name: &str, value: &str) { 164 assert!(!value.is_empty() && value.len() <= 128, "invalid {name}"); 165 assert!( 166 value.bytes().all(|byte| byte.is_ascii_graphic()), 167 "invalid {name}" 168 ); 169 if name == "HARVESTCIRCLE_BUILD_SOURCE_DIRTY" { 170 assert!( 171 matches!(value, "true" | "false" | "unknown"), 172 "invalid {name}" 173 ); 174 } 175 if name == "SOURCE_DATE_EPOCH" { 176 let _: u64 = value 177 .parse() 178 .expect("SOURCE_DATE_EPOCH must be an unsigned integer"); 179 } 180 } 181 182 fn parse_baseline(source: &str) -> BTreeMap<String, String> { 183 let mut values = BTreeMap::new(); 184 for (index, raw_line) in source.lines().enumerate() { 185 let line = raw_line.trim(); 186 if line.is_empty() || line.starts_with('#') { 187 continue; 188 } 189 let (key, value) = line 190 .split_once('=') 191 .unwrap_or_else(|| panic!("invalid FFI baseline line {}", index + 1)); 192 let key = key.trim(); 193 let value = value.trim(); 194 assert!( 195 !key.is_empty() && !value.is_empty(), 196 "empty FFI baseline entry" 197 ); 198 assert!( 199 values.insert(key.to_owned(), value.to_owned()).is_none(), 200 "duplicate FFI baseline key: {key}" 201 ); 202 } 203 let actual = values.keys().map(String::as_str).collect::<BTreeSet<_>>(); 204 let expected = BASELINE_KEYS.iter().copied().collect::<BTreeSet<_>>(); 205 assert_eq!( 206 actual, expected, 207 "FFI baseline keys differ from the contract" 208 ); 209 values 210 } 211 212 fn validate_baseline_inputs(baseline: &BTreeMap<String, String>) { 213 assert_eq!(required(baseline, "schema"), "harvestcircle.ffi.v4"); 214 assert_eq!( 215 required(baseline, "contract.id"), 216 "harvestcircle-desktop-ffi-v4" 217 ); 218 assert_eq!(required(baseline, "contract.major"), "4"); 219 assert_eq!(required(baseline, "contract.minor"), "5"); 220 assert_eq!(required(baseline, "snapshot.schema"), "1"); 221 assert_eq!(required(baseline, "storage.schema.minimum"), "1"); 222 assert_eq!(required(baseline, "storage.schema.current"), "3"); 223 assert_eq!( 224 required(baseline, "product.version"), 225 env!("CARGO_PKG_VERSION") 226 ); 227 228 let product_source = 229 fs::read_to_string(PRODUCT_MANIFEST_PATH).expect("read product manifest as UTF-8"); 230 let product_digest = 231 product_manifest::digest(&product_source).expect("canonicalize product manifest"); 232 assert_eq!( 233 required(baseline, "product.coordinate_digest"), 234 product_digest 235 ); 236 let provenance_source = 237 fs::read_to_string(SOURCE_PROVENANCE_PATH).expect("read source provenance as UTF-8"); 238 let provenance = 239 source_provenance::parse(&provenance_source).expect("canonicalize source provenance"); 240 assert_eq!( 241 required(baseline, "source.provenance_digest"), 242 provenance.digest() 243 ); 244 assert_eq!( 245 provenance.foundation_baseline(), 246 required(baseline, "source.foundation_baseline") 247 ); 248 249 assert_eq!(required(baseline, "storage.schema.current"), "3"); 250 } 251 252 fn required<'a>(baseline: &'a BTreeMap<String, String>, key: &str) -> &'a str { 253 baseline 254 .get(key) 255 .unwrap_or_else(|| panic!("missing FFI baseline key: {key}")) 256 .as_str() 257 } 258 259 fn emit(name: &str, value: &str) { 260 println!("cargo:rustc-env={name}={value}"); 261 } 262 263 fn collect_public_metadata(path: &Path, output: &mut Vec<String>) { 264 let source = fs::read_to_string(path).expect("read FFI source"); 265 let file = syn::parse_file(&source).expect("parse FFI source"); 266 for item in file.items { 267 match item { 268 Item::Const(item) if is_public(&item.vis) => push_tokens("const", item, output), 269 Item::Enum(item) if is_public(&item.vis) => push_tokens("enum", item, output), 270 Item::Fn(item) if is_public(&item.vis) => push_tokens("fn", item.sig, output), 271 Item::Struct(item) if is_public(&item.vis) => push_tokens("struct", item, output), 272 Item::Trait(item) if is_public(&item.vis) => push_tokens("trait", item, output), 273 Item::Impl(item) => { 274 let owner = item.self_ty.to_token_stream().to_string(); 275 for member in item.items { 276 if let ImplItem::Fn(function) = member 277 && is_public(&function.vis) 278 { 279 output.push(format!("method:{owner}:{}", function.sig.to_token_stream())); 280 } 281 } 282 } 283 _ => {} 284 } 285 } 286 } 287 288 fn push_tokens(kind: &str, value: impl ToTokens, output: &mut Vec<String>) { 289 output.push(format!("{kind}:{}", value.to_token_stream())); 290 } 291 292 const fn is_public(visibility: &Visibility) -> bool { 293 matches!(visibility, Visibility::Public(_)) 294 } 295 296 fn hex_digest(bytes: &[u8]) -> String { 297 Sha256::digest(bytes) 298 .iter() 299 .map(|byte| format!("{byte:02x}")) 300 .collect() 301 }