Package the Rust mobile core and generated Kotlin API for Android (#92)
Dependency security audit / rustsec (push) Failing after 24s
Dependency security audit / rustsec (push) Failing after 24s
This commit is contained in:
1 parent
e079b82b15
commit
fcb36a0684
42 files changed
+1586
-172
No files matched your search
@@ -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: ®ex::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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user