186 lines
9.5 KiB
Rust
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: ®ex::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"));
|
|
}
|
|
}
|