lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }