Package the Rust mobile core and generated Kotlin API for Android (#92)
Dependency security audit / rustsec (push) Failing after 24s

This commit is contained in:
hugo committed 2026-10-08 12:34:09 +02:00
1 parent e079b82b15
commit fcb36a0684
42 files changed
+1586 -172

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
//! Narrow, version-checked hardening of UniFFI 0.32's Kotlin diagnostics.
//! Records remain immutable data classes with unchanged wire layouts/checksums.
use std::{error::Error, fs, path::Path};
use regex::Regex;
pub fn prepare(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
if !input.is_dir() || input.is_symlink() || output.is_symlink() {
return Err("binding input and output must be distinct directories".into());
}
let input = input.canonicalize()?;
let destination = if output.exists() {
output.canonicalize()?
} else {
output
.parent()
.ok_or("output needs a parent")?
.canonicalize()?
.join(output.file_name().ok_or("output needs a name")?)
};
if input.starts_with(&destination) || destination.starts_with(&input) {
return Err("binding input/output directories must not overlap".into());
}
if output.exists() {
fs::remove_dir_all(output)?;
}
copy(&input, output)
}
fn copy(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
fs::create_dir_all(output)?;
let mut found = false;
for entry in fs::read_dir(input)? {
let entry = entry?;
let destination = output.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy(&entry.path(), &destination)?;
found = true;
} else if entry.file_type()?.is_file()
&& entry.path().extension().is_some_and(|ext| ext == "kt")
{
let source = fs::read_to_string(entry.path())?;
fs::write(destination, harden_source(&source)?)?;
found = true;
} else {
return Err("unexpected file in generated Kotlin bindings".into());
}
}
if !found {
return Err("UniFFI produced no Kotlin bindings".into());
}
Ok(())
}
fn harden_source(source: &str) -> Result<String, Box<dyn Error>> {
let internal = "class InternalException(message: String) : kotlin.Exception(message)";
let callback = "val err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }";
if !source.contains(internal) || source.matches(callback).count() != 2 {
return Err("UniFFI unexpected-error template changed; review secret redaction".into());
}
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")?;
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]
)
});
let errors = Regex::new(r#"(?m)(override val message\n\s*)get\(\) = ".*"$"#)?;
// Kotlin Exception.message is reserved; keep the Rust field/wire checksum unchanged.
let source = source
.replace(
"val `message`: kotlin.String",
"val `rustMessage`: kotlin.String",
)
.replace("value.`message`", "value.`rustMessage`");
let source = errors.replace_all(
&source,
"${1}get() = \"Rust operation failed ([REDACTED])\"",
);
let contract = "throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\")";
let checksum = "throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\")";
if !source.contains(contract) || !source.contains(checksum) {
return Err(
"UniFFI integrity template changed; checksum checks must remain enabled".into(),
);
}
let checksum_guard = Regex::new(
r#"if \(lib\.(\w+)\(\) != (-?\d+)\) \{\s*throw RuntimeException\("UniFFI API checksum mismatch: try cleaning and rebuilding your project"\)\s*\}"#,
)?;
let contract_guard = Regex::new(
r#"if \(bindings_contract_version != scaffolding_contract_version\) \{\s*throw RuntimeException\("UniFFI contract version mismatch: try cleaning and rebuilding your project"\)\s*\}"#,
)?;
if !checksum_guard.is_match(&source) || !contract_guard.is_match(&source) {
return Err(
"UniFFI integrity guard changed; refusing to generate unchecked bindings".into(),
);
}
let output = checksum_guard.replace_all(&source, "de.rfc1437.ironstorage.bridge.NativeBindingChecks.requireMatch(lib.${1}().toLong(), ${2}.toLong(), de.rfc1437.ironstorage.bridge.NativeFailureKind.ChecksumMismatch)");
let output = contract_guard.replace_all(&output, "de.rfc1437.ironstorage.bridge.NativeBindingChecks.requireMatch(scaffolding_contract_version.toLong(), bindings_contract_version.toLong(), de.rfc1437.ironstorage.bridge.NativeFailureKind.ContractMismatch)").into_owned();
if output.contains(contract) || output.contains(checksum) {
return Err("UniFFI integrity guard was not translated completely".into());
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCE: &str = "data class Secret (\n val value: String\n)\n{\n companion object\n}\nclass Failure {\n override val message\n get() = \"value=${value}\"\n}\nif (bindings_contract_version != scaffolding_contract_version) { throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\") }\nif (lib.checksum() != 1234) { throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\") }\nclass InternalException(message: String) : kotlin.Exception(message)\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\n";
#[test]
fn redacts_record_and_error_diagnostics_without_changing_fields_or_checksums() {
let hardened = harden_source(SOURCE).unwrap();
assert!(hardened.contains("val value: String"));
assert!(hardened.contains("Secret([REDACTED])"));
assert!(!hardened.contains("get() = \"value="));
assert!(hardened.contains("requireMatch(lib.checksum().toLong(), 1234.toLong()"));
assert!(hardened.contains("NativeFailureKind.ChecksumMismatch"));
assert!(hardened.contains("NativeFailureKind.ContractMismatch"));
assert!(!hardened.contains("stackTraceToString"));
assert!(!hardened.contains("kotlin.Exception(message)"));
}
#[test]
fn fails_closed_if_upstream_templates_or_integrity_checks_change() {
assert!(harden_source("data class Secret(val value: String)").is_err());
assert!(harden_source(&SOURCE.replace("UniFFI API checksum mismatch", "changed")).is_err());
}
#[test]
fn preserves_rust_message_fields_without_colliding_with_kotlin_exception_message() {
let source = SOURCE.replace("val value: String", "val `message`: kotlin.String")
+ "FfiConverterString.write(value.`message`, buf)";
let hardened = harden_source(&source).unwrap();
assert!(hardened.contains("val `rustMessage`: kotlin.String"));
assert!(hardened.contains("FfiConverterString.write(value.`rustMessage`, buf)"));
assert!(hardened.contains("override val message"));
}
}