feat(android): implement private Rust storage runtime (#90)
Dependency security audit / rustsec (push) Failing after 23s
Dependency security audit / rustsec (push) Failing after 23s
This commit is contained in:
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>
|
||||
+75
-6
@@ -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 {
|
||||
|
||||
+11
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
+69
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user