core_contract.rs (21477B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 fs, 4 path::{Path, PathBuf}, 5 }; 6 7 use syn::{Item, UseTree, Visibility}; 8 9 const ARCHITECTURE_RELATIVE: &str = "contracts/crates/release_v1/radroots_crates_release_v1.toml"; 10 const CORE_MANIFEST_RELATIVE: &str = "crates/core/Cargo.toml"; 11 const CORE_LIB_RELATIVE: &str = "crates/core/src/lib.rs"; 12 13 const CORE_RUNTIME_DEPENDENCIES: [&str; 2] = ["rust_decimal", "serde"]; 14 const CORE_DEV_DEPENDENCIES: [&str; 3] = ["dto_bindgen", "rust_decimal", "serde_json"]; 15 const CORE_ROOT_EXPORTS: [(&str, &str); 8] = [ 16 ("Currency", "currency::Currency"), 17 ("Decimal", "decimal::Decimal"), 18 ("Error", "error::Error"), 19 ("Money", "money::Money"), 20 ("Percent", "percent::Percent"), 21 ("Quantity", "quantity::Quantity"), 22 ("QuantityPrice", "pricing::QuantityPrice"), 23 ("Unit", "unit::Unit"), 24 ]; 25 pub(super) fn validate(workspace_root: &Path) -> Result<(), String> { 26 let architecture = read_toml(workspace_root, ARCHITECTURE_RELATIVE)?; 27 let core_spec = architecture 28 .get("package") 29 .and_then(toml::Value::as_array) 30 .and_then(|packages| { 31 packages.iter().find(|package| { 32 package.get("name").and_then(toml::Value::as_str) == Some("radroots_core") 33 }) 34 }) 35 .and_then(toml::Value::as_table) 36 .ok_or_else(|| format!("{ARCHITECTURE_RELATIVE} is missing radroots_core"))?; 37 38 let manifest = read_toml(workspace_root, CORE_MANIFEST_RELATIVE)?; 39 validate_manifest(core_spec, &manifest)?; 40 validate_crate_root(workspace_root, core_spec) 41 } 42 43 fn validate_manifest(core_spec: &toml::value::Table, manifest: &toml::Value) -> Result<(), String> { 44 let manifest = manifest 45 .as_table() 46 .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} must be a TOML table"))?; 47 let package = manifest 48 .get("package") 49 .and_then(toml::Value::as_table) 50 .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} is missing [package]"))?; 51 if package.get("name").and_then(toml::Value::as_str) != Some("radroots_core") { 52 return Err(format!( 53 "{CORE_MANIFEST_RELATIVE} package.name must be radroots_core" 54 )); 55 } 56 let library = manifest 57 .get("lib") 58 .and_then(toml::Value::as_table) 59 .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} is missing [lib]"))?; 60 if library.get("name").and_then(toml::Value::as_str) != Some("radroots_core") 61 || library.keys().map(String::as_str).collect::<BTreeSet<_>>() != BTreeSet::from(["name"]) 62 { 63 return Err(format!( 64 "{CORE_MANIFEST_RELATIVE} must use the conventional radroots_core library target" 65 )); 66 } 67 68 let features = manifest 69 .get("features") 70 .and_then(toml::Value::as_table) 71 .ok_or_else(|| format!("{CORE_MANIFEST_RELATIVE} is missing [features]"))?; 72 let expected_features = spec_strings(core_spec, "features")?; 73 let actual_features = features 74 .keys() 75 .filter(|name| name.as_str() != "default") 76 .cloned() 77 .collect::<BTreeSet<_>>(); 78 if actual_features != expected_features { 79 return Err(format!( 80 "radroots_core public features must be exactly {expected_features:?}, found {actual_features:?}" 81 )); 82 } 83 let expected_default = spec_strings(core_spec, "default_features")?; 84 let actual_default = value_strings( 85 features.get("default"), 86 &format!("{CORE_MANIFEST_RELATIVE} features.default"), 87 )?; 88 if actual_default != expected_default { 89 return Err(format!( 90 "radroots_core default features must be exactly {expected_default:?}, found {actual_default:?}" 91 )); 92 } 93 let std_enables = value_strings( 94 features.get("std"), 95 &format!("{CORE_MANIFEST_RELATIVE} features.std"), 96 )?; 97 if !std_enables.is_empty() { 98 return Err("radroots_core std must remain an empty additive marker".to_owned()); 99 } 100 let serde_enables = value_strings( 101 features.get("serde"), 102 &format!("{CORE_MANIFEST_RELATIVE} features.serde"), 103 )?; 104 let expected_serde = BTreeSet::from(["dep:serde".to_owned(), "rust_decimal/serde".to_owned()]); 105 if serde_enables != expected_serde { 106 return Err(format!( 107 "radroots_core serde feature must enable {expected_serde:?}, found {serde_enables:?}" 108 )); 109 } 110 111 validate_dependency_names( 112 manifest, 113 "dependencies", 114 BTreeSet::from(CORE_RUNTIME_DEPENDENCIES.map(str::to_owned)), 115 )?; 116 validate_dependency_names( 117 manifest, 118 "dev-dependencies", 119 BTreeSet::from(CORE_DEV_DEPENDENCIES.map(str::to_owned)), 120 )?; 121 reject_nonempty_dependency_section(manifest, "build-dependencies")?; 122 if manifest 123 .get("target") 124 .and_then(toml::Value::as_table) 125 .is_some_and(|targets| { 126 targets.values().any(|target| { 127 target.as_table().is_some_and(|table| { 128 ["dependencies", "dev-dependencies", "build-dependencies"] 129 .iter() 130 .any(|section| { 131 table 132 .get(*section) 133 .and_then(toml::Value::as_table) 134 .is_some_and(|dependencies| !dependencies.is_empty()) 135 }) 136 }) 137 }) 138 }) 139 { 140 return Err("radroots_core must not declare target-specific dependencies".to_owned()); 141 } 142 143 let dependencies = manifest 144 .get("dependencies") 145 .and_then(toml::Value::as_table) 146 .expect("validated dependencies"); 147 validate_dependency_shape(dependencies, "rust_decimal", false, false, &[])?; 148 validate_dependency_shape(dependencies, "serde", true, false, &["alloc", "derive"]) 149 } 150 151 fn validate_crate_root( 152 workspace_root: &Path, 153 core_spec: &toml::value::Table, 154 ) -> Result<(), String> { 155 let path = workspace_root.join(CORE_LIB_RELATIVE); 156 let raw = 157 fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; 158 let file = 159 syn::parse_file(&raw).map_err(|error| format!("parse {CORE_LIB_RELATIVE}: {error}"))?; 160 let mut modules = BTreeSet::new(); 161 let mut exports = BTreeMap::new(); 162 for item in &file.items { 163 match item { 164 Item::Mod(item) if matches!(item.vis, Visibility::Public(_)) => { 165 modules.insert(item.ident.to_string()); 166 } 167 Item::Use(item) if matches!(item.vis, Visibility::Public(_)) => { 168 collect_use_exports(&item.tree, &mut Vec::new(), &mut exports)?; 169 } 170 Item::Macro(item) 171 if item 172 .attrs 173 .iter() 174 .any(|attribute| attribute.path().is_ident("macro_export")) => 175 { 176 return Err(format!( 177 "{CORE_LIB_RELATIVE} must not declare a root macro export" 178 )); 179 } 180 item if is_public(item) => { 181 return Err(format!( 182 "{CORE_LIB_RELATIVE} contains an unsupported public root item: {}", 183 public_item_kind(item) 184 )); 185 } 186 _ => {} 187 } 188 } 189 190 let expected_modules = spec_strings(core_spec, "modules")?; 191 if modules != expected_modules { 192 return Err(format!( 193 "radroots_core public modules must be exactly {expected_modules:?}, found {modules:?}" 194 )); 195 } 196 let specified_exports = spec_strings(core_spec, "root_exports")?; 197 let canonical_exports = CORE_ROOT_EXPORTS 198 .iter() 199 .map(|(name, _)| (*name).to_owned()) 200 .collect::<BTreeSet<_>>(); 201 if specified_exports != canonical_exports { 202 return Err(format!( 203 "radroots_core canonical root exports must be exactly {canonical_exports:?}, found {specified_exports:?}" 204 )); 205 } 206 let expected_exports = CORE_ROOT_EXPORTS 207 .into_iter() 208 .map(|(name, source)| (name.to_owned(), source.to_owned())) 209 .collect::<BTreeMap<_, _>>(); 210 if exports != expected_exports { 211 return Err(format!( 212 "radroots_core root exports must match the canonical contract; expected {expected_exports:?}, found {exports:?}" 213 )); 214 } 215 Ok(()) 216 } 217 218 fn validate_dependency_names( 219 manifest: &toml::value::Table, 220 section: &str, 221 expected: BTreeSet<String>, 222 ) -> Result<(), String> { 223 let actual = manifest 224 .get(section) 225 .and_then(toml::Value::as_table) 226 .map(|dependencies| dependencies.keys().cloned().collect::<BTreeSet<_>>()) 227 .unwrap_or_default(); 228 if actual != expected { 229 return Err(format!( 230 "radroots_core {section} must be exactly {expected:?}, found {actual:?}" 231 )); 232 } 233 Ok(()) 234 } 235 236 fn reject_nonempty_dependency_section( 237 manifest: &toml::value::Table, 238 section: &str, 239 ) -> Result<(), String> { 240 if manifest 241 .get(section) 242 .and_then(toml::Value::as_table) 243 .is_some_and(|dependencies| !dependencies.is_empty()) 244 { 245 return Err(format!("radroots_core must not declare {section}")); 246 } 247 Ok(()) 248 } 249 250 fn validate_dependency_shape( 251 dependencies: &toml::value::Table, 252 name: &str, 253 optional: bool, 254 default_features: bool, 255 expected_features: &[&str], 256 ) -> Result<(), String> { 257 let dependency = dependencies 258 .get(name) 259 .and_then(toml::Value::as_table) 260 .ok_or_else(|| format!("radroots_core dependency {name} must use a dependency table"))?; 261 if dependency.get("workspace").and_then(toml::Value::as_bool) != Some(true) 262 || dependency 263 .get("optional") 264 .and_then(toml::Value::as_bool) 265 .unwrap_or(false) 266 != optional 267 || dependency 268 .get("default-features") 269 .and_then(toml::Value::as_bool) 270 .unwrap_or(true) 271 != default_features 272 { 273 return Err(format!( 274 "radroots_core dependency {name} has a noncanonical workspace/optional/default-features shape" 275 )); 276 } 277 let mut expected_keys = BTreeSet::from(["workspace", "default-features"]); 278 if optional { 279 expected_keys.insert("optional"); 280 } 281 if !expected_features.is_empty() { 282 expected_keys.insert("features"); 283 } 284 let actual_keys = dependency 285 .keys() 286 .map(String::as_str) 287 .collect::<BTreeSet<_>>(); 288 if actual_keys != expected_keys { 289 return Err(format!( 290 "radroots_core dependency {name} keys must be exactly {expected_keys:?}, found {actual_keys:?}" 291 )); 292 } 293 let actual_features = match dependency.get("features") { 294 Some(features) => value_strings( 295 Some(features), 296 &format!("{CORE_MANIFEST_RELATIVE} dependencies.{name}.features"), 297 )?, 298 None => BTreeSet::new(), 299 }; 300 let expected_features = expected_features 301 .iter() 302 .map(|feature| (*feature).to_owned()) 303 .collect::<BTreeSet<_>>(); 304 if actual_features != expected_features { 305 return Err(format!( 306 "radroots_core dependency {name} features must be {expected_features:?}, found {actual_features:?}" 307 )); 308 } 309 Ok(()) 310 } 311 312 fn spec_strings(package: &toml::value::Table, field: &str) -> Result<BTreeSet<String>, String> { 313 value_strings( 314 package.get(field), 315 &format!("{ARCHITECTURE_RELATIVE} radroots_core.{field}"), 316 ) 317 } 318 319 fn value_strings(value: Option<&toml::Value>, label: &str) -> Result<BTreeSet<String>, String> { 320 value 321 .and_then(toml::Value::as_array) 322 .ok_or_else(|| format!("{label} must be an array"))? 323 .iter() 324 .map(|value| { 325 value 326 .as_str() 327 .map(str::to_owned) 328 .ok_or_else(|| format!("{label} must contain strings")) 329 }) 330 .collect() 331 } 332 333 fn collect_use_exports( 334 tree: &UseTree, 335 prefix: &mut Vec<String>, 336 exports: &mut BTreeMap<String, String>, 337 ) -> Result<(), String> { 338 match tree { 339 UseTree::Path(path) => { 340 prefix.push(path.ident.to_string()); 341 collect_use_exports(&path.tree, prefix, exports)?; 342 prefix.pop(); 343 } 344 UseTree::Name(name) => { 345 let source_name = name.ident.to_string(); 346 let export_name = if source_name == "self" { 347 prefix.last().cloned().ok_or_else(|| { 348 format!("{CORE_LIB_RELATIVE} contains an invalid root self re-export") 349 })? 350 } else { 351 source_name.clone() 352 }; 353 let source = if source_name == "self" { 354 prefix.join("::") 355 } else { 356 qualified_source(prefix, &source_name) 357 }; 358 insert_export(exports, export_name, source)?; 359 } 360 UseTree::Rename(rename) if rename.rename != "_" => { 361 insert_export( 362 exports, 363 rename.rename.to_string(), 364 qualified_source(prefix, &rename.ident.to_string()), 365 )?; 366 } 367 UseTree::Rename(_) => {} 368 UseTree::Glob(_) => { 369 return Err(format!( 370 "{CORE_LIB_RELATIVE} must not use public glob re-exports" 371 )); 372 } 373 UseTree::Group(group) => { 374 for item in &group.items { 375 collect_use_exports(item, prefix, exports)?; 376 } 377 } 378 } 379 Ok(()) 380 } 381 382 fn qualified_source(prefix: &[String], name: &str) -> String { 383 if prefix.is_empty() { 384 name.to_owned() 385 } else { 386 format!("{}::{name}", prefix.join("::")) 387 } 388 } 389 390 fn insert_export( 391 exports: &mut BTreeMap<String, String>, 392 name: String, 393 source: String, 394 ) -> Result<(), String> { 395 if let Some(previous) = exports.insert(name.clone(), source.clone()) { 396 return Err(format!( 397 "{CORE_LIB_RELATIVE} exports {name} more than once from {previous} and {source}" 398 )); 399 } 400 Ok(()) 401 } 402 403 fn is_public(item: &Item) -> bool { 404 match item { 405 Item::Const(item) => matches!(item.vis, Visibility::Public(_)), 406 Item::Enum(item) => matches!(item.vis, Visibility::Public(_)), 407 Item::ExternCrate(item) => matches!(item.vis, Visibility::Public(_)), 408 Item::Fn(item) => matches!(item.vis, Visibility::Public(_)), 409 Item::Static(item) => matches!(item.vis, Visibility::Public(_)), 410 Item::Struct(item) => matches!(item.vis, Visibility::Public(_)), 411 Item::Trait(item) => matches!(item.vis, Visibility::Public(_)), 412 Item::TraitAlias(item) => matches!(item.vis, Visibility::Public(_)), 413 Item::Type(item) => matches!(item.vis, Visibility::Public(_)), 414 Item::Union(item) => matches!(item.vis, Visibility::Public(_)), 415 _ => false, 416 } 417 } 418 419 fn public_item_kind(item: &Item) -> &'static str { 420 match item { 421 Item::Const(_) => "const", 422 Item::Enum(_) => "enum", 423 Item::ExternCrate(_) => "extern crate", 424 Item::Fn(_) => "function", 425 Item::Static(_) => "static", 426 Item::Struct(_) => "struct", 427 Item::Trait(_) => "trait", 428 Item::TraitAlias(_) => "trait alias", 429 Item::Type(_) => "type alias", 430 Item::Union(_) => "union", 431 _ => "item", 432 } 433 } 434 435 fn read_toml(workspace_root: &Path, relative: &str) -> Result<toml::Value, String> { 436 let path = workspace_path(workspace_root, relative); 437 let raw = 438 fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; 439 raw.parse::<toml::Value>() 440 .map_err(|error| format!("parse {}: {error}", path.display())) 441 } 442 443 fn workspace_path(workspace_root: &Path, relative: &str) -> PathBuf { 444 workspace_root.join(relative) 445 } 446 447 #[cfg(test)] 448 mod tests { 449 use std::fs; 450 451 use tempfile::tempdir; 452 453 use super::validate; 454 455 const SPEC: &str = r#" 456 [[package]] 457 name = "radroots_core" 458 features = ["std", "serde"] 459 default_features = ["std", "serde"] 460 modules = ["currency", "decimal", "money", "percent", "pricing", "quantity", "unit"] 461 root_exports = ["Currency", "Decimal", "Money", "Percent", "Quantity", "QuantityPrice", "Unit", "Error"] 462 "#; 463 464 const MANIFEST: &str = r#" 465 [package] 466 name = "radroots_core" 467 468 [lib] 469 name = "radroots_core" 470 471 [features] 472 default = ["std", "serde"] 473 std = [] 474 serde = ["dep:serde", "rust_decimal/serde"] 475 476 [dependencies] 477 rust_decimal = { workspace = true, default-features = false } 478 serde = { workspace = true, default-features = false, features = ["alloc", "derive"], optional = true } 479 480 [dev-dependencies] 481 dto_bindgen = { workspace = true } 482 rust_decimal = { workspace = true } 483 serde_json = { workspace = true } 484 "#; 485 486 fn fixture() -> tempfile::TempDir { 487 let root = tempdir().expect("temporary workspace"); 488 fs::create_dir_all(root.path().join("contracts/crates/release_v1")) 489 .expect("spec directory"); 490 fs::create_dir_all(root.path().join("crates/core/src")).expect("core directory"); 491 fs::write( 492 root.path() 493 .join("contracts/crates/release_v1/radroots_crates_release_v1.toml"), 494 SPEC, 495 ) 496 .expect("architecture spec"); 497 fs::write(root.path().join("crates/core/Cargo.toml"), MANIFEST).expect("core manifest"); 498 let exports = super::CORE_ROOT_EXPORTS 499 .into_iter() 500 .map(|(_, source)| format!("pub use {source};")) 501 .collect::<Vec<_>>() 502 .join(" "); 503 fs::write( 504 root.path().join("crates/core/src/lib.rs"), 505 format!( 506 "pub mod currency; pub mod decimal; pub mod money; pub mod percent; pub mod pricing; pub mod quantity; pub mod unit; {exports}" 507 ), 508 ) 509 .expect("core root"); 510 root 511 } 512 513 #[test] 514 fn accepts_exact_core_contract() { 515 let root = fixture(); 516 validate(root.path()).expect("exact core contract"); 517 } 518 519 #[test] 520 fn rejects_public_codegen_feature_and_runtime_dependency() { 521 let root = fixture(); 522 let manifest_path = root.path().join("crates/core/Cargo.toml"); 523 let manifest = fs::read_to_string(&manifest_path).expect("manifest"); 524 fs::write( 525 &manifest_path, 526 manifest.replace("std = []", "std = []\ndto-bindgen = [\"dep:dto_bindgen\"]"), 527 ) 528 .expect("mutated manifest"); 529 let error = validate(root.path()).expect_err("codegen feature must fail"); 530 assert!(error.contains("public features must be exactly"), "{error}"); 531 532 fs::write( 533 &manifest_path, 534 MANIFEST.replace( 535 "[dev-dependencies]", 536 "tokio = { workspace = true }\n\n[dev-dependencies]", 537 ), 538 ) 539 .expect("runtime dependency manifest"); 540 let error = validate(root.path()).expect_err("runtime dependency must fail"); 541 assert!(error.contains("dependencies must be exactly"), "{error}"); 542 543 fs::write( 544 &manifest_path, 545 MANIFEST.replace( 546 "rust_decimal = { workspace = true, default-features = false }", 547 "rust_decimal = { workspace = true, default-features = false, features = [] }", 548 ), 549 ) 550 .expect("noncanonical dependency shape"); 551 let error = validate(root.path()).expect_err("extra dependency keys must fail"); 552 assert!(error.contains("keys must be exactly"), "{error}"); 553 } 554 555 #[test] 556 fn rejects_extra_public_module_or_export() { 557 let root = fixture(); 558 let lib_path = root.path().join("crates/core/src/lib.rs"); 559 let source = fs::read_to_string(&lib_path).expect("core root"); 560 fs::write(&lib_path, format!("{source} pub mod serde_ext;")).expect("extra module source"); 561 let error = validate(root.path()).expect_err("extra module must fail"); 562 assert!(error.contains("public modules must be exactly"), "{error}"); 563 564 fs::write(&lib_path, format!("{source} pub use fixture::Clock;")) 565 .expect("extra export source"); 566 let error = validate(root.path()).expect_err("extra export must fail"); 567 assert!(error.contains("root exports must match"), "{error}"); 568 } 569 570 #[test] 571 fn rejects_rebound_duplicate_and_glob_exports() { 572 let root = fixture(); 573 let lib_path = root.path().join("crates/core/src/lib.rs"); 574 let source = fs::read_to_string(&lib_path).expect("core root"); 575 fs::write( 576 &lib_path, 577 source.replace("pub use currency::Currency;", "pub use money::Currency;"), 578 ) 579 .expect("rebound export source"); 580 let error = validate(root.path()).expect_err("rebound export must fail"); 581 assert!(error.contains("root exports must match"), "{error}"); 582 583 fs::write(&lib_path, format!("{source} pub use currency::*;")).expect("glob export source"); 584 let error = validate(root.path()).expect_err("glob export must fail"); 585 assert!(error.contains("must not use public glob"), "{error}"); 586 587 fs::write(&lib_path, format!("{source} pub use currency::Currency;")) 588 .expect("duplicate export source"); 589 let error = validate(root.path()).expect_err("duplicate export must fail"); 590 assert!(error.contains("exports Currency more than once"), "{error}"); 591 } 592 }