feat(android): implement private Rust storage runtime (#90)
Dependency security audit / rustsec (push) Failing after 23s

This commit is contained in:
hugo committed 2026-10-08 14:36:05 +02:00
1 parent 180851087b
commit 874c8139a4
36 files changed
+2848 -39

No files matched your search

@@ -0,0 +1,42 @@
package de.rfc1437.ironstorage
import android.content.pm.ApplicationInfo
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import org.xmlpull.v1.XmlPullParser
class BackupPolicyTest {
@Test
fun installedResourcesExcludeCredentialAndDeviceProtectedDomains() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals(0, context.applicationInfo.flags and ApplicationInfo.FLAG_ALLOW_BACKUP)
val domains = setOf(
"root", "file", "database", "sharedpref", "external",
"device_root", "device_file", "device_database", "device_sharedpref"
)
for ((resource, sections) in mapOf(
R.xml.backup_rules to setOf("full-backup-content"),
R.xml.data_extraction_rules to setOf("cloud-backup", "device-transfer")
)) {
context.resources.getXml(resource).use { xml ->
val excluded = mutableMapOf<String, MutableSet<String>>()
var section = ""
while (xml.eventType != XmlPullParser.END_DOCUMENT) {
if (xml.eventType == XmlPullParser.START_TAG) {
if (xml.name in sections) section = xml.name
assertFalse(xml.name == "include")
if (xml.name == "exclude") {
assertEquals(".", xml.getAttributeValue(null, "path"))
excluded.getOrPut(section) { mutableSetOf() }
.add(xml.getAttributeValue(null, "domain"))
}
}
xml.next()
}
assertEquals(sections.associateWith { domains }, excluded)
}
}
}
}
@@ -8,6 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.platform.privateDirectories
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -21,6 +22,7 @@ object ShellModule {
fun shellRepository(@ApplicationContext context: Context): ShellRepository =
NativeFixtureShellRepository(
context,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
{ privateDirectories(context) }
)
}
@@ -4,4 +4,8 @@
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</full-backup-content>
@@ -5,6 +5,10 @@
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
@@ -12,5 +16,9 @@
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
@@ -5,8 +5,13 @@ import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.NativeFailureKind
import de.rfc1437.ironstorage.bridge.NativeRuntime
import de.rfc1437.ironstorage.bridge.generated.MobileIcon
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateRuntime
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
import de.rfc1437.ironstorage.bridge.generated.MobileTab
import de.rfc1437.ironstorage.bridge.generated.mobilePrivateStorageFailure
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -17,12 +22,13 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** #92's native fixture integration; repository initialization is owned by #90. */
/** Rust-owned private initialization; debug controls can switch to explicit fixtures. */
class NativeFixtureShellRepository internal constructor(
private val scope: CoroutineScope,
private val load: suspend (ShellState) -> ShellSnapshot
) : ShellRepository {
constructor(context: Context, scope: CoroutineScope) : this(scope, nativeLoader(context))
constructor(context: Context, scope: CoroutineScope, directories: () -> PrivateDirectories) :
this(scope, nativeLoader(context, directories))
private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList()))
override val snapshot: StateFlow<ShellSnapshot> = mutableSnapshot
private val revision = AtomicLong()
@@ -46,17 +52,52 @@ class NativeFixtureShellRepository internal constructor(
ShellSnapshot(ShellState.Error, emptyList(), nativeFailure = error.kind.toFacade())
}
if (revision.get() == operation) {
mutableSnapshot.update { result.copy(selectedTab = it.selectedTab) }
mutableSnapshot.update {
result.copy(
selectedTab = if (it.pages.isEmpty()) result.selectedTab else it.selectedTab
)
}
}
}
}
}
private fun nativeLoader(context: Context): suspend (ShellState) -> ShellSnapshot {
private fun nativeLoader(
context: Context,
directoryProvider: () -> PrivateDirectories
): suspend (ShellState) -> ShellSnapshot {
val runtime = NativeRuntime(context)
var storage: MobilePrivateRuntime? = null
var firstLoad = true
return { state ->
withContext(Dispatchers.IO) {
val shell = runtime.fixture(state.toNative())
var recovery: StorageRecovery? = null
val shell = if (firstLoad) {
firstLoad = false
try {
val directories = directoryProvider()
val initialized = storage ?: runtime.privateStorage(
MobilePrivateDirectories(
directories.files,
directories.noBackup,
directories.cache
)
)
storage = initialized
initialized.activate()
recovery = initialized.status().recovery?.toFacade()
initialized.shell()
} catch (error: MobilePrivateStorageFfiException.Failed) {
recovery = error.kind.toFacade()
mobilePrivateStorageFailure(error.kind)
} catch (_: java.io.IOException) {
recovery = StorageRecovery.InvalidRoots
runtime.fixture(MobileShellState.ERROR)
mobilePrivateStorageFailure(MobilePrivateStorageErrorKind.INVALID_ROOTS)
}
} else {
runtime.fixture(state.toNative())
}
val pages = shell.pages.map { page ->
ShellPage(
page.tab.toFacade(),
@@ -68,11 +109,39 @@ private fun nativeLoader(context: Context): suspend (ShellState) -> ShellSnapsho
}
val actual = shell.pages.firstOrNull()?.state
?: throw NativeBridgeException(NativeFailureKind.RustFailure)
ShellSnapshot(actual.toFacade(), pages)
ShellSnapshot(
actual.toFacade(),
pages,
selectedTab = shell.selectedTab.toFacade(),
storageRecovery = recovery
)
}
}
}
private fun MobilePrivateStorageErrorKind.toFacade(): StorageRecovery = when (this) {
MobilePrivateStorageErrorKind.INVALID_ROOTS -> StorageRecovery.InvalidRoots
MobilePrivateStorageErrorKind.UNSAFE_PATH -> StorageRecovery.UnsafePath
MobilePrivateStorageErrorKind.MISSING_ROOT -> StorageRecovery.MissingRoot
MobilePrivateStorageErrorKind.ACCESS_DENIED -> StorageRecovery.AccessDenied
MobilePrivateStorageErrorKind.READ_ONLY -> StorageRecovery.ReadOnly
MobilePrivateStorageErrorKind.STORAGE_FULL -> StorageRecovery.StorageFull
MobilePrivateStorageErrorKind.BUSY -> StorageRecovery.Busy
MobilePrivateStorageErrorKind.IO -> StorageRecovery.Io
MobilePrivateStorageErrorKind.CORRUPT_LAYOUT -> StorageRecovery.CorruptLayout
MobilePrivateStorageErrorKind.MIGRATION_REQUIRED -> StorageRecovery.MigrationRequired
MobilePrivateStorageErrorKind.CORRUPT_CONFIGURATION -> StorageRecovery.CorruptConfiguration
MobilePrivateStorageErrorKind.CONFIGURATION_MISSING_WITH_DATA ->
StorageRecovery.ConfigurationMissingWithData
MobilePrivateStorageErrorKind.INTERRUPTED_SETUP -> StorageRecovery.InterruptedSetup
MobilePrivateStorageErrorKind.RESTORED_WITHOUT_KEYSTORE ->
StorageRecovery.RestoredWithoutKeystore
MobilePrivateStorageErrorKind.SECURE_STORAGE_LOCKED -> StorageRecovery.SecureStorageLocked
MobilePrivateStorageErrorKind.SECURE_STORAGE_UNAVAILABLE ->
StorageRecovery.SecureStorageUnavailable
MobilePrivateStorageErrorKind.INVALID_SECURE_BINDING -> StorageRecovery.InvalidSecureBinding
}
private fun MobileShellState.toFacade(): ShellState = when (this) {
MobileShellState.LOADING -> ShellState.Loading
MobileShellState.EMPTY -> ShellState.Empty
@@ -0,0 +1,25 @@
package de.rfc1437.ironstorage.core
data class PrivateDirectories(val files: String, val noBackup: String, val cache: String) {
override fun toString(): String = "PrivateDirectories([REDACTED])"
}
enum class StorageRecovery {
InvalidRoots,
UnsafePath,
MissingRoot,
AccessDenied,
ReadOnly,
StorageFull,
Busy,
Io,
CorruptLayout,
MigrationRequired,
CorruptConfiguration,
ConfigurationMissingWithData,
InterruptedSetup,
RestoredWithoutKeystore,
SecureStorageLocked,
SecureStorageUnavailable,
InvalidSecureBinding
}
@@ -44,7 +44,8 @@ data class ShellSnapshot(
val state: ShellState,
val pages: List<ShellPage>,
val selectedTab: ShellTab = ShellTab.Home,
val nativeFailure: NativeFailure? = null
val nativeFailure: NativeFailure? = null,
val storageRecovery: StorageRecovery? = null
)
enum class NativeFailure {
@@ -0,0 +1,11 @@
package de.rfc1437.ironstorage.platform
import android.content.Context
import de.rfc1437.ironstorage.core.PrivateDirectories
/** Only Context's OS directory facts; Rust owns all subordinate paths and files. */
fun privateDirectories(context: Context): PrivateDirectories = PrivateDirectories(
context.filesDir.canonicalPath,
context.noBackupFilesDir.canonicalPath,
context.cacheDir.canonicalPath
)
+1 -1
View File
@@ -31,7 +31,7 @@ kotlin {
}
dependencies {
implementation("${libs.jna.get().module}:${libs.versions.jna.get()}@aar")
implementation(variantOf(libs.jna) { artifactType("aar") })
implementation(libs.annotation)
testImplementation(libs.junit)
androidTestImplementation(libs.android.test.runner)
@@ -0,0 +1,69 @@
package de.rfc1437.ironstorage.bridge
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
import de.rfc1437.ironstorage.bridge.generated.MobileTab
import de.rfc1437.ironstorage.bridge.generated.mobileLocalOnboardingOperation
import de.rfc1437.ironstorage.bridge.generated.setSelectedMobileTab
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PrivateStorageTest {
@Test
fun canonicalSandboxInitializationAndRecreationKeepDurableLayout() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val directories = MobilePrivateDirectories(
context.filesDir.canonicalPath,
context.noBackupFilesDir.canonicalPath,
context.cacheDir.canonicalPath
)
val native = NativeRuntime(context)
val initialized = try {
native.privateStorage(directories)
} catch (error: MobilePrivateStorageFfiException.Failed) {
throw AssertionError("Private initialization failed: ${error.kind}", error)
}
initialized.use { first ->
assertEquals(1u, first.status().version)
assertEquals(null, first.status().recovery)
val configured = first.status().configured
assertEquals(
if (configured) MobileShellState.READY else MobileShellState.EMPTY,
first.shell().pages.first().state
)
val layout = first.layout()
assertEquals("${context.noBackupFilesDir.canonicalPath}/ironstorage", layout.data)
assertEquals("${layout.data}/totp-catalog.toml", layout.catalog)
assertEquals("${context.cacheDir.canonicalPath}/ironstorage", layout.cache)
first.activate()
if (!configured) {
mobileLocalOnboardingOperation().use { onboarding ->
try {
onboarding.generate(
"Sandbox fixture <fixture@example.test>",
"public-test-passphrase",
false
)
} catch (error: MobileOnboardingFfiException.Failed) {
throw AssertionError("Private onboarding failed: ${error.kind}", error)
}
}
}
assertTrue(first.status().configured)
setSelectedMobileTab(MobileTab.PREFERENCES)
assertEquals(MobileTab.PREFERENCES, first.shell().selectedTab)
assertEquals(MobileShellState.READY, first.shell().pages.first().state)
first.clearDisposableCache()
native.privateStorage(directories).use { recreated ->
assertEquals(layout, recreated.layout())
assertEquals(first.status(), recreated.status())
}
assertFalse(layout.toString().contains(context.noBackupFilesDir.path))
}
}
}
@@ -3,9 +3,13 @@ package de.rfc1437.ironstorage.bridge
import android.content.Context
import android.os.Build
import android.os.Looper
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateRuntime
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
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.openMobilePrivateRuntime
import de.rfc1437.ironstorage.bridge.generated.uniffiEnsureInitialized
import java.io.File
import java.io.IOException
@@ -24,6 +28,11 @@ class NativeRuntime(context: Context) {
mobileShellFixture(state)
}
fun privateStorage(directories: MobilePrivateDirectories): MobilePrivateRuntime = call {
initialize()
openMobilePrivateRuntime(directories)
}
private fun initialize() = synchronized(lock) {
initializationFailure?.let { throw NativeBridgeException(it) }
if (!initialized) {
@@ -51,6 +60,8 @@ class NativeRuntime(context: Context) {
fail(error.kind)
} catch (error: CancellationException) {
throw error
} catch (error: MobilePrivateStorageFfiException) {
throw error
} catch (error: NativeBindingMismatchException) {
fail(error.kind)
} catch (_: IOException) {