commit 396b9ed397d2bd85935f04fc01f2348c931166f3
parent 235a396133ca0c5adde648396c8ed983b0d1185f
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 13:45:00 +0000
ios: regenerate sdk ffi bindings
- Add a governed UniFFI Swift generator routed through the private SDK FFI crate.
- Commit Swift source, C module artifacts, and an exact source-and-output lock.
- Enforce generated binding freshness through the SDK architecture gate.
- Verify repeat generation, Swift typechecking, xtask tests, and locked builds.
Diffstat:
5 files changed, 284 insertions(+), 3 deletions(-)
diff --git a/crates/sdk_ffi/Cargo.toml b/crates/sdk_ffi/Cargo.toml
@@ -14,10 +14,14 @@ readme = "README.md"
[lib]
crate-type = ["lib", "staticlib", "cdylib"]
+[[bin]]
+name = "radroots-sdk-bindgen"
+path = "src/bin/bindgen.rs"
+
[dependencies]
radroots_sdk = { workspace = true, features = ["memory"] }
thiserror = { workspace = true }
-uniffi = { workspace = true }
+uniffi = { workspace = true, features = ["cli"] }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
diff --git a/crates/sdk_ffi/src/bin/bindgen.rs b/crates/sdk_ffi/src/bin/bindgen.rs
@@ -0,0 +1,3 @@
+fn main() {
+ uniffi::uniffi_bindgen_main();
+}
diff --git a/tools/sdk_xtask_import/src/bindings.rs b/tools/sdk_xtask_import/src/bindings.rs
@@ -0,0 +1,253 @@
+use std::{
+ collections::BTreeMap,
+ env, fs,
+ path::{Path, PathBuf},
+ process::Command,
+};
+
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+
+use crate::fs::{workspace_root, write_if_changed};
+
+const FFI_PACKAGE: &str = "radroots_sdk_ffi";
+const FFI_VERSION: &str = "0.1.0-alpha";
+const BINDGEN_VERSION: &str = "0.29.5";
+
+#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
+struct SourceLock {
+ schema_version: u16,
+ source_package: String,
+ source_version: String,
+ source_sha256: String,
+ generator: String,
+ generator_version: String,
+ language: String,
+ outputs: BTreeMap<String, String>,
+}
+
+pub fn generate(args: &[String]) -> Result<(), String> {
+ match args {
+ [language] if language == "swift" => generate_language("swift"),
+ [language] if language == "kotlin" => generate_language("kotlin"),
+ _ => Err("usage: cargo xtask generate bindings <swift|kotlin>".to_owned()),
+ }
+}
+
+fn generate_language(language: &'static str) -> Result<(), String> {
+ let root = workspace_root()?;
+ build_bindgen(&root)?;
+ let target_dir = env::var_os("CARGO_TARGET_DIR")
+ .map(PathBuf::from)
+ .ok_or_else(|| "CARGO_TARGET_DIR must be set by extbuild for FFI generation".to_owned())?;
+ let library = target_dir.join("debug").join(format!(
+ "{}radroots_sdk_ffi{}",
+ env::consts::DLL_PREFIX,
+ env::consts::DLL_SUFFIX
+ ));
+ if !library.is_file() {
+ return Err(format!("missing built FFI library: {}", library.display()));
+ }
+ let bindgen = target_dir
+ .join("debug")
+ .join(format!("radroots-sdk-bindgen{}", env::consts::EXE_SUFFIX));
+ if !bindgen.is_file() {
+ return Err(format!(
+ "missing FFI bindgen executable: {}",
+ bindgen.display()
+ ));
+ }
+ let temporary = tempfile::tempdir()
+ .map_err(|error| format!("failed to create binding output directory: {error}"))?;
+ let status = Command::new(&bindgen)
+ .args(["generate", "--library"])
+ .arg(&library)
+ .args(["--crate", FFI_PACKAGE, "--language", language, "--out-dir"])
+ .arg(temporary.path())
+ .current_dir(&root)
+ .status()
+ .map_err(|error| format!("failed to start {}: {error}", bindgen.display()))?;
+ if !status.success() {
+ return Err(format!(
+ "{language} binding generation failed with {status}"
+ ));
+ }
+ install_outputs(&root, language, temporary.path())?;
+ println!("generated {language} bindings for {FFI_PACKAGE}");
+ Ok(())
+}
+
+fn build_bindgen(root: &Path) -> Result<(), String> {
+ let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
+ let status = Command::new(cargo)
+ .args([
+ "build",
+ "--locked",
+ "-p",
+ FFI_PACKAGE,
+ "--lib",
+ "--bin",
+ "radroots-sdk-bindgen",
+ ])
+ .current_dir(root)
+ .status()
+ .map_err(|error| format!("failed to start Cargo for FFI generation: {error}"))?;
+ if !status.success() {
+ return Err(format!(
+ "FFI library and bindgen build failed with {status}"
+ ));
+ }
+ Ok(())
+}
+
+fn install_outputs(root: &Path, language: &'static str, temporary: &Path) -> Result<(), String> {
+ let output_dir = root.join("generated").join(language);
+ fs::create_dir_all(&output_dir)
+ .map_err(|error| format!("failed to create {}: {error}", output_dir.display()))?;
+ let mut generated = fs::read_dir(temporary)
+ .map_err(|error| format!("failed to read {}: {error}", temporary.display()))?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|error| format!("failed to enumerate {}: {error}", temporary.display()))?;
+ generated.sort_by_key(std::fs::DirEntry::file_name);
+ let mut outputs = BTreeMap::new();
+ for entry in generated {
+ let path = entry.path();
+ if !path.is_file() {
+ return Err(format!(
+ "unexpected generated binding entry: {}",
+ path.display()
+ ));
+ }
+ let name = entry
+ .file_name()
+ .into_string()
+ .map_err(|_| format!("non-UTF-8 generated binding path: {}", path.display()))?;
+ let contents = fs::read(&path)
+ .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
+ let destination = output_dir.join(&name);
+ let text = String::from_utf8(contents)
+ .map_err(|error| format!("generated text binding is not UTF-8: {error}"))?;
+ let text = normalize_generated_text(&text);
+ outputs.insert(
+ name.clone(),
+ format!("{:x}", Sha256::digest(text.as_bytes())),
+ );
+ write_if_changed(&destination, &text)?;
+ }
+ if outputs.is_empty() {
+ return Err(format!("{language} binding generator emitted no files"));
+ }
+ let lock = SourceLock {
+ schema_version: 1,
+ source_package: FFI_PACKAGE.to_owned(),
+ source_version: FFI_VERSION.to_owned(),
+ source_sha256: ffi_source_sha256(root)?,
+ generator: "uniffi".to_owned(),
+ generator_version: BINDGEN_VERSION.to_owned(),
+ language: language.to_owned(),
+ outputs,
+ };
+ let mut rendered = serde_json::to_string_pretty(&lock)
+ .map_err(|error| format!("failed to render {language} source lock: {error}"))?;
+ rendered.push('\n');
+ write_if_changed(&output_dir.join("source.lock"), &rendered).map(|_| ())
+}
+
+fn normalize_generated_text(contents: &str) -> String {
+ let mut normalized = contents
+ .lines()
+ .map(str::trim_end)
+ .collect::<Vec<_>>()
+ .join("\n");
+ while normalized.ends_with('\n') {
+ normalized.pop();
+ }
+ normalized.push('\n');
+ normalized
+}
+
+pub fn check(root: &Path) -> Result<(), String> {
+ check_language(
+ root,
+ "swift",
+ &[
+ "radroots_sdk.swift",
+ "radroots_sdkFFI.h",
+ "radroots_sdkFFI.modulemap",
+ ],
+ )
+}
+
+fn check_language(root: &Path, language: &'static str, expected: &[&str]) -> Result<(), String> {
+ let output_dir = root.join("generated").join(language);
+ let lock_path = output_dir.join("source.lock");
+ let raw = fs::read_to_string(&lock_path)
+ .map_err(|error| format!("failed to read {}: {error}", lock_path.display()))?;
+ let actual: SourceLock = serde_json::from_str(&raw)
+ .map_err(|error| format!("failed to parse {}: {error}", lock_path.display()))?;
+ let mut outputs = BTreeMap::new();
+ for name in expected {
+ let path = output_dir.join(name);
+ let contents = fs::read(&path)
+ .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
+ outputs.insert(
+ (*name).to_owned(),
+ format!("{:x}", Sha256::digest(contents)),
+ );
+ }
+ let expected_lock = SourceLock {
+ schema_version: 1,
+ source_package: FFI_PACKAGE.to_owned(),
+ source_version: FFI_VERSION.to_owned(),
+ source_sha256: ffi_source_sha256(root)?,
+ generator: "uniffi".to_owned(),
+ generator_version: BINDGEN_VERSION.to_owned(),
+ language: language.to_owned(),
+ outputs,
+ };
+ if actual != expected_lock {
+ return Err(format!(
+ "stale generated {language} bindings: {}",
+ lock_path.display()
+ ));
+ }
+ Ok(())
+}
+
+fn ffi_source_sha256(root: &Path) -> Result<String, String> {
+ let crate_dir = root.join("crates/ffi");
+ let mut paths = vec![crate_dir.join("Cargo.toml"), crate_dir.join("README.md")];
+ collect_files(&crate_dir.join("src"), &mut paths)?;
+ paths.sort();
+ let mut hasher = Sha256::new();
+ for path in paths {
+ let relative = path
+ .strip_prefix(root)
+ .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
+ hasher.update(relative.to_string_lossy().as_bytes());
+ hasher.update([0]);
+ hasher.update(
+ fs::read(&path)
+ .map_err(|error| format!("failed to read {}: {error}", path.display()))?,
+ );
+ hasher.update([0]);
+ }
+ Ok(format!("{:x}", hasher.finalize()))
+}
+
+fn collect_files(dir: &Path, paths: &mut Vec<PathBuf>) -> Result<(), String> {
+ let mut entries = fs::read_dir(dir)
+ .map_err(|error| format!("failed to read {}: {error}", dir.display()))?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|error| format!("failed to enumerate {}: {error}", dir.display()))?;
+ entries.sort_by_key(std::fs::DirEntry::file_name);
+ for entry in entries {
+ let path = entry.path();
+ if path.is_dir() {
+ collect_files(&path, paths)?;
+ } else if path.is_file() {
+ paths.push(path);
+ }
+ }
+ Ok(())
+}
diff --git a/tools/sdk_xtask_import/src/check.rs b/tools/sdk_xtask_import/src/check.rs
@@ -154,6 +154,7 @@ pub fn check() -> Result<(), String> {
cli_host::check_cli_host()?;
check_package_build_artifacts(&root)?;
check_npm_pack_payloads(&root)?;
+ crate::bindings::check(&root)?;
Ok(())
}
@@ -166,7 +167,8 @@ pub fn architecture_ci(root: &Path) -> Result<(), String> {
check_forbidden_packages(root)?;
check_binding_crate_sources(root)?;
check_package_source_metadata(root)?;
- check_generated_outputs(root)
+ check_generated_outputs(root)?;
+ crate::bindings::check(root)
}
fn check_radroots_facade_conformance(root: &Path) -> Result<(), String> {
diff --git a/tools/sdk_xtask_import/src/main.rs b/tools/sdk_xtask_import/src/main.rs
@@ -1,4 +1,5 @@
mod architecture;
+mod bindings;
mod check;
mod cli_host;
mod contracts;
@@ -23,6 +24,7 @@ enum CommandAction<'a> {
CheckApiBoundaries,
CheckDependencyBoundaries,
GenerateAll,
+ GenerateBindings(&'a [String]),
GenerateTs,
GenerateWasm(&'a [String]),
GeneratePackageMetadata,
@@ -54,6 +56,7 @@ fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> {
architecture::validate_dependency_boundaries(&fs::workspace_root()?)
}
CommandAction::GenerateAll => generate::generate_all(),
+ CommandAction::GenerateBindings(rest) => bindings::generate(rest),
CommandAction::GenerateTs => generate::generate_ts(),
CommandAction::GenerateWasm(rest) => wasm::generate(rest),
CommandAction::GeneratePackageMetadata => generate::generate_package_metadata(),
@@ -78,6 +81,9 @@ fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> {
[command, target, rest @ ..] if command == "generate" && target == "wasm" => {
Ok(CommandAction::GenerateWasm(rest))
}
+ [command, target, rest @ ..] if command == "generate" && target == "bindings" => {
+ Ok(CommandAction::GenerateBindings(rest))
+ }
[command, target] if command == "generate" && target == "package-metadata" => {
Ok(CommandAction::GeneratePackageMetadata)
}
@@ -90,7 +96,7 @@ fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> {
}
fn usage() -> String {
- "usage: cargo xtask architecture | cargo xtask architecture-ci | cargo xtask check-api-boundaries | cargo xtask check-dependency-boundaries | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local | cargo xtask coverage run"
+ "usage: cargo xtask architecture | cargo xtask architecture-ci | cargo xtask check-api-boundaries | cargo xtask check-dependency-boundaries | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate bindings <swift|kotlin> | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local | cargo xtask coverage run"
.to_owned()
}
@@ -162,6 +168,19 @@ mod tests {
}
#[test]
+ fn accepts_generate_bindings() {
+ let args = [
+ "generate".to_owned(),
+ "bindings".to_owned(),
+ "swift".to_owned(),
+ ];
+ assert!(matches!(
+ command_action(&args).expect("action"),
+ CommandAction::GenerateBindings(rest) if rest == ["swift"]
+ ));
+ }
+
+ #[test]
fn accepts_generate_package_metadata() {
let args = ["generate".to_owned(), "package-metadata".to_owned()];
assert!(matches!(