Files
IronStorage/tools/android-build/src/bindings.rs
T
hugo 6679361d47
Android ARM verification / android (push) Canceled after 0s
Android ARM verification / rust (push) Canceled after 12m1s
Establish native Linux ARM64 Android CI and verification gates (#94)
2026-10-08 18:30:05 +02:00

186 lines
9.5 KiB
Rust

//! 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 initializer = "public fun uniffiEnsureInitialized() {";
if source.matches(initializer).count() != 1 {
return Err(
"UniFFI initialization template changed; review eager contract initialization".into(),
);
}
// Object references deliberately trigger Kotlin initialization and integrity guards.
let source = source.replace(
initializer,
"@Suppress(\"UNUSED_EXPRESSION\")\npublic fun uniffiEnsureInitialized() {",
);
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])\"");
// Callback results are operation-owned; erase plaintext after copying into the wire buffer.
let returned_secret = "val writeReturn = { value: kotlin.ByteArray -> uniffiOutReturn.setValue(FfiConverterByteArray.lower(value)) }";
if source.contains("internal object uniffiCallbackInterfaceMobileAndroidKeystore")
&& source.matches(returned_secret).count() != 2
{
return Err("UniFFI secret callback template changed; review byte-array lifetime".into());
}
let source = source.replace(returned_secret, "val writeReturn = { value: kotlin.ByteArray -> try { uniffiOutReturn.setValue(FfiConverterByteArray.lower(value)) } finally { value.fill(0) } }");
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[2]
)
});
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 = "public fun uniffiEnsureInitialized() {\n IntegrityCheckingUniffiLib\n UniffiLib\n}\ndata 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("@Suppress(\"UNUSED_EXPRESSION\")\npublic fun uniffiEnsureInitialized()")
);
assert!(hardened.contains(" IntegrityCheckingUniffiLib\n"));
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 erases_owned_callback_results_after_serialization_even_on_failure() {
let source = SOURCE.to_owned()
+ "val writeReturn = { value: kotlin.ByteArray -> uniffiOutReturn.setValue(FfiConverterByteArray.lower(value)) }";
let hardened = harden_source(&source).unwrap();
assert!(hardened.contains("finally { value.fill(0) }"));
assert!(hardened.contains("FfiConverterByteArray.lower(value)"));
assert!(
harden_source(
&(SOURCE.to_owned()
+ "internal object uniffiCallbackInterfaceMobileAndroidKeystore")
)
.is_err()
);
}
#[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"));
}
}