Share the platform-neutral Rust mobile contract (#91)
Dependency security audit / rustsec (push) Failing after 22s
Dependency security audit / rustsec (push) Failing after 22s
This commit is contained in:
1 parent
fcb36a0684
commit
180851087b
52 files changed
+8932
-4263
No files matched your search
@@ -21,10 +21,10 @@ class ShellScreenTest {
|
||||
ShellState.entries.forEach { state ->
|
||||
compose.onNodeWithText("Fixture: ${state.name}").performScrollTo().performClick()
|
||||
ShellTab.entries.forEach { tab ->
|
||||
compose.onNode(
|
||||
hasText(tab.name) and androidx.compose.ui.test.isSelectable()
|
||||
).performClick()
|
||||
val page = ShellFixtures.all.getValue(state).first { it.tab == tab }
|
||||
compose.onNode(
|
||||
hasText(page.title) and androidx.compose.ui.test.isSelectable()
|
||||
).performClick()
|
||||
compose.waitUntil(timeoutMillis = 10_000) {
|
||||
compose.onAllNodes(hasText(page.stateTitle) and isHeading())
|
||||
.fetchSemanticsNodes().size == 1
|
||||
|
||||
+36
-1
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
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.MobileShellState
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileTab
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
@@ -57,7 +58,13 @@ private fun nativeLoader(context: Context): suspend (ShellState) -> ShellSnapsho
|
||||
withContext(Dispatchers.IO) {
|
||||
val shell = runtime.fixture(state.toNative())
|
||||
val pages = shell.pages.map { page ->
|
||||
ShellPage(page.tab.toFacade(), page.title, page.stateTitle, page.stateDetail)
|
||||
ShellPage(
|
||||
page.tab.toFacade(),
|
||||
page.title,
|
||||
page.stateTitle,
|
||||
page.stateDetail,
|
||||
page.icon.toFacade()
|
||||
)
|
||||
}
|
||||
val actual = shell.pages.firstOrNull()?.state
|
||||
?: throw NativeBridgeException(NativeFailureKind.RustFailure)
|
||||
@@ -99,3 +106,31 @@ private fun NativeFailureKind.toFacade(): NativeFailure = when (this) {
|
||||
NativeFailureKind.RustFailure -> NativeFailure.RustFailure
|
||||
NativeFailureKind.MainThreadCall -> NativeFailure.MainThreadCall
|
||||
}
|
||||
|
||||
private fun MobileIcon.toFacade(): ShellIcon = when (this) {
|
||||
MobileIcon.HOME -> ShellIcon.Home
|
||||
MobileIcon.KEY -> ShellIcon.Key
|
||||
MobileIcon.SEARCH -> ShellIcon.Search
|
||||
MobileIcon.TIMER -> ShellIcon.Timer
|
||||
MobileIcon.SETTINGS -> ShellIcon.Settings
|
||||
MobileIcon.WARNING -> ShellIcon.Warning
|
||||
MobileIcon.BRANCH -> ShellIcon.Branch
|
||||
MobileIcon.SUCCESS -> ShellIcon.Success
|
||||
MobileIcon.SYNC -> ShellIcon.Sync
|
||||
MobileIcon.PROTECTED -> ShellIcon.Protected
|
||||
MobileIcon.REPOSITORY -> ShellIcon.Repository
|
||||
MobileIcon.HISTORY -> ShellIcon.History
|
||||
MobileIcon.INCOMING -> ShellIcon.Incoming
|
||||
MobileIcon.OUTGOING -> ShellIcon.Outgoing
|
||||
MobileIcon.RECIPIENTS -> ShellIcon.Recipients
|
||||
MobileIcon.SIGNATURE -> ShellIcon.Signature
|
||||
MobileIcon.FILE -> ShellIcon.File
|
||||
MobileIcon.MERGE -> ShellIcon.Merge
|
||||
MobileIcon.REFRESH -> ShellIcon.Refresh
|
||||
MobileIcon.FOLDER -> ShellIcon.Folder
|
||||
MobileIcon.USER -> ShellIcon.User
|
||||
MobileIcon.EMAIL -> ShellIcon.Email
|
||||
MobileIcon.WEBSITE -> ShellIcon.Website
|
||||
MobileIcon.TEXT -> ShellIcon.Text
|
||||
MobileIcon.NOTE -> ShellIcon.Note
|
||||
}
|
||||
@@ -5,11 +5,40 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
enum class ShellState { Loading, Empty, Ready, Locked, Error }
|
||||
enum class ShellTab { Home, Passwords, Search, Totp, Preferences }
|
||||
|
||||
enum class ShellIcon {
|
||||
Home,
|
||||
Key,
|
||||
Search,
|
||||
Timer,
|
||||
Settings,
|
||||
Warning,
|
||||
Branch,
|
||||
Success,
|
||||
Sync,
|
||||
Protected,
|
||||
Repository,
|
||||
History,
|
||||
Incoming,
|
||||
Outgoing,
|
||||
Recipients,
|
||||
Signature,
|
||||
File,
|
||||
Merge,
|
||||
Refresh,
|
||||
Folder,
|
||||
User,
|
||||
Email,
|
||||
Website,
|
||||
Text,
|
||||
Note
|
||||
}
|
||||
|
||||
data class ShellPage(
|
||||
val tab: ShellTab,
|
||||
val title: String,
|
||||
val stateTitle: String,
|
||||
val detail: String
|
||||
val detail: String,
|
||||
val icon: ShellIcon
|
||||
)
|
||||
data class ShellSnapshot(
|
||||
val state: ShellState,
|
||||
|
||||
+4
-1
@@ -15,7 +15,10 @@ class NativeFixtureShellRepositoryTest {
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
val repository = NativeFixtureShellRepository(backgroundScope) { state ->
|
||||
if (state == ShellState.Ready) withContext(NonCancellable) { pending.await() }
|
||||
ShellSnapshot(state, listOf(ShellPage(ShellTab.Home, "Home", state.name, "Fixture")))
|
||||
ShellSnapshot(
|
||||
state,
|
||||
listOf(ShellPage(ShellTab.Home, "Home", state.name, "Fixture", ShellIcon.Home))
|
||||
)
|
||||
}
|
||||
runCurrent()
|
||||
repository.showFixture(ShellState.Ready)
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
@@ -13,12 +14,14 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.NativeFailure
|
||||
import de.rfc1437.ironstorage.core.ShellIcon
|
||||
import de.rfc1437.ironstorage.core.ShellState
|
||||
import de.rfc1437.ironstorage.core.ShellTab
|
||||
|
||||
@@ -28,12 +31,18 @@ fun ShellScreen(viewModel: ShellViewModel) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
ShellTab.entries.forEach { tab ->
|
||||
snapshot.pages.forEach { page ->
|
||||
val tab = page.tab
|
||||
NavigationBarItem(
|
||||
selected = snapshot.selectedTab == tab,
|
||||
onClick = { viewModel.selectTab(tab) },
|
||||
icon = {},
|
||||
label = { Text(tab.name) }
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(navigationIcon(page.icon)),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
label = { Text(page.title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -76,3 +85,12 @@ fun ShellScreen(viewModel: ShellViewModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigationIcon(icon: ShellIcon): Int = when (icon) {
|
||||
ShellIcon.Home -> R.drawable.ic_home
|
||||
ShellIcon.Key -> R.drawable.ic_key
|
||||
ShellIcon.Search -> R.drawable.ic_search
|
||||
ShellIcon.Timer -> R.drawable.ic_timer
|
||||
ShellIcon.Settings -> R.drawable.ic_settings
|
||||
else -> error("Rust returned a non-navigation icon for a navigation page")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z" android:fillType="evenOdd" /></vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M7,14a5,5 0,1 1,4.9,-6H22v4h-2v3h-3v-3h-5.1A5,5 0,0 1,7,14zM5,9a2,2 0,1 0,4,0a2,2 0,1 0,-4,0" android:fillType="evenOdd" /></vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M9.5,3a6.5,6.5 0,1 0,4.1,11.6L20,21l1,-1 -6.4,-6.4A6.5,6.5 0,0 0,9.5,3zM9.5,5a4.5,4.5 0,1 1,0,9a4.5,4.5 0,1 1,0,-9" android:fillType="evenOdd" /></vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M19.4,13a7,7 0,0 0,0,-2l2,-1.5 -2,-3.5 -2.3,1a7,7 0,0 0,-1.7,-1L15,3H9l-0.4,3a7,7 0,0 0,-1.7,1l-2.3,-1 -2,3.5 2,1.5a7,7 0,0 0,0,2l-2,1.5 2,3.5 2.3,-1a7,7 0,0 0,1.7,1L9,21h6l0.4,-3a7,7 0,0 0,1.7,-1l2.3,1 2,-3.5zM12,8a4,4 0,1 1,0,8a4,4 0,1 1,0,-8" android:fillType="evenOdd" /></vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M9,1h6v2H9zM12,4a9,9 0,1 0,0,18a9,9 0,1 0,0,-18zM12,6a7,7 0,1 1,0,14a7,7 0,1 1,0,-14zM11,8h2v6h-2z" android:fillType="evenOdd" /></vector>
|
||||
@@ -46,6 +46,9 @@ val cargoExecutable = providers.environmentVariable("CARGO").orElse(
|
||||
}
|
||||
)
|
||||
val rustVersion = libs.versions.rust.get()
|
||||
val rustcExecutable = providers.exec {
|
||||
commandLine("rustup", "which", "--toolchain", rustVersion, "rustc")
|
||||
}.standardOutput.asText.map { it.trim() }
|
||||
val ndkDirectory = androidComponents.sdkComponents.ndkDirectory
|
||||
val sdkDirectory = androidComponents.sdkComponents.sdkDirectory
|
||||
val rustInputs = files(
|
||||
@@ -79,6 +82,8 @@ androidComponents.onVariants { variant ->
|
||||
inputs.property("ndkVersion", libs.versions.ndk.get())
|
||||
inputs.property("minSdk", libs.versions.minSdk.get())
|
||||
outputs.dir(rawNative)
|
||||
environment("RUSTUP_TOOLCHAIN", rustVersion)
|
||||
environment("RUSTC", rustcExecutable.get())
|
||||
environment("ANDROID_NDK_HOME", ndkDirectory.get().asFile.absolutePath)
|
||||
environment("ANDROID_HOME", sdkDirectory.get().asFile.absolutePath)
|
||||
environment(
|
||||
@@ -128,16 +133,10 @@ androidComponents.onVariants { variant ->
|
||||
outputs.dir(rawBindings)
|
||||
commandLine(
|
||||
cargoExecutable.get(), "+$rustVersion", "run", "--locked",
|
||||
"--package", "ironstorage-android-build", "--bin", "ironstorage-uniffi-bindgen",
|
||||
"--", "generate", "--library",
|
||||
nativeOutput.get().file(
|
||||
"arm64-v8a/libironstorage_android.so"
|
||||
).asFile.absolutePath,
|
||||
"--language", "kotlin", "--config",
|
||||
rootProject.file(
|
||||
"uniffi.toml"
|
||||
).absolutePath,
|
||||
"--out-dir", rawBindings.get().asFile.absolutePath, "--no-format"
|
||||
"--package", "ironstorage-android-build", "--", "contract-bindings",
|
||||
nativeOutput.get().file("arm64-v8a/libironstorage_android.so").asFile.absolutePath,
|
||||
rootProject.file("uniffi.toml").absolutePath,
|
||||
rawBindings.get().asFile.absolutePath, "phone"
|
||||
)
|
||||
}
|
||||
val prepare = tasks.register<Exec>("prepare${title}RustBindings") {
|
||||
@@ -153,7 +152,7 @@ androidComponents.onVariants { variant ->
|
||||
commandLine(
|
||||
cargoExecutable.get(), "+$rustVersion", "run", "--locked",
|
||||
"--package", "ironstorage-android-build", "--", "prepare-bindings",
|
||||
rawBindings.get().asFile.absolutePath, bindings.get().asFile.absolutePath
|
||||
rawBindings.get().dir("kotlin").asFile.absolutePath, bindings.get().asFile.absolutePath
|
||||
)
|
||||
}
|
||||
variant.sources.kotlin!!.addStaticSourceDirectory(bindings.get().asFile.absolutePath)
|
||||
@@ -162,3 +161,8 @@ androidComponents.onVariants { variant ->
|
||||
it.name == "pre${title}Build" || it.name == "compile${title}Kotlin"
|
||||
}.configureEach { dependsOn(prepare) }
|
||||
}
|
||||
|
||||
// Instrumentation suites share persistent emulators and must not compete for input focus.
|
||||
tasks.matching { it.name == "connectedDebugAndroidTest" }.configureEach {
|
||||
mustRunAfter(":app:connectedDebugAndroidTest")
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package de.rfc1437.ironstorage.bridge
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileCameraFrame
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClipboard
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardContent
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardDisposition
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardWait
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileCredentialRequest
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePlatformPaths
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileSecretLocator
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryPhase
|
||||
import de.rfc1437.ironstorage.bridge.generated.copyMobileEntry
|
||||
import de.rfc1437.ironstorage.bridge.generated.decodeMobileCameraFrame
|
||||
import de.rfc1437.ironstorage.bridge.generated.mobilePlatformLayout
|
||||
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
|
||||
import de.rfc1437.ironstorage.bridge.generated.validateMobileCredentialRequest
|
||||
import java.util.concurrent.Executors
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class MobileContractTest {
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
private fun initialize() {
|
||||
NativeRuntime(context).fixture(MobileShellState.EMPTY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun privatePathsAndProviderFactsAreValidatedInRust() {
|
||||
initialize()
|
||||
val layout = mobilePlatformLayout(
|
||||
MobilePlatformPaths(context.noBackupFilesDir.path, context.cacheDir.path)
|
||||
)
|
||||
assertEquals("${context.noBackupFilesDir.path}/config.toml", layout.configuration)
|
||||
assertThrows(MobilePlatformFfiException.InvalidPaths::class.java) {
|
||||
mobilePlatformLayout(MobilePlatformPaths("relative", context.cacheDir.path))
|
||||
}
|
||||
val request = validateMobileCredentialRequest(
|
||||
MobileCredentialRequest(
|
||||
"opaque-request",
|
||||
"de.example.app",
|
||||
"A".repeat(64),
|
||||
listOf("https://EXAMPLE.test:443/")
|
||||
)
|
||||
)
|
||||
assertEquals(listOf("https://example.test"), request.origins)
|
||||
assertThrows(MobilePlatformFfiException.InvalidCredentialRequest::class.java) {
|
||||
validateMobileCredentialRequest(request.copy(origins = listOf("file:///tmp")))
|
||||
}
|
||||
assertFalse(request.toString().contains("opaque-request"))
|
||||
assertFalse(
|
||||
MobileSecretLocator.OpenPgpPassphrase("SecretReferenceMarker")
|
||||
.toString().contains("SecretReferenceMarker")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foreignClipboardCallbackTransfersLargeSecretsAndPreservesNewerContent() {
|
||||
initialize()
|
||||
val secret = "binding-payload-".repeat(70_000)
|
||||
val clipboard = Clipboard()
|
||||
assertEquals(
|
||||
MobileClipboardDisposition.RESTORED_PREVIOUS,
|
||||
copyMobileEntry(MobileEntryCopy(secret, 45uL), clipboard)
|
||||
)
|
||||
assertEquals("previous", clipboard.text)
|
||||
assertEquals(secret.length, clipboard.writtenBytes)
|
||||
assertEquals(45_000uL, clipboard.timeout)
|
||||
clipboard.newer = "newer content"
|
||||
assertEquals(
|
||||
MobileClipboardDisposition.PRESERVED_NEWER,
|
||||
copyMobileEntry(MobileEntryCopy(secret, 45uL), clipboard)
|
||||
)
|
||||
assertEquals("newer content", clipboard.text)
|
||||
clipboard.newer = null
|
||||
clipboard.cancelled = true
|
||||
assertThrows(MobilePlatformFfiException.Cancelled::class.java) {
|
||||
copyMobileEntry(MobileEntryCopy(secret, 45uL), clipboard)
|
||||
}
|
||||
assertEquals("newer content", clipboard.text)
|
||||
assertFalse(MobileClipboardContent.Text(secret.toByteArray()).toString().contains(secret))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cameraPlaneBoundsAndConcurrentOperationCancellationSurviveNativeOwnership() {
|
||||
initialize()
|
||||
assertThrows(MobilePlatformFfiException.InvalidCameraFrame::class.java) {
|
||||
decodeMobileCameraFrame(MobileCameraFrame(UInt.MAX_VALUE, 1u, 1u, 1u, byteArrayOf(0)))
|
||||
}
|
||||
assertThrows(MobilePlatformFfiException.QrNotFound::class.java) {
|
||||
decodeMobileCameraFrame(
|
||||
MobileCameraFrame(1024u, 1024u, 2048u, 1u, ByteArray(2 * 1024 * 1024) { -1 })
|
||||
)
|
||||
}
|
||||
val executor = Executors.newFixedThreadPool(4)
|
||||
try {
|
||||
val operations = List(16) { mobileTotpOperation() }
|
||||
val tasks = operations.map { operation ->
|
||||
executor.submit {
|
||||
operation.cancel()
|
||||
assertEquals(MobileTotpDiscoveryPhase.CANCELLED, operation.progress().phase)
|
||||
}
|
||||
}
|
||||
tasks.forEach { it.get() }
|
||||
operations.forEach { it.close() }
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
private class Clipboard : MobileClipboard {
|
||||
var text = "previous"
|
||||
var writtenBytes = 0
|
||||
var timeout = 0uL
|
||||
var newer: String? = null
|
||||
var cancelled = false
|
||||
override fun read(): MobileClipboardContent =
|
||||
MobileClipboardContent.Text(text.toByteArray())
|
||||
override fun write(value: ByteArray) {
|
||||
writtenBytes = maxOf(writtenBytes, value.size)
|
||||
text = value.decodeToString()
|
||||
}
|
||||
override fun clear() {
|
||||
text = ""
|
||||
}
|
||||
override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait {
|
||||
timeout = milliseconds
|
||||
newer?.let { text = it }
|
||||
return if (cancelled) MobileClipboardWait.CANCELLED else MobileClipboardWait.ELAPSED
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -55,9 +55,9 @@ class NativeRuntimeTest {
|
||||
fun wrongNativeContractAndChecksumFailWithTypedRedactedErrors() {
|
||||
NativeRuntime(context).fixture(MobileShellState.EMPTY)
|
||||
val version = IntegrityCheckingUniffiLib
|
||||
.ffi_ironstorage_apple_uniffi_contract_version().toLong()
|
||||
.ffi_ironstorage_mobile_uniffi_contract_version().toLong()
|
||||
val checksum = IntegrityCheckingUniffiLib
|
||||
.uniffi_ironstorage_apple_checksum_func_mobile_shell_fixture().toLong() and 0xffffL
|
||||
.uniffi_ironstorage_mobile_checksum_func_mobile_shell_fixture().toLong() and 0xffffL
|
||||
NativeBindingChecks.requireMatch(-27278, 38258, NativeFailureKind.ChecksumMismatch)
|
||||
for ((actual, kind) in listOf(
|
||||
version to NativeFailureKind.ContractMismatch,
|
||||
|
||||
Reference in new issue
Block a user