bindings.rs (14749B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 env, fs, 4 path::{Path, PathBuf}, 5 process::Command, 6 }; 7 8 use serde::{Deserialize, Serialize}; 9 use sha2::{Digest, Sha256}; 10 11 use crate::{build_control::Mode, sdk_generation::fs::write_or_check}; 12 13 const FFI_PACKAGE: &str = "radroots_sdk_ffi"; 14 const FFI_VERSION: &str = "0.1.0-alpha"; 15 const BINDGEN_VERSION: &str = "0.29.5"; 16 17 #[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] 18 struct SourceLock { 19 schema_version: u16, 20 source_package: String, 21 source_version: String, 22 source_sha256: String, 23 generator: String, 24 generator_version: String, 25 language: String, 26 outputs: BTreeMap<String, String>, 27 } 28 29 pub fn generate( 30 source_root: &Path, 31 consumer_root: &Path, 32 language: &str, 33 mode: Mode, 34 ) -> Result<(), String> { 35 if !matches!(language, "swift" | "kotlin") { 36 return Err("SDK binding language must be swift or kotlin".to_owned()); 37 } 38 build_bindgen(source_root)?; 39 let target_dir = env::var_os("CARGO_TARGET_DIR") 40 .map(PathBuf::from) 41 .ok_or_else(|| "CARGO_TARGET_DIR must be set by extbuild for FFI generation".to_owned())?; 42 let library = target_dir.join("debug").join(format!( 43 "{}radroots_sdk_ffi{}", 44 env::consts::DLL_PREFIX, 45 env::consts::DLL_SUFFIX 46 )); 47 if !library.is_file() { 48 return Err(format!("missing built FFI library: {}", library.display())); 49 } 50 let bindgen = target_dir 51 .join("debug") 52 .join(format!("radroots-sdk-bindgen{}", env::consts::EXE_SUFFIX)); 53 if !bindgen.is_file() { 54 return Err(format!( 55 "missing FFI bindgen executable: {}", 56 bindgen.display() 57 )); 58 } 59 let temporary = tempfile::tempdir() 60 .map_err(|error| format!("failed to create binding output directory: {error}"))?; 61 let status = Command::new(&bindgen) 62 .args(["generate", "--library"]) 63 .arg(&library) 64 .args([ 65 "--crate", 66 FFI_PACKAGE, 67 "--language", 68 language, 69 "--no-format", 70 "--out-dir", 71 ]) 72 .arg(temporary.path()) 73 .current_dir(source_root) 74 .status() 75 .map_err(|error| format!("failed to start {}: {error}", bindgen.display()))?; 76 if !status.success() { 77 return Err(format!( 78 "{language} binding generation failed with {status}" 79 )); 80 } 81 install_outputs(source_root, consumer_root, language, temporary.path(), mode)?; 82 println!("generated {language} bindings for {FFI_PACKAGE}"); 83 Ok(()) 84 } 85 86 fn build_bindgen(root: &Path) -> Result<(), String> { 87 let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); 88 let status = Command::new(cargo) 89 .args([ 90 "build", 91 "--locked", 92 "-p", 93 FFI_PACKAGE, 94 "--lib", 95 "--bin", 96 "radroots-sdk-bindgen", 97 ]) 98 .current_dir(root) 99 .status() 100 .map_err(|error| format!("failed to start Cargo for FFI generation: {error}"))?; 101 if !status.success() { 102 return Err(format!( 103 "FFI library and bindgen build failed with {status}" 104 )); 105 } 106 Ok(()) 107 } 108 109 fn install_outputs( 110 source_root: &Path, 111 consumer_root: &Path, 112 language: &str, 113 temporary: &Path, 114 mode: Mode, 115 ) -> Result<(), String> { 116 let output_dir = consumer_root.join("generated").join(language); 117 let mut generated = Vec::new(); 118 collect_files(temporary, &mut generated)?; 119 generated.sort(); 120 let mut outputs = BTreeMap::new(); 121 for path in generated { 122 let name = path 123 .strip_prefix(temporary) 124 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))? 125 .to_str() 126 .ok_or_else(|| format!("non-UTF-8 generated binding path: {}", path.display()))? 127 .replace('\\', "/"); 128 let contents = fs::read(&path) 129 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 130 let destination = output_dir.join(&name); 131 let text = String::from_utf8(contents) 132 .map_err(|error| format!("generated text binding is not UTF-8: {error}"))?; 133 let text = normalize_generated_text(&text); 134 outputs.insert( 135 name.clone(), 136 format!("{:x}", Sha256::digest(text.as_bytes())), 137 ); 138 write_or_check(&destination, &text, mode)?; 139 } 140 if outputs.is_empty() { 141 return Err(format!("{language} binding generator emitted no files")); 142 } 143 let lock = SourceLock { 144 schema_version: 1, 145 source_package: FFI_PACKAGE.to_owned(), 146 source_version: FFI_VERSION.to_owned(), 147 source_sha256: ffi_source_sha256(source_root)?, 148 generator: "uniffi".to_owned(), 149 generator_version: BINDGEN_VERSION.to_owned(), 150 language: language.to_owned(), 151 outputs, 152 }; 153 let mut rendered = serde_json::to_string_pretty(&lock) 154 .map_err(|error| format!("failed to render {language} source lock: {error}"))?; 155 rendered.push('\n'); 156 write_or_check(&output_dir.join("source.lock"), &rendered, mode) 157 } 158 159 fn normalize_generated_text(contents: &str) -> String { 160 let mut normalized = contents 161 .lines() 162 .map(str::trim_end) 163 .collect::<Vec<_>>() 164 .join("\n"); 165 while normalized.ends_with('\n') { 166 normalized.pop(); 167 } 168 normalized.push('\n'); 169 normalized 170 } 171 172 pub fn check_generated_language( 173 source_root: &Path, 174 consumer_root: &Path, 175 language: &str, 176 ) -> Result<(), String> { 177 match language { 178 "swift" => check_language( 179 source_root, 180 consumer_root, 181 "swift", 182 &[ 183 "radroots_sdk.swift", 184 "radroots_sdkFFI.h", 185 "radroots_sdkFFI.modulemap", 186 ], 187 ), 188 "kotlin" => { 189 check_language( 190 source_root, 191 consumer_root, 192 "kotlin", 193 &["uniffi/radroots_sdk/radroots_sdk.kt"], 194 )?; 195 check_kotlin_schema_inventory(consumer_root) 196 } 197 _ => Err("SDK binding language must be swift or kotlin".to_owned()), 198 } 199 } 200 201 fn check_kotlin_schema_inventory(root: &Path) -> Result<(), String> { 202 let path = root.join("generated/kotlin/uniffi/radroots_sdk/radroots_sdk.kt"); 203 let source = fs::read_to_string(&path) 204 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 205 for required in [ 206 "class MobileClient", 207 "data class CapabilityStatus", 208 "enum class CapabilityAvailability", 209 "enum class CapabilityMaturity", 210 "sealed class Exception", 211 ] { 212 if !source.contains(required) { 213 return Err(format!( 214 "generated Kotlin schema inventory is missing {required}: {}", 215 path.display() 216 )); 217 } 218 } 219 for forbidden in ["Keychain", "BackgroundTask", "Presentation"] { 220 if source.contains(forbidden) { 221 return Err(format!( 222 "generated Kotlin bindings contain host-owned {forbidden} API: {}", 223 path.display() 224 )); 225 } 226 } 227 Ok(()) 228 } 229 230 fn check_language( 231 source_root: &Path, 232 consumer_root: &Path, 233 language: &str, 234 expected: &[&str], 235 ) -> Result<(), String> { 236 let output_dir = consumer_root.join("generated").join(language); 237 let mut actual_paths = Vec::new(); 238 collect_files(&output_dir, &mut actual_paths)?; 239 let actual = actual_paths 240 .into_iter() 241 .map(|path| { 242 path.strip_prefix(&output_dir) 243 .map(|relative| relative.to_string_lossy().replace('\\', "/")) 244 .map_err(|error| format!("failed to relativize {}: {error}", path.display())) 245 }) 246 .collect::<Result<BTreeSet<_>, _>>()?; 247 let expected_inventory = expected 248 .iter() 249 .map(|name| (*name).to_owned()) 250 .chain(["source.lock".to_owned()]) 251 .collect::<BTreeSet<_>>(); 252 if actual != expected_inventory { 253 return Err(format!( 254 "unexpected generated {language} binding inventory: expected {expected_inventory:?}, found {actual:?}" 255 )); 256 } 257 let lock_path = output_dir.join("source.lock"); 258 let raw = fs::read_to_string(&lock_path) 259 .map_err(|error| format!("failed to read {}: {error}", lock_path.display()))?; 260 let actual: SourceLock = serde_json::from_str(&raw) 261 .map_err(|error| format!("failed to parse {}: {error}", lock_path.display()))?; 262 let mut outputs = BTreeMap::new(); 263 for name in expected { 264 let path = output_dir.join(name); 265 let contents = fs::read(&path) 266 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 267 outputs.insert( 268 (*name).to_owned(), 269 format!("{:x}", Sha256::digest(contents)), 270 ); 271 } 272 let expected_lock = SourceLock { 273 schema_version: 1, 274 source_package: FFI_PACKAGE.to_owned(), 275 source_version: FFI_VERSION.to_owned(), 276 source_sha256: ffi_source_sha256(source_root)?, 277 generator: "uniffi".to_owned(), 278 generator_version: BINDGEN_VERSION.to_owned(), 279 language: language.to_owned(), 280 outputs, 281 }; 282 if actual != expected_lock { 283 return Err(format!( 284 "stale generated {language} bindings: {}", 285 lock_path.display() 286 )); 287 } 288 Ok(()) 289 } 290 291 fn ffi_source_sha256(root: &Path) -> Result<String, String> { 292 let crate_dir = root.join("crates/sdk_ffi"); 293 let mut paths = vec![crate_dir.join("Cargo.toml"), crate_dir.join("README.md")]; 294 collect_files(&crate_dir.join("src"), &mut paths)?; 295 paths.sort(); 296 let mut hasher = Sha256::new(); 297 for path in paths { 298 let relative = path 299 .strip_prefix(root) 300 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; 301 hasher.update(relative.to_string_lossy().as_bytes()); 302 hasher.update([0]); 303 hasher.update( 304 fs::read(&path) 305 .map_err(|error| format!("failed to read {}: {error}", path.display()))?, 306 ); 307 hasher.update([0]); 308 } 309 Ok(format!("{:x}", hasher.finalize())) 310 } 311 312 fn collect_files(dir: &Path, paths: &mut Vec<PathBuf>) -> Result<(), String> { 313 let mut entries = fs::read_dir(dir) 314 .map_err(|error| format!("failed to read {}: {error}", dir.display()))? 315 .collect::<Result<Vec<_>, _>>() 316 .map_err(|error| format!("failed to enumerate {}: {error}", dir.display()))?; 317 entries.sort_by_key(std::fs::DirEntry::file_name); 318 for entry in entries { 319 let path = entry.path(); 320 if path.is_dir() { 321 collect_files(&path, paths)?; 322 } else if path.is_file() { 323 paths.push(path); 324 } 325 } 326 Ok(()) 327 } 328 329 #[cfg(test)] 330 mod tests { 331 use std::{collections::BTreeMap, fs}; 332 333 use sha2::{Digest, Sha256}; 334 335 use crate::build_control::Mode; 336 337 use super::{ 338 BINDGEN_VERSION, FFI_PACKAGE, FFI_VERSION, SourceLock, check_generated_language, 339 ffi_source_sha256, generate, 340 }; 341 342 #[test] 343 fn language_check_does_not_require_the_other_binding_tree() { 344 let consumer = tempfile::TempDir::new().expect("consumer fixture"); 345 let source = crate::workspace_root(); 346 let output_dir = consumer.path().join("generated/swift"); 347 fs::create_dir_all(&output_dir).expect("Swift output directory"); 348 let outputs = BTreeMap::from([ 349 ("radroots_sdk.swift".to_owned(), "swift\n"), 350 ("radroots_sdkFFI.h".to_owned(), "header\n"), 351 ("radroots_sdkFFI.modulemap".to_owned(), "module\n"), 352 ]); 353 for (name, contents) in &outputs { 354 fs::write(output_dir.join(name), contents).expect("Swift fixture output"); 355 } 356 let lock = SourceLock { 357 schema_version: 1, 358 source_package: FFI_PACKAGE.to_owned(), 359 source_version: FFI_VERSION.to_owned(), 360 source_sha256: ffi_source_sha256(&source).expect("FFI source digest"), 361 generator: "uniffi".to_owned(), 362 generator_version: BINDGEN_VERSION.to_owned(), 363 language: "swift".to_owned(), 364 outputs: outputs 365 .into_iter() 366 .map(|(name, contents)| { 367 (name, format!("{:x}", Sha256::digest(contents.as_bytes()))) 368 }) 369 .collect(), 370 }; 371 let mut rendered = serde_json::to_string_pretty(&lock).expect("source lock"); 372 rendered.push('\n'); 373 fs::write(output_dir.join("source.lock"), rendered).expect("source lock fixture"); 374 375 check_generated_language(&source, consumer.path(), "swift").expect("Swift-only validation"); 376 assert!(check_generated_language(&source, consumer.path(), "kotlin").is_err()); 377 } 378 379 #[test] 380 #[ignore = "requires the native UniFFI bindgen toolchain"] 381 fn canonical_bindgen_matches_the_frozen_sdk_surface() { 382 let consumer = tempfile::TempDir::new().expect("consumer fixture"); 383 let source = crate::workspace_root(); 384 generate(&source, consumer.path(), "swift", Mode::Write).expect("Swift generation"); 385 generate(&source, consumer.path(), "kotlin", Mode::Write).expect("Kotlin generation"); 386 check_generated_language(&source, consumer.path(), "swift").expect("fresh Swift bindings"); 387 check_generated_language(&source, consumer.path(), "kotlin") 388 .expect("fresh Kotlin bindings"); 389 390 let expected = BTreeMap::from([ 391 ( 392 "generated/swift/radroots_sdk.swift", 393 "38ff1a652ff49b45c48601d2b1c0064103c1e815df9e80604f3369af2e53f4ac", 394 ), 395 ( 396 "generated/swift/radroots_sdkFFI.h", 397 "2efe02ecda4a68a495bacc61acf8ff41dc84bc01d9132a00b38353a57c51466a", 398 ), 399 ( 400 "generated/swift/radroots_sdkFFI.modulemap", 401 "b18fa5af1567a940fc699afe371a3a4653dca206f3233bcdf322062ab8192170", 402 ), 403 ( 404 "generated/kotlin/uniffi/radroots_sdk/radroots_sdk.kt", 405 "cc57260fade24a3b6d8366f06f17fb2d3132a0293cb61dd2a12d30d95f4a4e66", 406 ), 407 ]); 408 for (relative, expected_sha256) in expected { 409 let bytes = fs::read(consumer.path().join(relative)).expect("generated binding"); 410 assert_eq!( 411 format!("{:x}", Sha256::digest(bytes)), 412 expected_sha256, 413 "generated SDK binding {relative}" 414 ); 415 } 416 } 417 }