contracts.rs (23035B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 fs, 4 path::{Path, PathBuf}, 5 }; 6 7 use serde::Deserialize; 8 9 use crate::sdk_generation::package_matrix::{ 10 PackageSpec, WasmPackageSpec, package_specs, wasm_package_specs, 11 }; 12 13 #[derive(Debug, Deserialize)] 14 #[serde(deny_unknown_fields)] 15 struct ExportContract { 16 language: LanguageContract, 17 packages: BTreeMap<String, String>, 18 artifacts: Option<ExportArtifacts>, 19 runtime: RuntimeContract, 20 } 21 22 #[derive(Debug, Deserialize)] 23 #[serde(deny_unknown_fields)] 24 struct PackageContract { 25 language: LanguageContract, 26 sdk: SdkPackageContract, 27 rollout: RolloutContract, 28 operations: BTreeMap<String, String>, 29 shared_types: BTreeMap<String, String>, 30 artifacts: Option<SdkArtifacts>, 31 npm_packages: Option<BTreeMap<String, NpmPackageContract>>, 32 } 33 34 #[derive(Debug, Deserialize)] 35 #[serde(deny_unknown_fields)] 36 struct LanguageContract { 37 id: String, 38 repository: String, 39 } 40 41 #[derive(Debug, Deserialize)] 42 #[serde(deny_unknown_fields)] 43 struct RuntimeContract { 44 networking: String, 45 signing: String, 46 deterministic_codec: String, 47 } 48 49 #[derive(Debug, Deserialize)] 50 #[serde(deny_unknown_fields)] 51 struct ExportArtifacts { 52 models_dir: String, 53 constants_dir: String, 54 wasm_dist_dir: Option<String>, 55 manifest_file: String, 56 } 57 58 #[derive(Debug, Deserialize)] 59 #[serde(deny_unknown_fields)] 60 struct SdkPackageContract { 61 package: Option<String>, 62 package_family: Option<String>, 63 module_format: Option<String>, 64 deterministic_codec: String, 65 signing: String, 66 networking: String, 67 } 68 69 #[derive(Debug, Deserialize)] 70 #[serde(deny_unknown_fields)] 71 struct NpmPackageContract { 72 kind: String, 73 crate_name: String, 74 crate_dir: String, 75 package: String, 76 package_dir: String, 77 out_name: Option<String>, 78 out_dir: Option<String>, 79 } 80 81 #[derive(Debug)] 82 struct ExpectedNpmPackage { 83 kind: &'static str, 84 crate_name: &'static str, 85 crate_dir: &'static str, 86 package: &'static str, 87 package_dir: &'static str, 88 out_name: Option<&'static str>, 89 out_dir: Option<&'static str>, 90 } 91 92 #[derive(Debug, Deserialize)] 93 #[serde(deny_unknown_fields)] 94 struct RolloutContract { 95 stage: String, 96 order: u32, 97 } 98 99 #[derive(Debug, Deserialize)] 100 #[serde(deny_unknown_fields)] 101 struct SdkArtifacts { 102 models_dir: String, 103 runtime_dir: String, 104 wasm_dist_dir: String, 105 manifest_file: String, 106 } 107 108 pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> { 109 radroots_protocol::runtime::v1::validate_catalog(radroots_protocol::runtime::v1::CATALOG) 110 .map_err(|error| error.to_string())?; 111 let exports = load_contract_dir::<ExportContract>(&root.join("contracts").join("exports"))?; 112 let packages = load_contract_dir::<PackageContract>(&root.join("contracts").join("packages"))?; 113 if exports.is_empty() { 114 return Err("contracts/exports must define at least one language".to_owned()); 115 } 116 if packages.is_empty() { 117 return Err("contracts/packages must define at least one language".to_owned()); 118 } 119 120 let mut export_packages = BTreeMap::new(); 121 let mut export_languages = BTreeSet::new(); 122 for export in &exports { 123 validate_language(&export.language, "exports")?; 124 validate_non_empty_map(&export.packages, "exports packages")?; 125 validate_runtime( 126 &export.runtime.networking, 127 &export.runtime.signing, 128 &export.runtime.deterministic_codec, 129 &format!("exports {}", export.language.id), 130 )?; 131 let artifacts = export 132 .artifacts 133 .as_ref() 134 .ok_or_else(|| format!("exports {} artifacts are required", export.language.id))?; 135 validate_non_empty(&artifacts.models_dir, "exports artifacts.models_dir")?; 136 validate_non_empty(&artifacts.constants_dir, "exports artifacts.constants_dir")?; 137 validate_non_empty(&artifacts.manifest_file, "exports artifacts.manifest_file")?; 138 if export.language.id == "ts" { 139 validate_non_empty( 140 artifacts.wasm_dist_dir.as_deref().unwrap_or(""), 141 "exports ts artifacts.wasm_dist_dir", 142 )?; 143 } 144 if !export_languages.insert(export.language.id.clone()) { 145 return Err(format!("duplicate exports language {}", export.language.id)); 146 } 147 let packages = export 148 .packages 149 .values() 150 .cloned() 151 .collect::<BTreeSet<String>>(); 152 if export.language.id != "ts" && packages.len() != 1 { 153 return Err(format!( 154 "exports {} must resolve to one curated package", 155 export.language.id 156 )); 157 } 158 export_packages.insert(export.language.id.clone(), packages); 159 } 160 161 let mut package_languages = BTreeSet::new(); 162 let mut operation_keys: Option<BTreeSet<String>> = None; 163 let mut shared_type_keys: Option<BTreeSet<String>> = None; 164 let mut rollout_orders = BTreeMap::new(); 165 for package in &packages { 166 validate_language(&package.language, "packages")?; 167 if let Some(package_name) = package.sdk.package.as_deref() { 168 validate_non_empty(package_name, "packages sdk.package")?; 169 } 170 if let Some(package_family) = package.sdk.package_family.as_deref() { 171 validate_non_empty(package_family, "packages sdk.package_family")?; 172 } 173 validate_runtime( 174 &package.sdk.networking, 175 &package.sdk.signing, 176 &package.sdk.deterministic_codec, 177 &format!("packages {}", package.language.id), 178 )?; 179 if let Some(module_format) = package.sdk.module_format.as_deref() { 180 validate_non_empty(module_format, "packages sdk.module_format")?; 181 } 182 if package.sdk.package.is_none() && package.sdk.package_family.is_none() { 183 return Err(format!( 184 "packages {} sdk.package or sdk.package_family is required", 185 package.language.id 186 )); 187 } 188 validate_rollout(&package.language.id, &package.rollout)?; 189 validate_non_empty_map(&package.operations, "packages operations")?; 190 validate_non_empty_map(&package.shared_types, "packages shared_types")?; 191 if package.language.id == "ts" { 192 let artifacts = package 193 .artifacts 194 .as_ref() 195 .ok_or_else(|| "packages ts artifacts are required".to_owned())?; 196 validate_non_empty(&artifacts.models_dir, "packages ts artifacts.models_dir")?; 197 validate_non_empty(&artifacts.runtime_dir, "packages ts artifacts.runtime_dir")?; 198 validate_non_empty( 199 &artifacts.wasm_dist_dir, 200 "packages ts artifacts.wasm_dist_dir", 201 )?; 202 validate_non_empty( 203 &artifacts.manifest_file, 204 "packages ts artifacts.manifest_file", 205 )?; 206 } 207 if !package_languages.insert(package.language.id.clone()) { 208 return Err(format!( 209 "duplicate packages language {}", 210 package.language.id 211 )); 212 } 213 let Some(packages_for_language) = export_packages.get(&package.language.id) else { 214 return Err(format!( 215 "packages {} is missing a matching export contract", 216 package.language.id 217 )); 218 }; 219 if package.language.id == "ts" { 220 let npm_packages = package 221 .npm_packages 222 .as_ref() 223 .ok_or_else(|| "packages ts npm_packages are required".to_owned())?; 224 validate_ts_npm_packages(package, packages_for_language, npm_packages)?; 225 } else { 226 if package.npm_packages.is_some() { 227 return Err(format!( 228 "packages {} npm_packages is only supported for ts", 229 package.language.id 230 )); 231 } 232 let sdk_package = package.sdk.package.as_ref().ok_or_else(|| { 233 format!("packages {} sdk.package is required", package.language.id) 234 })?; 235 let expected = [sdk_package.clone()].into_iter().collect::<BTreeSet<_>>(); 236 if packages_for_language != &expected { 237 return Err(format!( 238 "exports {} must resolve to package {}", 239 package.language.id, sdk_package 240 )); 241 } 242 } 243 let current_operations = package.operations.keys().cloned().collect::<BTreeSet<_>>(); 244 match &operation_keys { 245 Some(expected) if expected != ¤t_operations => { 246 return Err(format!( 247 "packages {} operations must match the shared operation set", 248 package.language.id 249 )); 250 } 251 None => operation_keys = Some(current_operations), 252 _ => {} 253 } 254 let current_shared_types = package 255 .shared_types 256 .keys() 257 .cloned() 258 .collect::<BTreeSet<_>>(); 259 match &shared_type_keys { 260 Some(expected) if expected != ¤t_shared_types => { 261 return Err(format!( 262 "packages {} shared_types must match the shared type set", 263 package.language.id 264 )); 265 } 266 None => shared_type_keys = Some(current_shared_types), 267 _ => {} 268 } 269 rollout_orders.insert(package.language.id.clone(), package.rollout.order); 270 } 271 272 if export_languages != package_languages { 273 return Err("contracts/exports and contracts/packages languages must match".to_owned()); 274 } 275 if rollout_orders.get("ts") != Some(&1) { 276 return Err("packages ts rollout.order must be 1".to_owned()); 277 } 278 Ok(()) 279 } 280 281 fn validate_ts_npm_packages( 282 package: &PackageContract, 283 export_packages: &BTreeSet<String>, 284 npm_packages: &BTreeMap<String, NpmPackageContract>, 285 ) -> Result<(), String> { 286 if package.sdk.package.is_some() { 287 return Err( 288 "packages ts sdk.package must not be set for multi-package npm output".to_owned(), 289 ); 290 } 291 let package_family = package 292 .sdk 293 .package_family 294 .as_deref() 295 .ok_or_else(|| "packages ts sdk.package_family is required".to_owned())?; 296 if package_family != "@radroots" { 297 return Err("packages ts sdk.package_family must be @radroots".to_owned()); 298 } 299 let expected = expected_ts_npm_packages(); 300 let expected_keys = expected.keys().cloned().collect::<BTreeSet<_>>(); 301 let actual_keys = npm_packages.keys().cloned().collect::<BTreeSet<_>>(); 302 if actual_keys != expected_keys { 303 let missing = expected_keys 304 .difference(&actual_keys) 305 .cloned() 306 .collect::<Vec<_>>(); 307 let extra = actual_keys 308 .difference(&expected_keys) 309 .cloned() 310 .collect::<Vec<_>>(); 311 return Err(format!( 312 "packages ts npm_packages must match package matrix: missing {:?}, extra {:?}", 313 missing, extra 314 )); 315 } 316 for (key, expected_package) in expected { 317 let actual = npm_packages 318 .get(&key) 319 .ok_or_else(|| format!("packages ts npm package {key} is missing"))?; 320 validate_npm_package(&key, actual, &expected_package)?; 321 } 322 let expected_names = npm_packages 323 .values() 324 .map(|package| package.package.clone()) 325 .collect::<BTreeSet<_>>(); 326 if export_packages != &expected_names { 327 return Err("exports ts package set must match TypeScript npm package matrix".to_owned()); 328 } 329 Ok(()) 330 } 331 332 fn expected_ts_npm_packages() -> BTreeMap<String, ExpectedNpmPackage> { 333 let mut expected = BTreeMap::new(); 334 for spec in package_specs() { 335 expected.insert(spec.key.to_owned(), expected_from_package_spec(*spec)); 336 } 337 for spec in wasm_package_specs() { 338 expected.insert(spec.key.to_owned(), expected_from_wasm_package_spec(*spec)); 339 } 340 expected 341 } 342 343 fn expected_from_package_spec(spec: PackageSpec) -> ExpectedNpmPackage { 344 ExpectedNpmPackage { 345 kind: "bindings", 346 crate_name: spec.crate_name, 347 crate_dir: spec.crate_dir, 348 package: spec.package_name, 349 package_dir: spec.package_dir, 350 out_name: None, 351 out_dir: None, 352 } 353 } 354 355 fn expected_from_wasm_package_spec(spec: WasmPackageSpec) -> ExpectedNpmPackage { 356 ExpectedNpmPackage { 357 kind: "wasm", 358 crate_name: spec.crate_name, 359 crate_dir: spec.crate_dir, 360 package: spec.package_name, 361 package_dir: spec.package_dir, 362 out_name: Some(spec.out_name), 363 out_dir: Some(spec.out_dir), 364 } 365 } 366 367 fn validate_npm_package( 368 key: &str, 369 actual: &NpmPackageContract, 370 expected: &ExpectedNpmPackage, 371 ) -> Result<(), String> { 372 validate_npm_field(key, "kind", &actual.kind, expected.kind)?; 373 validate_npm_field(key, "crate_name", &actual.crate_name, expected.crate_name)?; 374 validate_npm_field(key, "crate_dir", &actual.crate_dir, expected.crate_dir)?; 375 validate_npm_field(key, "package", &actual.package, expected.package)?; 376 validate_npm_field( 377 key, 378 "package_dir", 379 &actual.package_dir, 380 expected.package_dir, 381 )?; 382 validate_optional_npm_field( 383 key, 384 "out_name", 385 actual.out_name.as_deref(), 386 expected.out_name, 387 )?; 388 validate_optional_npm_field(key, "out_dir", actual.out_dir.as_deref(), expected.out_dir)?; 389 Ok(()) 390 } 391 392 fn validate_npm_field(key: &str, field: &str, actual: &str, expected: &str) -> Result<(), String> { 393 validate_non_empty(actual, &format!("packages ts npm package {key} {field}"))?; 394 if actual != expected { 395 return Err(format!( 396 "packages ts npm package {key} {field} must be {expected}" 397 )); 398 } 399 Ok(()) 400 } 401 402 fn validate_optional_npm_field( 403 key: &str, 404 field: &str, 405 actual: Option<&str>, 406 expected: Option<&str>, 407 ) -> Result<(), String> { 408 match (actual, expected) { 409 (Some(actual), Some(expected)) => validate_npm_field(key, field, actual, expected), 410 (None, None) => Ok(()), 411 (Some(_), None) => Err(format!( 412 "packages ts npm package {key} {field} must not be set" 413 )), 414 (None, Some(expected)) => Err(format!( 415 "packages ts npm package {key} {field} must be {expected}" 416 )), 417 } 418 } 419 420 fn load_contract_dir<T>(dir: &Path) -> Result<Vec<T>, String> 421 where 422 T: for<'de> Deserialize<'de>, 423 { 424 let read_dir = 425 fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?; 426 let mut entries = read_dir 427 .collect::<Result<Vec<_>, _>>() 428 .map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; 429 entries.sort_by_key(|entry| entry.file_name()); 430 let mut contracts = Vec::new(); 431 for entry in entries { 432 let path = entry.path(); 433 if path.extension().and_then(|extension| extension.to_str()) != Some("toml") { 434 continue; 435 } 436 contracts.push(parse_toml(&path)?); 437 } 438 Ok(contracts) 439 } 440 441 fn parse_toml<T>(path: &PathBuf) -> Result<T, String> 442 where 443 T: for<'de> Deserialize<'de>, 444 { 445 let raw = fs::read_to_string(path) 446 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 447 toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) 448 } 449 450 fn validate_language(language: &LanguageContract, family: &str) -> Result<(), String> { 451 validate_non_empty(&language.id, &format!("{family} language.id"))?; 452 validate_non_empty( 453 &language.repository, 454 &format!("{family} language.repository"), 455 ) 456 } 457 458 fn validate_runtime( 459 networking: &str, 460 signing: &str, 461 deterministic_codec: &str, 462 family: &str, 463 ) -> Result<(), String> { 464 validate_non_empty(networking, &format!("{family} networking"))?; 465 validate_non_empty(signing, &format!("{family} signing"))?; 466 validate_non_empty( 467 deterministic_codec, 468 &format!("{family} deterministic_codec"), 469 ) 470 } 471 472 fn validate_rollout(language: &str, rollout: &RolloutContract) -> Result<(), String> { 473 validate_non_empty(&rollout.stage, "packages rollout.stage")?; 474 if !matches!(rollout.stage.as_str(), "active" | "next" | "deferred") { 475 return Err(format!("packages {language} rollout.stage is invalid")); 476 } 477 if rollout.order == 0 { 478 return Err(format!( 479 "packages {language} rollout.order must be greater than zero" 480 )); 481 } 482 Ok(()) 483 } 484 485 fn validate_non_empty(value: &str, field: &str) -> Result<(), String> { 486 if value.trim().is_empty() || value.trim() != value { 487 return Err(format!("{field} must be non-empty")); 488 } 489 Ok(()) 490 } 491 492 fn validate_non_empty_map(map: &BTreeMap<String, String>, field: &str) -> Result<(), String> { 493 if map.is_empty() { 494 return Err(format!("{field} must not be empty")); 495 } 496 for (key, value) in map { 497 validate_non_empty(key, field)?; 498 validate_non_empty(value, field)?; 499 } 500 Ok(()) 501 } 502 503 #[cfg(test)] 504 mod tests { 505 use std::{ 506 fs, 507 time::{SystemTime, UNIX_EPOCH}, 508 }; 509 510 use super::validate_sdk_contracts; 511 512 #[test] 513 fn rejects_mismatched_language_sets() { 514 let root = test_root("language_mismatch"); 515 write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS); 516 let error = validate_sdk_contracts(&root).expect_err("missing packages should fail"); 517 assert!(error.contains("contracts/packages")); 518 let _ = fs::remove_dir_all(root); 519 } 520 521 #[test] 522 fn rejects_package_export_mismatch() { 523 let root = test_root("package_mismatch"); 524 write_contract( 525 &root, 526 "contracts/exports/ts.toml", 527 EXPORT_TS 528 .replace("@radroots/core-bindings", "@radroots/other") 529 .as_str(), 530 ); 531 write_contract(&root, "contracts/packages/ts.toml", PACKAGE_TS); 532 let error = validate_sdk_contracts(&root).expect_err("mismatch should fail"); 533 assert!(error.contains("exports ts package set")); 534 let _ = fs::remove_dir_all(root); 535 } 536 537 #[test] 538 fn rejects_ts_package_matrix_drift() { 539 let root = test_root("ts_matrix_drift"); 540 write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS); 541 write_contract( 542 &root, 543 "contracts/packages/ts.toml", 544 PACKAGE_TS 545 .replace( 546 "package = \"@radroots/core-bindings\"", 547 "package = \"@radroots/other\"", 548 ) 549 .as_str(), 550 ); 551 let error = validate_sdk_contracts(&root).expect_err("mismatch should fail"); 552 assert!(error.contains("packages ts npm package core package")); 553 let _ = fs::remove_dir_all(root); 554 } 555 556 fn test_root(name: &str) -> std::path::PathBuf { 557 let stamp = SystemTime::now() 558 .duration_since(UNIX_EPOCH) 559 .expect("time") 560 .as_nanos(); 561 std::env::temp_dir().join(format!("radroots_sdk_contracts_{name}_{stamp}")) 562 } 563 564 fn write_contract(root: &std::path::Path, relative: &str, contents: &str) { 565 let path = root.join(relative); 566 fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); 567 fs::write(path, contents).expect("write contract"); 568 } 569 570 const EXPORT_TS: &str = r#"[language] 571 id = "ts" 572 repository = "sdk-typescript" 573 574 [packages] 575 "radroots_core" = "@radroots/core-bindings" 576 "radroots_event" = "@radroots/event-bindings" 577 "radroots_identity" = "@radroots/identity-bindings" 578 "radroots_replica_schema" = "@radroots/replica-schema-bindings" 579 "radroots_trade" = "@radroots/trade-bindings" 580 "radroots_event_codec_wasm" = "@radroots/event-codec-wasm" 581 "radroots_replica_store_wasm" = "@radroots/replica-store-wasm" 582 "radroots_replica_sync_wasm" = "@radroots/replica-sync-wasm" 583 584 [artifacts] 585 models_dir = "src/generated" 586 constants_dir = "src/generated" 587 wasm_dist_dir = "dist" 588 manifest_file = "export-manifest.json" 589 590 [runtime] 591 networking = "native" 592 signing = "native" 593 deterministic_codec = "wasm" 594 "#; 595 596 const PACKAGE_TS: &str = r#"[language] 597 id = "ts" 598 repository = "sdk-typescript" 599 600 [sdk] 601 package_family = "@radroots" 602 module_format = "esm" 603 deterministic_codec = "wasm" 604 signing = "native" 605 networking = "native" 606 607 [npm_packages.core] 608 kind = "bindings" 609 crate_name = "radroots_core_bindings" 610 crate_dir = "crates/core_bindings" 611 package = "@radroots/core-bindings" 612 package_dir = "packages/core-bindings" 613 614 [npm_packages.event] 615 kind = "bindings" 616 crate_name = "radroots_event_bindings" 617 crate_dir = "crates/event_bindings" 618 package = "@radroots/event-bindings" 619 package_dir = "packages/event-bindings" 620 621 [npm_packages.identity] 622 kind = "bindings" 623 crate_name = "radroots_identity_bindings" 624 crate_dir = "crates/identity_bindings" 625 package = "@radroots/identity-bindings" 626 package_dir = "packages/identity-bindings" 627 628 [npm_packages.replica_schema] 629 kind = "bindings" 630 crate_name = "radroots_replica_schema_bindings" 631 crate_dir = "crates/replica_schema_bindings" 632 package = "@radroots/replica-schema-bindings" 633 package_dir = "packages/replica-schema-bindings" 634 635 [npm_packages.trade] 636 kind = "bindings" 637 crate_name = "radroots_trade_bindings" 638 crate_dir = "crates/trade_bindings" 639 package = "@radroots/trade-bindings" 640 package_dir = "packages/trade-bindings" 641 642 [npm_packages.event_codec] 643 kind = "wasm" 644 crate_name = "radroots_event_codec_wasm" 645 crate_dir = "crates/event_codec_wasm" 646 package = "@radroots/event-codec-wasm" 647 package_dir = "packages/event-codec-wasm" 648 out_name = "radroots_event_codec_wasm" 649 out_dir = "../../packages/event-codec-wasm/dist" 650 651 [npm_packages.replica_store] 652 kind = "wasm" 653 crate_name = "radroots_replica_store_wasm" 654 crate_dir = "crates/replica_store_wasm" 655 package = "@radroots/replica-store-wasm" 656 package_dir = "packages/replica-store-wasm" 657 out_name = "radroots_replica_store_wasm" 658 out_dir = "../../packages/replica-store-wasm/dist" 659 660 [npm_packages.replica_sync] 661 kind = "wasm" 662 crate_name = "radroots_replica_sync_wasm" 663 crate_dir = "crates/replica_sync_wasm" 664 package = "@radroots/replica-sync-wasm" 665 package_dir = "packages/replica-sync-wasm" 666 out_name = "radroots_replica_sync_wasm" 667 out_dir = "../../packages/replica-sync-wasm/dist" 668 669 [rollout] 670 stage = "active" 671 order = 1 672 673 [operations] 674 "farm.build_draft" = "farm.buildDraft" 675 676 [shared_types] 677 "RadrootsNip01EventWireParts" = "RadrootsNip01EventWireParts" 678 "RadrootsNip01EventWireDto" = "RadrootsNip01EventWireDto" 679 "RadrootsEventDraft" = "RadrootsEventDraft" 680 "RadrootsSignedEventDto" = "RadrootsSignedEventDto" 681 "RadrootsVerifiedSignedEventDto" = "RadrootsVerifiedSignedEventDto" 682 "RadrootsEventEnvelopeDto" = "RadrootsEventEnvelopeDto" 683 684 [artifacts] 685 models_dir = "src/generated" 686 runtime_dir = "src/runtime" 687 wasm_dist_dir = "dist" 688 manifest_file = "export-manifest.json" 689 "#; 690 }