contracts.rs (13239B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 fs, 4 path::{Path, PathBuf}, 5 }; 6 7 use serde::Deserialize; 8 9 #[derive(Debug, Deserialize)] 10 #[serde(deny_unknown_fields)] 11 struct ExportContract { 12 language: LanguageContract, 13 packages: BTreeMap<String, String>, 14 artifacts: Option<ExportArtifacts>, 15 runtime: RuntimeContract, 16 } 17 18 #[derive(Debug, Deserialize)] 19 #[serde(deny_unknown_fields)] 20 struct PackageContract { 21 language: LanguageContract, 22 sdk: SdkPackageContract, 23 rollout: RolloutContract, 24 operations: BTreeMap<String, String>, 25 shared_types: BTreeMap<String, String>, 26 artifacts: Option<SdkArtifacts>, 27 } 28 29 #[derive(Debug, Deserialize)] 30 #[serde(deny_unknown_fields)] 31 struct LanguageContract { 32 id: String, 33 repository: String, 34 } 35 36 #[derive(Debug, Deserialize)] 37 #[serde(deny_unknown_fields)] 38 struct RuntimeContract { 39 networking: String, 40 signing: String, 41 deterministic_codec: String, 42 } 43 44 #[derive(Debug, Deserialize)] 45 #[serde(deny_unknown_fields)] 46 struct ExportArtifacts { 47 models_dir: String, 48 constants_dir: String, 49 wasm_dist_dir: Option<String>, 50 manifest_file: String, 51 } 52 53 #[derive(Debug, Deserialize)] 54 #[serde(deny_unknown_fields)] 55 struct SdkPackageContract { 56 package: String, 57 module_format: Option<String>, 58 deterministic_codec: String, 59 signing: String, 60 networking: String, 61 } 62 63 #[derive(Debug, Deserialize)] 64 #[serde(deny_unknown_fields)] 65 struct RolloutContract { 66 stage: String, 67 order: u32, 68 } 69 70 #[derive(Debug, Deserialize)] 71 #[serde(deny_unknown_fields)] 72 struct SdkArtifacts { 73 models_dir: String, 74 runtime_dir: String, 75 wasm_dist_dir: String, 76 manifest_file: String, 77 } 78 79 pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> { 80 let exports = load_contract_dir::<ExportContract>(&root.join("contracts").join("exports"))?; 81 let packages = load_contract_dir::<PackageContract>(&root.join("contracts").join("packages"))?; 82 if exports.is_empty() { 83 return Err("contracts/exports must define at least one language".to_owned()); 84 } 85 if packages.is_empty() { 86 return Err("contracts/packages must define at least one language".to_owned()); 87 } 88 89 let mut export_packages = BTreeMap::new(); 90 let mut export_languages = BTreeSet::new(); 91 for export in &exports { 92 validate_language(&export.language, "exports")?; 93 validate_non_empty_map(&export.packages, "exports packages")?; 94 validate_runtime( 95 &export.runtime.networking, 96 &export.runtime.signing, 97 &export.runtime.deterministic_codec, 98 &format!("exports {}", export.language.id), 99 )?; 100 let artifacts = export 101 .artifacts 102 .as_ref() 103 .ok_or_else(|| format!("exports {} artifacts are required", export.language.id))?; 104 validate_non_empty(&artifacts.models_dir, "exports artifacts.models_dir")?; 105 validate_non_empty(&artifacts.constants_dir, "exports artifacts.constants_dir")?; 106 validate_non_empty(&artifacts.manifest_file, "exports artifacts.manifest_file")?; 107 if export.language.id == "ts" { 108 validate_non_empty( 109 artifacts.wasm_dist_dir.as_deref().unwrap_or(""), 110 "exports ts artifacts.wasm_dist_dir", 111 )?; 112 } 113 if !export_languages.insert(export.language.id.clone()) { 114 return Err(format!("duplicate exports language {}", export.language.id)); 115 } 116 let packages = export 117 .packages 118 .values() 119 .cloned() 120 .collect::<BTreeSet<String>>(); 121 if packages.len() != 1 { 122 return Err(format!( 123 "exports {} must resolve to one curated package", 124 export.language.id 125 )); 126 } 127 export_packages.insert(export.language.id.clone(), packages); 128 } 129 130 let mut package_languages = BTreeSet::new(); 131 let mut operation_keys: Option<BTreeSet<String>> = None; 132 let mut shared_type_keys: Option<BTreeSet<String>> = None; 133 let mut rollout_orders = BTreeMap::new(); 134 for package in &packages { 135 validate_language(&package.language, "packages")?; 136 validate_non_empty(&package.sdk.package, "packages sdk.package")?; 137 validate_runtime( 138 &package.sdk.networking, 139 &package.sdk.signing, 140 &package.sdk.deterministic_codec, 141 &format!("packages {}", package.language.id), 142 )?; 143 if let Some(module_format) = package.sdk.module_format.as_deref() { 144 validate_non_empty(module_format, "packages sdk.module_format")?; 145 } 146 validate_rollout(&package.language.id, &package.rollout)?; 147 validate_non_empty_map(&package.operations, "packages operations")?; 148 validate_non_empty_map(&package.shared_types, "packages shared_types")?; 149 if package.language.id == "ts" { 150 let artifacts = package 151 .artifacts 152 .as_ref() 153 .ok_or_else(|| "packages ts artifacts are required".to_owned())?; 154 validate_non_empty(&artifacts.models_dir, "packages ts artifacts.models_dir")?; 155 validate_non_empty(&artifacts.runtime_dir, "packages ts artifacts.runtime_dir")?; 156 validate_non_empty( 157 &artifacts.wasm_dist_dir, 158 "packages ts artifacts.wasm_dist_dir", 159 )?; 160 validate_non_empty( 161 &artifacts.manifest_file, 162 "packages ts artifacts.manifest_file", 163 )?; 164 } 165 if !package_languages.insert(package.language.id.clone()) { 166 return Err(format!( 167 "duplicate packages language {}", 168 package.language.id 169 )); 170 } 171 let Some(packages_for_language) = export_packages.get(&package.language.id) else { 172 return Err(format!( 173 "packages {} is missing a matching export contract", 174 package.language.id 175 )); 176 }; 177 let expected = [package.sdk.package.clone()] 178 .into_iter() 179 .collect::<BTreeSet<_>>(); 180 if packages_for_language != &expected { 181 return Err(format!( 182 "exports {} must resolve to package {}", 183 package.language.id, package.sdk.package 184 )); 185 } 186 let current_operations = package.operations.keys().cloned().collect::<BTreeSet<_>>(); 187 match &operation_keys { 188 Some(expected) if expected != ¤t_operations => { 189 return Err(format!( 190 "packages {} operations must match the shared operation set", 191 package.language.id 192 )); 193 } 194 None => operation_keys = Some(current_operations), 195 _ => {} 196 } 197 let current_shared_types = package 198 .shared_types 199 .keys() 200 .cloned() 201 .collect::<BTreeSet<_>>(); 202 match &shared_type_keys { 203 Some(expected) if expected != ¤t_shared_types => { 204 return Err(format!( 205 "packages {} shared_types must match the shared type set", 206 package.language.id 207 )); 208 } 209 None => shared_type_keys = Some(current_shared_types), 210 _ => {} 211 } 212 rollout_orders.insert(package.language.id.clone(), package.rollout.order); 213 } 214 215 if export_languages != package_languages { 216 return Err("contracts/exports and contracts/packages languages must match".to_owned()); 217 } 218 if rollout_orders.get("ts") != Some(&1) { 219 return Err("packages ts rollout.order must be 1".to_owned()); 220 } 221 Ok(()) 222 } 223 224 fn load_contract_dir<T>(dir: &Path) -> Result<Vec<T>, String> 225 where 226 T: for<'de> Deserialize<'de>, 227 { 228 let read_dir = 229 fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?; 230 let mut entries = read_dir 231 .collect::<Result<Vec<_>, _>>() 232 .map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; 233 entries.sort_by_key(|entry| entry.file_name()); 234 let mut contracts = Vec::new(); 235 for entry in entries { 236 let path = entry.path(); 237 if path.extension().and_then(|extension| extension.to_str()) != Some("toml") { 238 continue; 239 } 240 contracts.push(parse_toml(&path)?); 241 } 242 Ok(contracts) 243 } 244 245 fn parse_toml<T>(path: &PathBuf) -> Result<T, String> 246 where 247 T: for<'de> Deserialize<'de>, 248 { 249 let raw = fs::read_to_string(path) 250 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 251 toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) 252 } 253 254 fn validate_language(language: &LanguageContract, family: &str) -> Result<(), String> { 255 validate_non_empty(&language.id, &format!("{family} language.id"))?; 256 validate_non_empty( 257 &language.repository, 258 &format!("{family} language.repository"), 259 ) 260 } 261 262 fn validate_runtime( 263 networking: &str, 264 signing: &str, 265 deterministic_codec: &str, 266 family: &str, 267 ) -> Result<(), String> { 268 validate_non_empty(networking, &format!("{family} networking"))?; 269 validate_non_empty(signing, &format!("{family} signing"))?; 270 validate_non_empty( 271 deterministic_codec, 272 &format!("{family} deterministic_codec"), 273 ) 274 } 275 276 fn validate_rollout(language: &str, rollout: &RolloutContract) -> Result<(), String> { 277 validate_non_empty(&rollout.stage, "packages rollout.stage")?; 278 if !matches!(rollout.stage.as_str(), "active" | "next" | "deferred") { 279 return Err(format!("packages {language} rollout.stage is invalid")); 280 } 281 if rollout.order == 0 { 282 return Err(format!( 283 "packages {language} rollout.order must be greater than zero" 284 )); 285 } 286 Ok(()) 287 } 288 289 fn validate_non_empty(value: &str, field: &str) -> Result<(), String> { 290 if value.trim().is_empty() || value.trim() != value { 291 return Err(format!("{field} must be non-empty")); 292 } 293 Ok(()) 294 } 295 296 fn validate_non_empty_map(map: &BTreeMap<String, String>, field: &str) -> Result<(), String> { 297 if map.is_empty() { 298 return Err(format!("{field} must not be empty")); 299 } 300 for (key, value) in map { 301 validate_non_empty(key, field)?; 302 validate_non_empty(value, field)?; 303 } 304 Ok(()) 305 } 306 307 #[cfg(test)] 308 mod tests { 309 use std::{ 310 fs, 311 time::{SystemTime, UNIX_EPOCH}, 312 }; 313 314 use super::validate_sdk_contracts; 315 316 #[test] 317 fn current_sdk_contracts_validate() { 318 let root = crate::fs::workspace_root().expect("workspace root"); 319 validate_sdk_contracts(&root).expect("sdk contracts validate"); 320 } 321 322 #[test] 323 fn rejects_mismatched_language_sets() { 324 let root = test_root("language_mismatch"); 325 write_contract( 326 &root, 327 "contracts/exports/ts.toml", 328 EXPORT_TS.replace("@radroots/sdk", "@radroots/sdk").as_str(), 329 ); 330 let error = validate_sdk_contracts(&root).expect_err("missing packages should fail"); 331 assert!(error.contains("contracts/packages")); 332 let _ = fs::remove_dir_all(root); 333 } 334 335 #[test] 336 fn rejects_package_export_mismatch() { 337 let root = test_root("package_mismatch"); 338 write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS); 339 write_contract( 340 &root, 341 "contracts/packages/ts.toml", 342 PACKAGE_TS 343 .replace("@radroots/sdk", "@radroots/other") 344 .as_str(), 345 ); 346 let error = validate_sdk_contracts(&root).expect_err("mismatch should fail"); 347 assert!(error.contains("exports ts must resolve")); 348 let _ = fs::remove_dir_all(root); 349 } 350 351 fn test_root(name: &str) -> std::path::PathBuf { 352 let stamp = SystemTime::now() 353 .duration_since(UNIX_EPOCH) 354 .expect("time") 355 .as_nanos(); 356 std::env::temp_dir().join(format!("radroots_sdk_contracts_{name}_{stamp}")) 357 } 358 359 fn write_contract(root: &std::path::Path, relative: &str, contents: &str) { 360 let path = root.join(relative); 361 fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); 362 fs::write(path, contents).expect("write contract"); 363 } 364 365 const EXPORT_TS: &str = r#"[language] 366 id = "ts" 367 repository = "sdk-typescript" 368 369 [packages] 370 "radroots_core" = "@radroots/sdk" 371 372 [artifacts] 373 models_dir = "src/generated" 374 constants_dir = "src/generated" 375 wasm_dist_dir = "dist" 376 manifest_file = "export-manifest.json" 377 378 [runtime] 379 networking = "native" 380 signing = "native" 381 deterministic_codec = "wasm" 382 "#; 383 384 const PACKAGE_TS: &str = r#"[language] 385 id = "ts" 386 repository = "sdk-typescript" 387 388 [sdk] 389 package = "@radroots/sdk" 390 module_format = "esm" 391 deterministic_codec = "wasm" 392 signing = "native" 393 networking = "native" 394 395 [rollout] 396 stage = "active" 397 order = 1 398 399 [operations] 400 "profile.build_draft" = "profile.buildDraft" 401 402 [shared_types] 403 "WireEventParts" = "WireEventParts" 404 405 [artifacts] 406 models_dir = "src/generated" 407 runtime_dir = "src/runtime" 408 wasm_dist_dir = "dist" 409 manifest_file = "export-manifest.json" 410 "#; 411 }