Share the platform-neutral Rust mobile contract (#91)
Dependency security audit / rustsec (push) Failing after 22s
Dependency security audit / rustsec (push) Failing after 22s
This commit is contained in:
1 parent
fcb36a0684
commit
180851087b
52 files changed
+8932
-4263
No files matched your search
@@ -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: ®ex::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\(\) = ".*"$"#)?;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user