Share the platform-neutral Rust mobile contract (#91)
Dependency security audit / rustsec (push) Failing after 22s

This commit is contained in:
hugo committed 2026-10-08 13:25:09 +02:00
1 parent fcb36a0684
commit 180851087b
52 files changed
+8932 -4263

No files matched your search

+2 -2
View File
@@ -61,14 +61,14 @@ fn harden_source(source: &str) -> Result<String, Box<dyn Error>> {
}
let source = source.replace(internal, "class InternalException(@Suppress(\"UNUSED_PARAMETER\") message: String) : kotlin.Exception(\"Native Rust failure ([REDACTED])\")")
.replace(callback, "val err = \"Native callback failed ([REDACTED])\"");
let records = Regex::new(r"(?ms)^data class (\w+) \((.*?)\n\)([^\{]*)\{\n")?;
let records = Regex::new(r"(?ms)^([ \t]*)data class (\w+)\s*\((.*?)\)[^\{]*\{\n")?;
if !records.is_match(&source) {
return Err("UniFFI record template changed; review diagnostic hardening".into());
}
let source = records.replace_all(&source, |captures: &regex::Captures<'_>| {
format!(
"{} override fun toString(): String = \"{}([REDACTED])\"\n",
&captures[0], &captures[1]
&captures[0], &captures[2]
)
});
let errors = Regex::new(r#"(?m)(override val message\n\s*)get\(\) = ".*"$"#)?;
+315
View File
@@ -0,0 +1,315 @@
//! Generate both languages from compiled metadata and verify their whole public inventory.
use std::{collections::BTreeSet, error::Error, fs, path::Path};
use camino::Utf8PathBuf;
use regex::Regex;
use uniffi_bindgen::{EmptyCrateConfigSupplier, interface::AsType};
fn utf8(path: &Path) -> Result<Utf8PathBuf, Box<dyn Error>> {
Ok(Utf8PathBuf::from_path_buf(path.to_owned()).map_err(|_| "non-UTF8 binding path")?)
}
pub fn generate(
library: &Path,
config: &Path,
output: &Path,
watch: bool,
) -> Result<(), Box<dyn Error>> {
use uniffi::{GenerateOptions, TargetLanguage};
let library = utf8(library)?;
let config = utf8(config)?;
let out = utf8(output)?;
fs::create_dir_all(output)?;
for (language, directory) in [
(TargetLanguage::Swift, "swift"),
(TargetLanguage::Kotlin, "kotlin"),
] {
uniffi::generate(GenerateOptions {
languages: vec![language],
source: library.clone(),
out_dir: out.join(directory),
config_override: Some(config.clone()),
format: false,
..Default::default()
})?;
}
let cis = uniffi_bindgen::library_mode::find_cis(&library, &EmptyCrateConfigSupplier)?;
if cis.len() != 1 || cis[0].namespace() != "ironstorage_mobile" {
return Err("expected one shared mobile UniFFI component".into());
}
let ci = &cis[0];
let swift = fs::read_to_string(output.join("swift/ironstorage_mobile.swift"))?;
let kotlin = read_kotlin(&output.join("kotlin"))?;
let mut inventory = BTreeSet::new();
let mut names = BTreeSet::new();
for record in ci.record_definitions() {
names.insert(record.name().to_owned());
inventory.insert(format!("record\t{}", record.name()));
for field in record.fields() {
let name = camel(field.name());
if !swift.contains(&format!("var {name}:")) || !kotlin.contains(&format!("`{name}`:")) {
return Err(format!(
"record field missing in a generated language: {}.{name}",
record.name()
)
.into());
}
inventory.insert(format!(
"field\t{}.{}\t{:?}",
record.name(),
field.name(),
field.as_type()
));
}
}
for value in ci.enum_definitions() {
names.insert(value.name().to_owned());
inventory.insert(format!("enum\t{}", value.name()));
for variant in value.variants() {
inventory.insert(format!(
"variant\t{}.{}\t{:?}",
value.name(),
variant.name(),
variant
.fields()
.iter()
.map(|field| field.as_type())
.collect::<Vec<_>>()
));
}
}
for object in ci.object_definitions() {
names.insert(object.name().to_owned());
inventory.insert(format!("object\t{}", object.name()));
for method in object.methods() {
inventory.insert(format!(
"method\t{}.{}\t{:?}\t{:?}",
object.name(),
method.name(),
method
.arguments()
.iter()
.map(|argument| argument.as_type())
.collect::<Vec<_>>(),
method.return_type()
));
}
}
for function in ci.function_definitions() {
let name = camel(function.name());
if !swift.contains(&format!("func {name}("))
|| !kotlin.contains(&format!("fun `{name}`("))
&& !kotlin.contains(&format!("fun {name}("))
{
return Err(format!("function missing from generated language: {name}").into());
}
inventory.insert(format!(
"function\t{}\t{:?}\t{:?}",
function.name(),
function
.arguments()
.iter()
.map(|argument| argument.as_type())
.collect::<Vec<_>>(),
function.return_type()
));
}
for name in names {
let kotlin_name = if ci.is_name_used_as_error(&name) {
format!("{}Exception", name.strip_suffix("Error").unwrap_or(&name))
} else {
name.clone()
};
if !swift.contains(&name) || !kotlin.contains(&kotlin_name) {
return Err(format!("type missing from generated language: {name}").into());
}
}
// Both generated languages must verify every native operation checksum, not just its name.
for checksum in ci.iter_checksum_ffi_functions() {
if !swift.contains(checksum.name()) || !kotlin.contains(checksum.name()) {
return Err(format!("missing integrity check: {}", checksum.name()).into());
}
}
if !watch
&& inventory
.iter()
.any(|line| line.to_ascii_lowercase().contains("watch"))
{
return Err("Watch data leaked into the phone contract".into());
}
if watch
&& !inventory
.iter()
.any(|line| line.contains("MobileWatchSnapshotTransfer"))
{
return Err("Apple contract lost Watch transfer".into());
}
fs::write(
output.join("inventory.tsv"),
inventory.into_iter().collect::<Vec<_>>().join("\n") + "\n",
)?;
prepare_swift(
&output.join("swift/ironstorage_mobile.swift"),
&output.join("swift/ironstorage_mobile.swift"),
)?;
let header = output.join("swift/ironstorage_mobileFFI.h");
fs::write(&header, normalized_lines(&fs::read_to_string(&header)?))?;
println!("Verified Swift/Kotlin inventory and operation checksums (watch={watch})");
Ok(())
}
pub fn compare(phone: &Path, watch: &Path) -> Result<(), Box<dyn Error>> {
let phone = fs::read_to_string(phone)?;
let watch = fs::read_to_string(watch)?;
let shared = |source: &str| -> BTreeSet<String> {
source
.lines()
.filter(|line| {
!line.to_ascii_lowercase().contains("watch")
&& !line.starts_with("method\tMobileAuthentication.preferences\t")
})
.map(str::to_owned)
.collect()
};
let phone = shared(&phone);
let watch = shared(&watch);
if phone != watch {
return Err(format!(
"phone/Apple public API drift: phone-only={:?}, Apple-only={:?}",
phone.difference(&watch).collect::<Vec<_>>(),
watch.difference(&phone).collect::<Vec<_>>()
)
.into());
}
println!(
"Verified {} identical shared phone feature/signature entries",
phone.len()
);
Ok(())
}
fn camel(name: &str) -> String {
let mut parts = name.split('_');
let mut output = parts.next().unwrap_or_default().to_owned();
for part in parts {
let mut chars = part.chars();
if let Some(first) = chars.next() {
output.extend(first.to_uppercase());
output.extend(chars);
}
}
output
}
fn read_kotlin(path: &Path) -> Result<String, Box<dyn Error>> {
let mut source = String::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
source.push_str(&read_kotlin(&entry.path())?);
} else if entry.path().extension().is_some_and(|ext| ext == "kt") {
source.push_str(&fs::read_to_string(entry.path())?);
}
}
Ok(source)
}
fn normalized_lines(source: &str) -> String {
source
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
.trim_end()
.to_owned()
+ "\n"
}
pub fn prepare_swift(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
let source = fs::read_to_string(input)?;
if source.contains("// IronStorage diagnostic redaction") {
return Err("Swift diagnostics already prepared".into());
}
let records = Regex::new(r"(?m)^public struct (Mobile\w+)[ :]")?;
let mut names = records
.captures_iter(&source)
.map(|capture| capture[1].to_owned())
.collect::<BTreeSet<_>>();
// Error/associated-value enums also expose payloads through Swift's default reflection.
let error_names = Regex::new(r"(?m)^enum (Mobile\w+Error):")?;
names.extend(
error_names
.captures_iter(&source)
.map(|capture| capture[1].to_owned()),
);
for name in ["MobileSecretLocator", "MobileClipboardContent"] {
if source.contains(&format!("enum {name}")) {
names.insert(name.into());
}
}
if names.is_empty() {
return Err("Swift record template changed".into());
}
let panic_payload =
"throw UniffiInternalError.rustPanic(try FfiConverterString.lift(callStatus.errorBuf))";
let callback_payload = "FfiConverterString.lower(String(describing: error))";
if source.contains("fileprivate enum UniffiInternalError")
&& (!source.contains(panic_payload) || source.matches(callback_payload).count() != 2)
{
return Err("UniFFI Swift unexpected-error template changed".into());
}
let source = source.replace(panic_payload, "_ = try FfiConverterString.lift(callStatus.errorBuf)\n throw UniffiInternalError.rustPanic(\"Native Rust failure ([REDACTED])\")")
.replace(callback_payload, "FfiConverterString.lower(\"Native callback failed ([REDACTED])\")")
.replace("} catch let error {\n callStatus.pointee.code = CALL_UNEXPECTED_ERROR", "} catch {\n callStatus.pointee.code = CALL_UNEXPECTED_ERROR")
.replace("case let .rustPanic(message): return message", "case .rustPanic: return \"Native Rust failure ([REDACTED])\"");
let mut output_source = source;
output_source.push_str(
"\n// IronStorage diagnostic redaction; wire values and checksums remain generated.\n",
);
for name in names {
output_source.push_str(&format!("extension {name}: CustomStringConvertible, CustomDebugStringConvertible {{\n public var description: String {{ \"{name}([REDACTED])\" }}\n public var debugDescription: String {{ description }}\n}}\n"));
}
fs::write(output, normalized_lines(&output_source))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inventory_comparison_rejects_missing_methods_and_type_changes() {
let root =
std::env::temp_dir().join(format!("ironstorage-contract-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let phone = root.join("phone.tsv");
let watch = root.join("watch.tsv");
fs::write(&phone, "method\tMobileAuthentication.state\t[]\tBool\n").unwrap();
fs::write(&watch, "method\tMobileAuthentication.state\t[]\tString\n").unwrap();
assert!(compare(&phone, &watch).is_err());
fs::write(
&watch,
"method\tMobileAuthentication.state\t[]\tBool\nfield\tMobileTotpPage.watch\tWatch\n",
)
.unwrap();
compare(&phone, &watch).unwrap();
fs::remove_dir_all(root).unwrap();
}
#[test]
fn swift_record_and_error_reflection_are_redacted() {
let root = std::env::temp_dir().join(format!(
"ironstorage-swift-redaction-{}",
std::process::id()
));
fs::create_dir_all(&root).unwrap();
let input = root.join("raw.swift");
let output = root.join("prepared.swift");
fs::write(&input, "public struct MobileEntryCopy: Equatable { public var value: String }\npublic\nenum MobileAuthenticationFfiError: Swift.Error {}\n").unwrap();
prepare_swift(&input, &output).unwrap();
let hardened = fs::read_to_string(output).unwrap();
assert!(hardened.contains("MobileEntryCopy([REDACTED])"));
assert!(hardened.contains("MobileAuthenticationFfiError([REDACTED])"));
assert!(hardened.contains("public var value: String"));
fs::remove_dir_all(root).unwrap();
}
}
+17 -1
View File
@@ -4,6 +4,7 @@ use ironstorage::mobile::{MobileShell, MobileShellState};
use std::{env, error::Error, fs, path::Path};
mod bindings;
mod contract;
mod native;
mod toolchain;
@@ -16,6 +17,20 @@ fn main() -> Result<(), Box<dyn Error>> {
fs::write(path, fixtures())?;
}
[_, command, path] if command == "check" => check(Path::new(path))?,
[_, command, library, config, output, mode] if command == "contract-bindings" => {
contract::generate(
Path::new(library),
Path::new(config),
Path::new(output),
mode == "watch",
)?;
}
[_, command, phone, watch] if command == "compare-contract" => {
contract::compare(Path::new(phone), Path::new(watch))?;
}
[_, command, input, output] if command == "prepare-swift" => {
contract::prepare_swift(Path::new(input), Path::new(output))?;
}
[_, command, input, output] if command == "prepare-bindings" => {
bindings::prepare(Path::new(input), Path::new(output))?;
}
@@ -68,11 +83,12 @@ fn fixtures() -> String {
output.push_str(&format!(" ShellState.{state:?} to listOf(\n"));
for page in MobileShell::fixture(state).pages() {
output.push_str(&format!(
" ShellPage(ShellTab.{:?}, {}, {}, {}),\n",
" ShellPage(ShellTab.{:?}, {}, {}, {}, ShellIcon.{:?}),\n",
page.tab(),
kotlin_string(page.title()),
kotlin_string(page.state_title()),
kotlin_string(page.state_detail()),
page.icon(),
));
}
output.push_str(" ),\n");