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

@@ -0,0 +1,128 @@
package de.rfc1437.ironstorage.bridge
import android.content.ContextWrapper
import android.content.pm.ApplicationInfo
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.FfiConverterString
import de.rfc1437.ironstorage.bridge.generated.IntegrityCheckingUniffiLib
import de.rfc1437.ironstorage.bridge.generated.InternalException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
import de.rfc1437.ironstorage.bridge.generated.MobileTab
import de.rfc1437.ironstorage.bridge.generated.UniffiRustCallStatus
import de.rfc1437.ironstorage.bridge.generated.uniffiTraitInterfaceCall
import java.io.File
import java.util.zip.ZipOutputStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Test
class NativeRuntimeTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
@Test
fun generatedBindingsCallEveryRustFixtureAndRedactDiagnostics() {
val runtime = NativeRuntime(context)
MobileShellState.entries.forEach { state ->
val shell = runtime.fixture(state)
assertEquals(MobileTab.entries, shell.pages.map { it.tab })
assertEquals(setOf(state), shell.pages.map { it.state }.toSet())
}
val secret = "NativeFixtureSecretMustNeverAppearInDiagnostics"
val copy = MobileEntryCopy(secret, 60uL)
assertEquals(secret, copy.value)
assertFalse(copy.toString().contains(secret))
val error = MobileAuthenticationFfiException.Failed(
MobileAuthenticationErrorKind.entries.first(),
secret,
secret
)
assertFalse(error.toString().contains(secret))
assertFalse(error.message.contains(secret))
assertFalse(InternalException(secret).toString().contains(secret))
val status = UniffiRustCallStatus()
uniffiTraitInterfaceCall<Unit>(status, { throw IllegalStateException(secret) }, {})
assertEquals(
"Native callback failed ([REDACTED])",
FfiConverterString.lift(status.error_buf)
)
}
@Test
fun wrongNativeContractAndChecksumFailWithTypedRedactedErrors() {
NativeRuntime(context).fixture(MobileShellState.EMPTY)
val version = IntegrityCheckingUniffiLib
.ffi_ironstorage_apple_uniffi_contract_version().toLong()
val checksum = IntegrityCheckingUniffiLib
.uniffi_ironstorage_apple_checksum_func_mobile_shell_fixture().toLong() and 0xffffL
NativeBindingChecks.requireMatch(-27278, 38258, NativeFailureKind.ChecksumMismatch)
for ((actual, kind) in listOf(
version to NativeFailureKind.ContractMismatch,
checksum to NativeFailureKind.ChecksumMismatch
)) {
val failure = assertThrows(NativeBindingMismatchException::class.java) {
NativeBindingChecks.requireMatch(actual, actual + 1, kind)
}
assertEquals(kind, failure.kind)
assertEquals("Rust bindings do not match the packaged native library", failure.message)
}
}
@Test
fun mainThreadAndUnreadablePackagesFailWithTypedErrors() {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
val failure = assertThrows(NativeBridgeException::class.java) {
NativeRuntime(context).fixture(MobileShellState.EMPTY)
}
assertEquals(NativeFailureKind.MainThreadCall, failure.kind)
}
val packageFile = File.createTempFile("native-corrupt-package", ".apk", context.cacheDir)
try {
val info = ApplicationInfo(context.applicationInfo).apply {
sourceDir = packageFile.absolutePath
splitSourceDirs = null
nativeLibraryDir = File(context.cacheDir, "missing-native-fixture").absolutePath
}
val invalid = object : ContextWrapper(context) {
override fun getApplicationContext() = this
override fun getApplicationInfo() = info
}
val failure = assertThrows(NativeBridgeException::class.java) {
NativeRuntime(invalid).fixture(MobileShellState.EMPTY)
}
assertEquals(NativeFailureKind.LoadFailure, failure.kind)
} finally {
packageFile.delete()
}
}
@Test
fun missingLibraryAndUnsupportedAbiFailBeforeEnteringRust() {
val abiFailure = assertThrows(NativeBridgeException::class.java) {
NativeRuntime.supportedAbi(listOf("unsupported-fixture-abi"))
}
assertEquals(NativeFailureKind.UnsupportedAbi, abiFailure.kind)
val packageFile = File.createTempFile("native-empty-package", ".apk", context.cacheDir)
try {
ZipOutputStream(packageFile.outputStream()).close()
val info = ApplicationInfo(context.applicationInfo).apply {
sourceDir = packageFile.absolutePath
splitSourceDirs = null
nativeLibraryDir = File(context.cacheDir, "missing-native-fixture").absolutePath
}
val missing = object : ContextWrapper(context) {
override fun getApplicationContext() = this
override fun getApplicationInfo() = info
}
val failure = assertThrows(NativeBridgeException::class.java) {
NativeRuntime(missing).fixture(MobileShellState.EMPTY)
}
assertEquals(NativeFailureKind.MissingLibrary, failure.kind)
} finally {
packageFile.delete()
}
}
}
@@ -0,0 +1,9 @@
package de.rfc1437.ironstorage.bridge
internal object NativeBindingChecks {
fun requireMatch(actual: Long, expected: Long, kind: NativeFailureKind) {
// UniFFI checksums are u16; JNA's Int return can include unspecified upper bits.
val value = if (kind == NativeFailureKind.ChecksumMismatch) actual and 0xffffL else actual
if (value != expected) throw NativeBindingMismatchException(kind)
}
}
@@ -0,0 +1,17 @@
package de.rfc1437.ironstorage.bridge
enum class NativeFailureKind {
UnsupportedAbi,
MissingLibrary,
ContractMismatch,
ChecksumMismatch,
LoadFailure,
RustFailure,
MainThreadCall
}
class NativeBindingMismatchException(val kind: NativeFailureKind) :
LinkageError("Rust bindings do not match the packaged native library")
class NativeBridgeException(val kind: NativeFailureKind) :
Exception("Native Rust runtime unavailable: $kind")
@@ -0,0 +1,80 @@
package de.rfc1437.ironstorage.bridge
import android.content.Context
import android.os.Build
import android.os.Looper
import de.rfc1437.ironstorage.bridge.generated.MobileShell
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
import de.rfc1437.ironstorage.bridge.generated.mobileShellFixture
import de.rfc1437.ironstorage.bridge.generated.uniffiEnsureInitialized
import java.io.File
import java.io.IOException
import java.util.concurrent.CancellationException
import java.util.zip.ZipFile
/** OS library loading only. Callers schedule all native calls off the main thread. */
class NativeRuntime(context: Context) {
private val applicationInfo = context.applicationContext.applicationInfo
private val lock = Any()
private var initialized = false
private var initializationFailure: NativeFailureKind? = null
fun fixture(state: MobileShellState): MobileShell = call {
initialize()
mobileShellFixture(state)
}
private fun initialize() = synchronized(lock) {
initializationFailure?.let { throw NativeBridgeException(it) }
if (!initialized) {
val abi = supportedAbi(Build.SUPPORTED_ABIS.toList())
val extracted = File(applicationInfo.nativeLibraryDir, LIBRARY_FILE).isFile
val packages =
listOf(applicationInfo.sourceDir) + applicationInfo.splitSourceDirs.orEmpty()
val packaged = extracted || packages.any { path ->
ZipFile(path).use { it.getEntry("lib/$abi/$LIBRARY_FILE") != null }
}
if (!packaged) throw NativeBridgeException(NativeFailureKind.MissingLibrary)
System.loadLibrary(LIBRARY)
uniffiEnsureInitialized()
initialized = true
}
}
private fun <T> call(operation: () -> T): T {
if (Looper.myLooper() == Looper.getMainLooper()) {
throw NativeBridgeException(NativeFailureKind.MainThreadCall)
}
try {
return operation()
} catch (error: NativeBridgeException) {
fail(error.kind)
} catch (error: CancellationException) {
throw error
} catch (error: NativeBindingMismatchException) {
fail(error.kind)
} catch (_: IOException) {
fail(NativeFailureKind.LoadFailure)
} catch (_: LinkageError) {
fail(NativeFailureKind.LoadFailure)
} catch (_: Exception) {
fail(NativeFailureKind.RustFailure)
}
}
private fun fail(kind: NativeFailureKind): Nothing {
synchronized(lock) {
if (!initialized) initializationFailure = kind
}
throw NativeBridgeException(kind)
}
companion object {
private const val LIBRARY = "ironstorage_android"
private const val LIBRARY_FILE = "libironstorage_android.so"
internal fun supportedAbi(deviceAbis: List<String>): String =
deviceAbis.firstOrNull { it in setOf("arm64-v8a", "armeabi-v7a", "x86_64") }
?: throw NativeBridgeException(NativeFailureKind.UnsupportedAbi)
}
}