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
Generated
+12
-3
@@ -5437,17 +5437,19 @@ dependencies = [
|
|||||||
name = "ironstorage-android"
|
name = "ironstorage-android"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ironstorage-apple",
|
"ironstorage-mobile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironstorage-android-build"
|
name = "ironstorage-android-build"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"camino",
|
||||||
"ironstorage",
|
"ironstorage",
|
||||||
"object",
|
"object",
|
||||||
"regex",
|
"regex",
|
||||||
"uniffi",
|
"uniffi",
|
||||||
|
"uniffi_bindgen",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5455,8 +5457,7 @@ dependencies = [
|
|||||||
name = "ironstorage-apple"
|
name = "ironstorage-apple"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ironstorage",
|
"ironstorage-mobile",
|
||||||
"uniffi",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5506,6 +5507,14 @@ dependencies = [
|
|||||||
"simple-file-manifest",
|
"simple-file-manifest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironstorage-mobile"
|
||||||
|
version = "1.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"ironstorage",
|
||||||
|
"uniffi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironstorage-tui"
|
name = "ironstorage-tui"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ members = [
|
|||||||
"apps/tui",
|
"apps/tui",
|
||||||
"crates/apple",
|
"crates/apple",
|
||||||
"crates/android",
|
"crates/android",
|
||||||
|
"crates/mobile-bridge",
|
||||||
"crates/storage",
|
"crates/storage",
|
||||||
"crates/watch-apple",
|
"crates/watch-apple",
|
||||||
"tools/android-build",
|
"tools/android-build",
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ class ShellScreenTest {
|
|||||||
ShellState.entries.forEach { state ->
|
ShellState.entries.forEach { state ->
|
||||||
compose.onNodeWithText("Fixture: ${state.name}").performScrollTo().performClick()
|
compose.onNodeWithText("Fixture: ${state.name}").performScrollTo().performClick()
|
||||||
ShellTab.entries.forEach { tab ->
|
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 }
|
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.waitUntil(timeoutMillis = 10_000) {
|
||||||
compose.onAllNodes(hasText(page.stateTitle) and isHeading())
|
compose.onAllNodes(hasText(page.stateTitle) and isHeading())
|
||||||
.fetchSemanticsNodes().size == 1
|
.fetchSemanticsNodes().size == 1
|
||||||
|
|||||||
+36
-1
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||||
import de.rfc1437.ironstorage.bridge.NativeFailureKind
|
import de.rfc1437.ironstorage.bridge.NativeFailureKind
|
||||||
import de.rfc1437.ironstorage.bridge.NativeRuntime
|
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.MobileShellState
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobileTab
|
import de.rfc1437.ironstorage.bridge.generated.MobileTab
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
@@ -57,7 +58,13 @@ private fun nativeLoader(context: Context): suspend (ShellState) -> ShellSnapsho
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val shell = runtime.fixture(state.toNative())
|
val shell = runtime.fixture(state.toNative())
|
||||||
val pages = shell.pages.map { page ->
|
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
|
val actual = shell.pages.firstOrNull()?.state
|
||||||
?: throw NativeBridgeException(NativeFailureKind.RustFailure)
|
?: throw NativeBridgeException(NativeFailureKind.RustFailure)
|
||||||
@@ -99,3 +106,31 @@ private fun NativeFailureKind.toFacade(): NativeFailure = when (this) {
|
|||||||
NativeFailureKind.RustFailure -> NativeFailure.RustFailure
|
NativeFailureKind.RustFailure -> NativeFailure.RustFailure
|
||||||
NativeFailureKind.MainThreadCall -> NativeFailure.MainThreadCall
|
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 ShellState { Loading, Empty, Ready, Locked, Error }
|
||||||
enum class ShellTab { Home, Passwords, Search, Totp, Preferences }
|
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(
|
data class ShellPage(
|
||||||
val tab: ShellTab,
|
val tab: ShellTab,
|
||||||
val title: String,
|
val title: String,
|
||||||
val stateTitle: String,
|
val stateTitle: String,
|
||||||
val detail: String
|
val detail: String,
|
||||||
|
val icon: ShellIcon
|
||||||
)
|
)
|
||||||
data class ShellSnapshot(
|
data class ShellSnapshot(
|
||||||
val state: ShellState,
|
val state: ShellState,
|
||||||
|
|||||||
+4
-1
@@ -15,7 +15,10 @@ class NativeFixtureShellRepositoryTest {
|
|||||||
val pending = CompletableDeferred<Unit>()
|
val pending = CompletableDeferred<Unit>()
|
||||||
val repository = NativeFixtureShellRepository(backgroundScope) { state ->
|
val repository = NativeFixtureShellRepository(backgroundScope) { state ->
|
||||||
if (state == ShellState.Ready) withContext(NonCancellable) { pending.await() }
|
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()
|
runCurrent()
|
||||||
repository.showFixture(ShellState.Ready)
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
@@ -13,12 +14,14 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.heading
|
import androidx.compose.ui.semantics.heading
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.rfc1437.ironstorage.core.NativeFailure
|
import de.rfc1437.ironstorage.core.NativeFailure
|
||||||
|
import de.rfc1437.ironstorage.core.ShellIcon
|
||||||
import de.rfc1437.ironstorage.core.ShellState
|
import de.rfc1437.ironstorage.core.ShellState
|
||||||
import de.rfc1437.ironstorage.core.ShellTab
|
import de.rfc1437.ironstorage.core.ShellTab
|
||||||
|
|
||||||
@@ -28,12 +31,18 @@ fun ShellScreen(viewModel: ShellViewModel) {
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
ShellTab.entries.forEach { tab ->
|
snapshot.pages.forEach { page ->
|
||||||
|
val tab = page.tab
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = snapshot.selectedTab == tab,
|
selected = snapshot.selectedTab == tab,
|
||||||
onClick = { viewModel.selectTab(tab) },
|
onClick = { viewModel.selectTab(tab) },
|
||||||
icon = {},
|
icon = {
|
||||||
label = { Text(tab.name) }
|
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 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 ndkDirectory = androidComponents.sdkComponents.ndkDirectory
|
||||||
val sdkDirectory = androidComponents.sdkComponents.sdkDirectory
|
val sdkDirectory = androidComponents.sdkComponents.sdkDirectory
|
||||||
val rustInputs = files(
|
val rustInputs = files(
|
||||||
@@ -79,6 +82,8 @@ androidComponents.onVariants { variant ->
|
|||||||
inputs.property("ndkVersion", libs.versions.ndk.get())
|
inputs.property("ndkVersion", libs.versions.ndk.get())
|
||||||
inputs.property("minSdk", libs.versions.minSdk.get())
|
inputs.property("minSdk", libs.versions.minSdk.get())
|
||||||
outputs.dir(rawNative)
|
outputs.dir(rawNative)
|
||||||
|
environment("RUSTUP_TOOLCHAIN", rustVersion)
|
||||||
|
environment("RUSTC", rustcExecutable.get())
|
||||||
environment("ANDROID_NDK_HOME", ndkDirectory.get().asFile.absolutePath)
|
environment("ANDROID_NDK_HOME", ndkDirectory.get().asFile.absolutePath)
|
||||||
environment("ANDROID_HOME", sdkDirectory.get().asFile.absolutePath)
|
environment("ANDROID_HOME", sdkDirectory.get().asFile.absolutePath)
|
||||||
environment(
|
environment(
|
||||||
@@ -128,16 +133,10 @@ androidComponents.onVariants { variant ->
|
|||||||
outputs.dir(rawBindings)
|
outputs.dir(rawBindings)
|
||||||
commandLine(
|
commandLine(
|
||||||
cargoExecutable.get(), "+$rustVersion", "run", "--locked",
|
cargoExecutable.get(), "+$rustVersion", "run", "--locked",
|
||||||
"--package", "ironstorage-android-build", "--bin", "ironstorage-uniffi-bindgen",
|
"--package", "ironstorage-android-build", "--", "contract-bindings",
|
||||||
"--", "generate", "--library",
|
nativeOutput.get().file("arm64-v8a/libironstorage_android.so").asFile.absolutePath,
|
||||||
nativeOutput.get().file(
|
rootProject.file("uniffi.toml").absolutePath,
|
||||||
"arm64-v8a/libironstorage_android.so"
|
rawBindings.get().asFile.absolutePath, "phone"
|
||||||
).asFile.absolutePath,
|
|
||||||
"--language", "kotlin", "--config",
|
|
||||||
rootProject.file(
|
|
||||||
"uniffi.toml"
|
|
||||||
).absolutePath,
|
|
||||||
"--out-dir", rawBindings.get().asFile.absolutePath, "--no-format"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val prepare = tasks.register<Exec>("prepare${title}RustBindings") {
|
val prepare = tasks.register<Exec>("prepare${title}RustBindings") {
|
||||||
@@ -153,7 +152,7 @@ androidComponents.onVariants { variant ->
|
|||||||
commandLine(
|
commandLine(
|
||||||
cargoExecutable.get(), "+$rustVersion", "run", "--locked",
|
cargoExecutable.get(), "+$rustVersion", "run", "--locked",
|
||||||
"--package", "ironstorage-android-build", "--", "prepare-bindings",
|
"--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)
|
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"
|
it.name == "pre${title}Build" || it.name == "compile${title}Kotlin"
|
||||||
}.configureEach { dependsOn(prepare) }
|
}.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() {
|
fun wrongNativeContractAndChecksumFailWithTypedRedactedErrors() {
|
||||||
NativeRuntime(context).fixture(MobileShellState.EMPTY)
|
NativeRuntime(context).fixture(MobileShellState.EMPTY)
|
||||||
val version = IntegrityCheckingUniffiLib
|
val version = IntegrityCheckingUniffiLib
|
||||||
.ffi_ironstorage_apple_uniffi_contract_version().toLong()
|
.ffi_ironstorage_mobile_uniffi_contract_version().toLong()
|
||||||
val checksum = IntegrityCheckingUniffiLib
|
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)
|
NativeBindingChecks.requireMatch(-27278, 38258, NativeFailureKind.ChecksumMismatch)
|
||||||
for ((actual, kind) in listOf(
|
for ((actual, kind) in listOf(
|
||||||
version to NativeFailureKind.ContractMismatch,
|
version to NativeFailureKind.ContractMismatch,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+2607
-236
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
module ironstorage_appleFFI {
|
module ironstorage_mobileFFI {
|
||||||
header "ironstorage_appleFFI.h"
|
header "ironstorage_mobileFFI.h"
|
||||||
export *
|
export *
|
||||||
use "Darwin"
|
use "Darwin"
|
||||||
use "_Builtin_stdbool"
|
use "_Builtin_stdbool"
|
||||||
@@ -1 +1 @@
|
|||||||
#include "Generated/ironstorage_appleFFI.h"
|
#include "Generated/ironstorage_mobileFFI.h"
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension MobileIcon {
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .home: return "house"
|
||||||
|
case .key: return "key"
|
||||||
|
case .search: return "magnifyingglass"
|
||||||
|
case .timer: return "timer"
|
||||||
|
case .settings: return "gearshape"
|
||||||
|
case .warning: return "exclamationmark.triangle"
|
||||||
|
case .branch: return "point.3.connected.trianglepath.dotted"
|
||||||
|
case .success: return "checkmark.circle"
|
||||||
|
case .sync: return "arrow.triangle.2.circlepath"
|
||||||
|
case .protected: return "checkmark.shield"
|
||||||
|
case .repository: return "externaldrive.fill"
|
||||||
|
case .history: return "clock.arrow.circlepath"
|
||||||
|
case .incoming: return "arrow.down.circle"
|
||||||
|
case .outgoing: return "arrow.up.circle"
|
||||||
|
case .recipients: return "person.2"
|
||||||
|
case .signature: return "signature"
|
||||||
|
case .file: return "doc"
|
||||||
|
case .merge: return "arrow.triangle.merge"
|
||||||
|
case .refresh: return "arrow.clockwise.circle"
|
||||||
|
case .folder: return "folder"
|
||||||
|
case .user: return "person.fill"
|
||||||
|
case .email: return "envelope.fill"
|
||||||
|
case .website: return "globe"
|
||||||
|
case .text: return "text.alignleft"
|
||||||
|
case .note: return "note.text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedSystemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .home: return "house.fill"
|
||||||
|
case .key: return "key.fill"
|
||||||
|
case .settings: return "gearshape.fill"
|
||||||
|
default: return systemImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MobilePage {
|
||||||
|
var systemImage: String { icon.systemImage }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MobileHomeAction {
|
||||||
|
var systemImage: String { icon.systemImage }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MobileHomeSummaryRow {
|
||||||
|
var systemImage: String { icon.systemImage }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MobileHomeChange {
|
||||||
|
var systemImage: String { icon.systemImage }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MobileHomeCommit {
|
||||||
|
var systemImage: String { icon.systemImage }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MobileHomeNotice {
|
||||||
|
var systemImage: String { icon.systemImage }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MobilePasswordRow {
|
||||||
|
var systemImage: String { icon.systemImage }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MobileEntryField {
|
||||||
|
var systemImage: String { icon.systemImage }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MobileEntryEditorField {
|
||||||
|
var systemImage: String { icon.systemImage }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MobilePage {
|
||||||
|
var selectedSystemImage: String { icon.selectedSystemImage }
|
||||||
|
}
|
||||||
+2
-2
@@ -37,7 +37,7 @@ targets:
|
|||||||
- Resources/App/PrivacyInfo.xcprivacy
|
- Resources/App/PrivacyInfo.xcprivacy
|
||||||
- Resources/App/ThirdPartyLicenses.txt
|
- Resources/App/ThirdPartyLicenses.txt
|
||||||
- Sources/App
|
- Sources/App
|
||||||
- Generated/ironstorage_apple.swift
|
- Generated/ironstorage_mobile.swift
|
||||||
dependencies:
|
dependencies:
|
||||||
- target: IronStorageWatch
|
- target: IronStorageWatch
|
||||||
embed: true
|
embed: true
|
||||||
@@ -72,7 +72,7 @@ targets:
|
|||||||
sources:
|
sources:
|
||||||
- Resources/AutoFill/PrivacyInfo.xcprivacy
|
- Resources/AutoFill/PrivacyInfo.xcprivacy
|
||||||
- Sources/AutoFill
|
- Sources/AutoFill
|
||||||
- Generated/ironstorage_apple.swift
|
- Generated/ironstorage_mobile.swift
|
||||||
preBuildScripts:
|
preBuildScripts:
|
||||||
- name: Build Rust core
|
- name: Build Rust core
|
||||||
basedOnDependencyAnalysis: false
|
basedOnDependencyAnalysis: false
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ name = "ironstorage_android"
|
|||||||
crate-type = ["lib", "cdylib"]
|
crate-type = ["lib", "cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ironstorage-apple = { path = "../apple" }
|
ironstorage-mobile = { path = "../mobile-bridge" }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
//! Android packaging of the existing mobile UniFFI component.
|
//! Android packaging of the shared phone UniFFI component.
|
||||||
//! The shared component owns every conversion; no interface is copied here.
|
//! The shared component owns every conversion; no interface is copied here.
|
||||||
|
|
||||||
ironstorage_apple::uniffi_reexport_scaffolding!();
|
ironstorage_mobile::uniffi_reexport_scaffolding!();
|
||||||
@@ -13,5 +13,4 @@ name = "ironstorage_apple"
|
|||||||
crate-type = ["lib", "staticlib", "cdylib"]
|
crate-type = ["lib", "staticlib", "cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ironstorage = { path = "../storage", default-features = false, features = ["full"] }
|
ironstorage-mobile = { path = "../mobile-bridge", features = ["watch"] }
|
||||||
uniffi.workspace = true
|
|
||||||
+3
-2375
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "ironstorage-mobile"
|
||||||
|
description = "Shared platform-neutral mobile UniFFI contract"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
watch = []
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ironstorage = { path = "../storage", default-features = false, features = ["full"] }
|
||||||
|
uniffi.workspace = true
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,441 @@
|
|||||||
|
//! Mechanical adapters: foreign code performs OS calls, storage owns behavior.
|
||||||
|
|
||||||
|
use std::{fmt, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use ironstorage::{
|
||||||
|
authentication::AuthenticationClock,
|
||||||
|
config::Config,
|
||||||
|
mobile_platform as storage,
|
||||||
|
presentation::{self, ClipboardBackend},
|
||||||
|
repository::SecretBytes,
|
||||||
|
secret_store::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
MobileAuthentication, MobileAuthenticationFfiError, MobileAuthenticationState, MobileEntryCopy,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Error)]
|
||||||
|
pub enum MobilePlatformFfiError {
|
||||||
|
InvalidPaths,
|
||||||
|
InvalidCameraFrame,
|
||||||
|
QrNotFound,
|
||||||
|
InvalidQrPayload,
|
||||||
|
InvalidCredentialRequest,
|
||||||
|
ClipboardFailed,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MobilePlatformFfiError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "Mobile platform operation failed: {self:?}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::error::Error for MobilePlatformFfiError {}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobilePlatformPaths {
|
||||||
|
pub data_root: String,
|
||||||
|
pub cache_root: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<MobilePlatformPaths> for storage::MobilePlatformPaths {
|
||||||
|
type Error = MobilePlatformFfiError;
|
||||||
|
fn try_from(paths: MobilePlatformPaths) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(paths.data_root.into(), paths.cache_root.into())
|
||||||
|
.map_err(|_| MobilePlatformFfiError::InvalidPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobilePlatformLayout {
|
||||||
|
pub configuration: String,
|
||||||
|
pub vault: String,
|
||||||
|
pub keys: String,
|
||||||
|
pub cache: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn mobile_platform_layout(
|
||||||
|
paths: MobilePlatformPaths,
|
||||||
|
) -> Result<MobilePlatformLayout, MobilePlatformFfiError> {
|
||||||
|
let paths = storage::MobilePlatformPaths::try_from(paths)?;
|
||||||
|
Ok(MobilePlatformLayout {
|
||||||
|
configuration: paths.configuration().to_string_lossy().into_owned(),
|
||||||
|
vault: paths.vault().to_string_lossy().into_owned(),
|
||||||
|
keys: paths.keys().to_string_lossy().into_owned(),
|
||||||
|
cache: paths.cache().to_string_lossy().into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Enum)]
|
||||||
|
pub enum MobileSecretLocator {
|
||||||
|
OpenPgpPassphrase {
|
||||||
|
fingerprint: String,
|
||||||
|
},
|
||||||
|
HttpsGitCredential {
|
||||||
|
server_id: String,
|
||||||
|
application_id: String,
|
||||||
|
},
|
||||||
|
SshKeyPassphrase {
|
||||||
|
fingerprint: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&SecretLocator> for MobileSecretLocator {
|
||||||
|
fn from(locator: &SecretLocator) -> Self {
|
||||||
|
match locator {
|
||||||
|
SecretLocator::OpenPgpPassphrase { fingerprint } => Self::OpenPgpPassphrase {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
},
|
||||||
|
SecretLocator::HttpsGitCredential {
|
||||||
|
server_id,
|
||||||
|
application_id,
|
||||||
|
} => Self::HttpsGitCredential {
|
||||||
|
server_id: server_id.clone(),
|
||||||
|
application_id: application_id.clone(),
|
||||||
|
},
|
||||||
|
SecretLocator::SshKeyPassphrase { fingerprint } => Self::SshKeyPassphrase {
|
||||||
|
fingerprint: fingerprint.to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileSecretProtection {
|
||||||
|
DeviceUnlocked,
|
||||||
|
RequireUserPresence,
|
||||||
|
BiometryCurrentSet,
|
||||||
|
}
|
||||||
|
impl From<SecretProtection> for MobileSecretProtection {
|
||||||
|
fn from(value: SecretProtection) -> Self {
|
||||||
|
match value {
|
||||||
|
SecretProtection::DeviceUnlocked => Self::DeviceUnlocked,
|
||||||
|
SecretProtection::RequireUserPresence => Self::RequireUserPresence,
|
||||||
|
SecretProtection::BiometryCurrentSet => Self::BiometryCurrentSet,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Error)]
|
||||||
|
pub enum MobileSecretError {
|
||||||
|
AlreadyExists,
|
||||||
|
Missing,
|
||||||
|
Locked,
|
||||||
|
Denied,
|
||||||
|
Cancelled,
|
||||||
|
Corrupted,
|
||||||
|
UnsupportedProtection,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
impl fmt::Display for MobileSecretError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "OS secret operation failed: {self:?}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::error::Error for MobileSecretError {}
|
||||||
|
impl From<MobileSecretError> for SecretStoreError {
|
||||||
|
fn from(value: MobileSecretError) -> Self {
|
||||||
|
match value {
|
||||||
|
MobileSecretError::AlreadyExists => Self::AlreadyExists,
|
||||||
|
MobileSecretError::Missing => Self::Missing,
|
||||||
|
MobileSecretError::Locked => Self::Locked,
|
||||||
|
MobileSecretError::Denied => Self::Denied,
|
||||||
|
MobileSecretError::Cancelled => Self::Cancelled,
|
||||||
|
MobileSecretError::Corrupted => Self::Corrupted,
|
||||||
|
MobileSecretError::UnsupportedProtection => Self::UnsupportedProtection,
|
||||||
|
MobileSecretError::Unavailable => Self::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receives storage-encoded opaque bytes; cannot select accounts, leases or protection.
|
||||||
|
#[uniffi::export(rust, foreign)]
|
||||||
|
pub trait MobileSecretBackend: Send + Sync {
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
locator: MobileSecretLocator,
|
||||||
|
protection: MobileSecretProtection,
|
||||||
|
value: Vec<u8>,
|
||||||
|
) -> Result<(), MobileSecretError>;
|
||||||
|
fn retrieve(
|
||||||
|
&self,
|
||||||
|
locator: MobileSecretLocator,
|
||||||
|
protection: MobileSecretProtection,
|
||||||
|
) -> Result<Vec<u8>, MobileSecretError>;
|
||||||
|
fn replace(
|
||||||
|
&self,
|
||||||
|
locator: MobileSecretLocator,
|
||||||
|
protection: MobileSecretProtection,
|
||||||
|
value: Vec<u8>,
|
||||||
|
) -> Result<(), MobileSecretError>;
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
locator: MobileSecretLocator,
|
||||||
|
protection: MobileSecretProtection,
|
||||||
|
) -> Result<(), MobileSecretError>;
|
||||||
|
fn lock(&self) -> Result<(), MobileSecretError>;
|
||||||
|
fn unlock(&self) -> Result<(), MobileSecretError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct SecretBackendAdapter(pub Arc<dyn MobileSecretBackend>);
|
||||||
|
impl SecretStoreBackend for SecretBackendAdapter {
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
self.0
|
||||||
|
.create(locator.into(), protection.into(), value.to_vec())
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
fn retrieve(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
self.0
|
||||||
|
.retrieve(locator.into(), protection.into())
|
||||||
|
.map(SecretBytes::new)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
fn replace(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
self.0
|
||||||
|
.replace(locator.into(), protection.into(), value.to_vec())
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
self.0
|
||||||
|
.delete(locator.into(), protection.into())
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
self.0.lock().map_err(Into::into)
|
||||||
|
}
|
||||||
|
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
self.0.unlock().map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export(rust, foreign)]
|
||||||
|
pub trait MobileClock: Send + Sync {
|
||||||
|
fn monotonic_milliseconds(&self) -> u64;
|
||||||
|
}
|
||||||
|
struct ClockAdapter(Arc<dyn MobileClock>);
|
||||||
|
impl AuthenticationClock for ClockAdapter {
|
||||||
|
fn now(&self) -> Duration {
|
||||||
|
Duration::from_millis(self.0.monotonic_milliseconds())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn mobile_authentication_with_platform(
|
||||||
|
paths: MobilePlatformPaths,
|
||||||
|
backend: Arc<dyn MobileSecretBackend>,
|
||||||
|
clock: Arc<dyn MobileClock>,
|
||||||
|
) -> Result<Arc<MobileAuthentication>, MobileAuthenticationFfiError> {
|
||||||
|
let paths = storage::MobilePlatformPaths::try_from(paths).map_err(|_| {
|
||||||
|
MobileAuthenticationFfiError::Failed {
|
||||||
|
kind: crate::MobileAuthenticationErrorKind::Configuration,
|
||||||
|
title: "Invalid Private Paths".into(),
|
||||||
|
detail: "The OS private roots are invalid.".into(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let config = Config::load(Some(&paths.configuration())).map_err(|error| {
|
||||||
|
MobileAuthenticationFfiError::Failed {
|
||||||
|
kind: crate::MobileAuthenticationErrorKind::Configuration,
|
||||||
|
title: "Configuration Is Unavailable".into(),
|
||||||
|
detail: error.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let authentication = ironstorage::mobile_authentication::MobileAuthentication::with_platform(
|
||||||
|
config,
|
||||||
|
Arc::new(SecretBackendAdapter(backend)),
|
||||||
|
Arc::new(ClockAdapter(clock)),
|
||||||
|
)?;
|
||||||
|
Ok(Arc::new(MobileAuthentication {
|
||||||
|
authentication: Arc::new(authentication),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileLifecycleEvent {
|
||||||
|
Foreground,
|
||||||
|
Background,
|
||||||
|
DeviceLocked,
|
||||||
|
UserActivity,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
impl From<MobileLifecycleEvent> for storage::MobileLifecycleEvent {
|
||||||
|
fn from(event: MobileLifecycleEvent) -> Self {
|
||||||
|
match event {
|
||||||
|
MobileLifecycleEvent::Foreground => Self::Foreground,
|
||||||
|
MobileLifecycleEvent::Background => Self::Background,
|
||||||
|
MobileLifecycleEvent::DeviceLocked => Self::DeviceLocked,
|
||||||
|
MobileLifecycleEvent::UserActivity => Self::UserActivity,
|
||||||
|
MobileLifecycleEvent::Cancel => Self::Cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[uniffi::export]
|
||||||
|
impl MobileAuthentication {
|
||||||
|
pub fn lifecycle(
|
||||||
|
&self,
|
||||||
|
event: MobileLifecycleEvent,
|
||||||
|
) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.lifecycle(event.into())
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileCameraFrame {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub row_stride: u32,
|
||||||
|
pub pixel_stride: u32,
|
||||||
|
pub luminance: Vec<u8>,
|
||||||
|
}
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn decode_mobile_camera_frame(
|
||||||
|
frame: MobileCameraFrame,
|
||||||
|
) -> Result<Vec<u8>, MobilePlatformFfiError> {
|
||||||
|
storage::MobileCameraFrame {
|
||||||
|
width: frame.width,
|
||||||
|
height: frame.height,
|
||||||
|
row_stride: frame.row_stride,
|
||||||
|
pixel_stride: frame.pixel_stride,
|
||||||
|
luminance: SecretBytes::new(frame.luminance),
|
||||||
|
}
|
||||||
|
.decode()
|
||||||
|
.map(|payload| payload.expose().to_vec())
|
||||||
|
.map_err(|error| match error {
|
||||||
|
presentation::QrError::NotFound => MobilePlatformFfiError::QrNotFound,
|
||||||
|
presentation::QrError::InvalidPayload => MobilePlatformFfiError::InvalidQrPayload,
|
||||||
|
_ => MobilePlatformFfiError::InvalidCameraFrame,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Enum)]
|
||||||
|
pub enum MobileClipboardContent {
|
||||||
|
Text { value: Vec<u8> },
|
||||||
|
EmptyOrNonText,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileClipboardWait {
|
||||||
|
Elapsed,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileClipboardDisposition {
|
||||||
|
RestoredPrevious,
|
||||||
|
Cleared,
|
||||||
|
PreservedNewer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export(rust, foreign)]
|
||||||
|
pub trait MobileClipboard: Send + Sync {
|
||||||
|
fn read(&self) -> Result<MobileClipboardContent, MobilePlatformFfiError>;
|
||||||
|
fn write(&self, value: Vec<u8>) -> Result<(), MobilePlatformFfiError>;
|
||||||
|
fn clear(&self) -> Result<(), MobilePlatformFfiError>;
|
||||||
|
fn wait_for_timeout(&self, milliseconds: u64) -> MobileClipboardWait;
|
||||||
|
}
|
||||||
|
struct ClipboardAdapter(Arc<dyn MobileClipboard>);
|
||||||
|
impl ClipboardBackend for ClipboardAdapter {
|
||||||
|
fn read(&mut self) -> Result<presentation::ClipboardContent, presentation::ClipboardError> {
|
||||||
|
self.0
|
||||||
|
.read()
|
||||||
|
.map(|value| match value {
|
||||||
|
MobileClipboardContent::Text { value } => {
|
||||||
|
presentation::ClipboardContent::text(value)
|
||||||
|
}
|
||||||
|
MobileClipboardContent::EmptyOrNonText => {
|
||||||
|
presentation::ClipboardContent::EmptyOrNonText
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|_| presentation::ClipboardError::ReadFailed)
|
||||||
|
}
|
||||||
|
fn write(&mut self, value: &SecretBytes) -> Result<(), presentation::ClipboardError> {
|
||||||
|
self.0
|
||||||
|
.write(value.expose().to_vec())
|
||||||
|
.map_err(|_| presentation::ClipboardError::WriteFailed)
|
||||||
|
}
|
||||||
|
fn clear(&mut self) -> Result<(), presentation::ClipboardError> {
|
||||||
|
self.0
|
||||||
|
.clear()
|
||||||
|
.map_err(|_| presentation::ClipboardError::CleanupFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn copy_mobile_entry(
|
||||||
|
copy: MobileEntryCopy,
|
||||||
|
clipboard: Arc<dyn MobileClipboard>,
|
||||||
|
) -> Result<MobileClipboardDisposition, MobilePlatformFfiError> {
|
||||||
|
let timeout = presentation::ClipboardTimeout::new(Duration::from_secs(copy.timeout_seconds))
|
||||||
|
.map_err(|_| MobilePlatformFfiError::ClipboardFailed)?;
|
||||||
|
let mut manager =
|
||||||
|
presentation::ClipboardManager::new(ClipboardAdapter(Arc::clone(&clipboard)), timeout);
|
||||||
|
manager
|
||||||
|
.copy_with(
|
||||||
|
&SecretBytes::new(copy.value.into_bytes()),
|
||||||
|
|duration| match clipboard.wait_for_timeout(duration.as_millis() as u64) {
|
||||||
|
MobileClipboardWait::Elapsed => presentation::ClipboardWait::Elapsed,
|
||||||
|
MobileClipboardWait::Cancelled => presentation::ClipboardWait::Cancelled,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map(|result| match result {
|
||||||
|
presentation::ClipboardDisposition::RestoredPrevious => {
|
||||||
|
MobileClipboardDisposition::RestoredPrevious
|
||||||
|
}
|
||||||
|
presentation::ClipboardDisposition::Cleared => MobileClipboardDisposition::Cleared,
|
||||||
|
presentation::ClipboardDisposition::PreservedNewer => {
|
||||||
|
MobileClipboardDisposition::PreservedNewer
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|error| {
|
||||||
|
if error == presentation::ClipboardError::Cancelled {
|
||||||
|
MobilePlatformFfiError::Cancelled
|
||||||
|
} else {
|
||||||
|
MobilePlatformFfiError::ClipboardFailed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileCredentialRequest {
|
||||||
|
pub request_id: String,
|
||||||
|
pub package: String,
|
||||||
|
pub signing_certificate_sha256: String,
|
||||||
|
pub origins: Vec<String>,
|
||||||
|
}
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn validate_mobile_credential_request(
|
||||||
|
request: MobileCredentialRequest,
|
||||||
|
) -> Result<MobileCredentialRequest, MobilePlatformFfiError> {
|
||||||
|
let request = storage::MobileCredentialRequest::new(
|
||||||
|
request.request_id,
|
||||||
|
request.package,
|
||||||
|
request.signing_certificate_sha256,
|
||||||
|
request.origins,
|
||||||
|
)
|
||||||
|
.map_err(|_| MobilePlatformFfiError::InvalidCredentialRequest)?;
|
||||||
|
Ok(MobileCredentialRequest {
|
||||||
|
request_id: request.request_id().to_owned(),
|
||||||
|
package: request.package().to_owned(),
|
||||||
|
signing_certificate_sha256: request.signing_certificate_sha256().to_owned(),
|
||||||
|
origins: request.origins().to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -55,6 +55,12 @@ pub trait AuthenticationClock: Send + Sync + 'static {
|
|||||||
fn now(&self) -> Duration;
|
fn now(&self) -> Duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<T: AuthenticationClock + ?Sized> AuthenticationClock for Arc<T> {
|
||||||
|
fn now(&self) -> Duration {
|
||||||
|
(**self).now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Process-local monotonic clock used by production sessions.
|
/// Process-local monotonic clock used by production sessions.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct SystemAuthenticationClock {
|
pub struct SystemAuthenticationClock {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ pub mod mobile_onboarding;
|
|||||||
#[cfg(feature = "full")]
|
#[cfg(feature = "full")]
|
||||||
pub mod mobile_passwords;
|
pub mod mobile_passwords;
|
||||||
#[cfg(feature = "full")]
|
#[cfg(feature = "full")]
|
||||||
|
pub mod mobile_platform;
|
||||||
|
#[cfg(feature = "full")]
|
||||||
pub mod mobile_totp;
|
pub mod mobile_totp;
|
||||||
pub mod mobile_watch;
|
pub mod mobile_watch;
|
||||||
#[cfg(feature = "full")]
|
#[cfg(feature = "full")]
|
||||||
|
|||||||
@@ -5,6 +5,36 @@ use crate::{
|
|||||||
repository::{Repository, RepositoryError},
|
repository::{Repository, RepositoryError},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Presentation intent. Native UIs choose their own assets and selected variants.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobileIcon {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
pub enum MobileTab {
|
pub enum MobileTab {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -34,23 +64,13 @@ impl MobileTab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn system_image(self) -> &'static str {
|
pub const fn icon(self) -> MobileIcon {
|
||||||
match self {
|
match self {
|
||||||
Self::Home => "house",
|
Self::Home => MobileIcon::Home,
|
||||||
Self::Passwords => "key",
|
Self::Passwords => MobileIcon::Key,
|
||||||
Self::Search => "magnifyingglass",
|
Self::Search => MobileIcon::Search,
|
||||||
Self::Totp => "timer",
|
Self::Totp => MobileIcon::Timer,
|
||||||
Self::Preferences => "gearshape",
|
Self::Preferences => MobileIcon::Settings,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn selected_system_image(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Home => "house.fill",
|
|
||||||
Self::Passwords => "key.fill",
|
|
||||||
Self::Search => "magnifyingglass",
|
|
||||||
Self::Totp => "timer",
|
|
||||||
Self::Preferences => "gearshape.fill",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,8 +111,7 @@ pub enum MobileShellState {
|
|||||||
pub struct MobilePage {
|
pub struct MobilePage {
|
||||||
tab: MobileTab,
|
tab: MobileTab,
|
||||||
title: String,
|
title: String,
|
||||||
system_image: String,
|
icon: MobileIcon,
|
||||||
selected_system_image: String,
|
|
||||||
state: MobileShellState,
|
state: MobileShellState,
|
||||||
state_title: String,
|
state_title: String,
|
||||||
state_detail: String,
|
state_detail: String,
|
||||||
@@ -107,12 +126,8 @@ impl MobilePage {
|
|||||||
&self.title
|
&self.title
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system_image(&self) -> &str {
|
pub fn icon(&self) -> MobileIcon {
|
||||||
&self.system_image
|
self.icon
|
||||||
}
|
|
||||||
|
|
||||||
pub fn selected_system_image(&self) -> &str {
|
|
||||||
&self.selected_system_image
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn state(&self) -> MobileShellState {
|
pub fn state(&self) -> MobileShellState {
|
||||||
@@ -180,8 +195,7 @@ impl MobileShell {
|
|||||||
MobilePage {
|
MobilePage {
|
||||||
tab,
|
tab,
|
||||||
title: tab.title().to_owned(),
|
title: tab.title().to_owned(),
|
||||||
system_image: tab.system_image().to_owned(),
|
icon: tab.icon(),
|
||||||
selected_system_image: tab.selected_system_image().to_owned(),
|
|
||||||
state,
|
state,
|
||||||
state_title,
|
state_title,
|
||||||
state_detail,
|
state_detail,
|
||||||
@@ -268,8 +282,7 @@ mod tests {
|
|||||||
assert!(shell.pages().iter().all(|page| {
|
assert!(shell.pages().iter().all(|page| {
|
||||||
page.state() == state
|
page.state() == state
|
||||||
&& !page.title().is_empty()
|
&& !page.title().is_empty()
|
||||||
&& !page.system_image().is_empty()
|
&& page.icon() == page.tab().icon()
|
||||||
&& !page.selected_system_image().is_empty()
|
|
||||||
&& !page.state_title().is_empty()
|
&& !page.state_title().is_empty()
|
||||||
&& !page.state_detail().is_empty()
|
&& !page.state_detail().is_empty()
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
//! Shared mobile authentication state; Swift only supplies input and presents results.
|
//! Shared mobile authentication state; native frontends supply input and present results.
|
||||||
|
|
||||||
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex, time::Duration};
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
error::Error,
|
||||||
|
fmt,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
authentication::{
|
authentication::{
|
||||||
AuthenticationError, AuthenticationTimeout, NativeAuthenticationHandle,
|
AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession,
|
||||||
NativeAuthenticationSession,
|
AuthenticationTimeout, SystemAuthenticationClock,
|
||||||
},
|
},
|
||||||
config::{Config, ConfigError, GitRemote, MobileAppearance},
|
config::{Config, ConfigError, GitRemote, MobileAppearance},
|
||||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||||
@@ -33,7 +39,8 @@ use crate::{
|
|||||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
|
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
|
||||||
},
|
},
|
||||||
secret_store::{
|
secret_store::{
|
||||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
|
||||||
|
SecretStoreBackend, SecretStoreError,
|
||||||
},
|
},
|
||||||
write::{VaultWriter, WriteError},
|
write::{VaultWriter, WriteError},
|
||||||
};
|
};
|
||||||
@@ -279,7 +286,7 @@ impl MobileAuthenticationState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ActiveMobileLease {
|
struct ActiveMobileLease {
|
||||||
handle: NativeAuthenticationHandle,
|
handle: MobileAuthenticationHandle,
|
||||||
key: KeyInfo,
|
key: KeyInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,11 +333,17 @@ impl Drop for MobileRepositoryOperationGuard<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One process-wide mobile authentication lease shared by every tab and viewer.
|
/// One process-wide mobile authentication lease shared by every tab and viewer.
|
||||||
|
type MobileAuthenticationSession =
|
||||||
|
AuthenticationSession<Arc<dyn SecretStoreBackend>, Arc<dyn AuthenticationClock>>;
|
||||||
|
type MobileAuthenticationHandle =
|
||||||
|
AuthenticationHandle<Arc<dyn SecretStoreBackend>, Arc<dyn AuthenticationClock>>;
|
||||||
|
|
||||||
pub struct MobileAuthentication {
|
pub struct MobileAuthentication {
|
||||||
config: Config,
|
config: Config,
|
||||||
repository: Repository,
|
repository: Repository,
|
||||||
keys: KeyStore,
|
keys: KeyStore,
|
||||||
session: NativeAuthenticationSession,
|
session: MobileAuthenticationSession,
|
||||||
|
backend: Arc<dyn SecretStoreBackend>,
|
||||||
status: Mutex<MobileAuthenticationStatus>,
|
status: Mutex<MobileAuthenticationStatus>,
|
||||||
totp_discovery: Mutex<()>,
|
totp_discovery: Mutex<()>,
|
||||||
}
|
}
|
||||||
@@ -344,13 +357,28 @@ impl MobileAuthentication {
|
|||||||
error.to_string(),
|
error.to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let backend = Arc::new(NativeSecretBackend::new().map_err(preference_secret_error)?);
|
||||||
|
Self::with_platform(
|
||||||
|
config,
|
||||||
|
backend,
|
||||||
|
Arc::new(SystemAuthenticationClock::default()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OS mechanics are injected; configuration, protection, leases and storage stay here.
|
||||||
|
pub fn with_platform(
|
||||||
|
config: Config,
|
||||||
|
backend: Arc<dyn SecretStoreBackend>,
|
||||||
|
clock: Arc<dyn AuthenticationClock>,
|
||||||
|
) -> Result<Self, MobileAuthenticationError> {
|
||||||
let repository = Repository::open(config.vault()).map_err(entry_error)?;
|
let repository = Repository::open(config.vault()).map_err(entry_error)?;
|
||||||
let keys = KeyStore::load(config.key_material()).map_err(key_error)?;
|
let keys = KeyStore::load(config.key_material()).map_err(key_error)?;
|
||||||
let session = NativeAuthenticationSession::system(
|
let session = MobileAuthenticationSession::with_clock(
|
||||||
|
Arc::clone(&backend),
|
||||||
SecretProtectionPolicy::current_biometry_for_openpgp(),
|
SecretProtectionPolicy::current_biometry_for_openpgp(),
|
||||||
config.authentication_timeout(),
|
config.authentication_timeout(),
|
||||||
)
|
clock,
|
||||||
.map_err(MobileAuthenticationError::authentication)?;
|
);
|
||||||
let watch_snapshot =
|
let watch_snapshot =
|
||||||
WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml"));
|
WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml"));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -371,6 +399,7 @@ impl MobileAuthentication {
|
|||||||
repository,
|
repository,
|
||||||
keys,
|
keys,
|
||||||
session,
|
session,
|
||||||
|
backend,
|
||||||
totp_discovery: Mutex::new(()),
|
totp_discovery: Mutex::new(()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -599,7 +628,7 @@ impl MobileAuthentication {
|
|||||||
sync_configured: remote.is_some(),
|
sync_configured: remote.is_some(),
|
||||||
repository_title,
|
repository_title,
|
||||||
repository_url: remote.map_or_else(
|
repository_url: remote.map_or_else(
|
||||||
|| "Stored and versioned on this iPhone".to_owned(),
|
|| "Stored and versioned on this device".to_owned(),
|
||||||
|remote| remote.url().to_string(),
|
|remote| remote.url().to_string(),
|
||||||
),
|
),
|
||||||
server_title: remote
|
server_title: remote
|
||||||
@@ -621,7 +650,10 @@ impl MobileAuthentication {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
application_account: remote.map(application_account).transpose()?.flatten(),
|
application_account: remote
|
||||||
|
.map(|remote| application_account(remote, Arc::clone(&self.backend)))
|
||||||
|
.transpose()?
|
||||||
|
.flatten(),
|
||||||
default_key_title: key
|
default_key_title: key
|
||||||
.user_ids()
|
.user_ids()
|
||||||
.first()
|
.first()
|
||||||
@@ -674,7 +706,7 @@ impl MobileAuthentication {
|
|||||||
.find(|remote| remote.name().as_str() == "origin")
|
.find(|remote| remote.name().as_str() == "origin")
|
||||||
.or_else(|| self.config.git_remotes().first())
|
.or_else(|| self.config.git_remotes().first())
|
||||||
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
|
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
|
||||||
let store = preference_secret_store()?;
|
let store = preference_secret_store(Arc::clone(&self.backend));
|
||||||
store.unlock().map_err(preference_secret_error)?;
|
store.unlock().map_err(preference_secret_error)?;
|
||||||
let (server_id, application_id) = remote.https_credentials().ok_or_else(|| {
|
let (server_id, application_id) = remote.https_credentials().ok_or_else(|| {
|
||||||
config_detail("The configured Git remote does not use HTTPS credentials.")
|
config_detail("The configured Git remote does not use HTTPS credentials.")
|
||||||
@@ -1276,6 +1308,26 @@ impl MobileAuthentication {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn lifecycle(
|
||||||
|
&self,
|
||||||
|
event: crate::mobile_platform::MobileLifecycleEvent,
|
||||||
|
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
|
||||||
|
use crate::mobile_platform::MobileLifecycleEvent;
|
||||||
|
match event {
|
||||||
|
MobileLifecycleEvent::Foreground => {}
|
||||||
|
MobileLifecycleEvent::Background | MobileLifecycleEvent::DeviceLocked => {
|
||||||
|
self.manual_lock()?
|
||||||
|
}
|
||||||
|
MobileLifecycleEvent::UserActivity => {
|
||||||
|
if self.state()?.unlocked() {
|
||||||
|
self.touch_user_activity()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MobileLifecycleEvent::Cancel => self.cancel()?,
|
||||||
|
}
|
||||||
|
self.state()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cancel(&self) -> Result<(), MobileAuthenticationError> {
|
pub fn cancel(&self) -> Result<(), MobileAuthenticationError> {
|
||||||
self.session
|
self.session
|
||||||
.cancel()
|
.cancel()
|
||||||
@@ -1319,7 +1371,7 @@ impl MobileAuthentication {
|
|||||||
|
|
||||||
fn set_active(
|
fn set_active(
|
||||||
&self,
|
&self,
|
||||||
handle: NativeAuthenticationHandle,
|
handle: MobileAuthenticationHandle,
|
||||||
key: KeyInfo,
|
key: KeyInfo,
|
||||||
) -> Result<(), MobileAuthenticationError> {
|
) -> Result<(), MobileAuthenticationError> {
|
||||||
self.status()?.active = Some(ActiveMobileLease { handle, key });
|
self.status()?.active = Some(ActiveMobileLease { handle, key });
|
||||||
@@ -1529,12 +1581,12 @@ fn repository_operation_conflict(
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct KeyOnlyProvider<'a> {
|
struct KeyOnlyProvider<'a> {
|
||||||
handle: NativeAuthenticationHandle,
|
handle: MobileAuthenticationHandle,
|
||||||
fingerprint: &'a str,
|
fingerprint: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> KeyOnlyProvider<'a> {
|
impl<'a> KeyOnlyProvider<'a> {
|
||||||
fn new(handle: NativeAuthenticationHandle, key: &'a KeyInfo) -> Self {
|
fn new(handle: MobileAuthenticationHandle, key: &'a KeyInfo) -> Self {
|
||||||
Self {
|
Self {
|
||||||
handle,
|
handle,
|
||||||
fingerprint: key.fingerprint().as_str(),
|
fingerprint: key.fingerprint().as_str(),
|
||||||
@@ -1567,19 +1619,24 @@ fn config_detail(detail: &str) -> MobileAuthenticationError {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn preference_secret_store() -> Result<NativeSecretStore, MobileAuthenticationError> {
|
fn preference_secret_store(
|
||||||
NativeSecretStore::system(
|
backend: Arc<dyn SecretStoreBackend>,
|
||||||
|
) -> SecretStore<Arc<dyn SecretStoreBackend>> {
|
||||||
|
SecretStore::new(
|
||||||
|
backend,
|
||||||
SecretCachePolicy::Disabled,
|
SecretCachePolicy::Disabled,
|
||||||
SecretProtectionPolicy::device_unlocked(),
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
)
|
)
|
||||||
.map_err(preference_secret_error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthenticationError> {
|
fn application_account(
|
||||||
|
remote: &GitRemote,
|
||||||
|
backend: Arc<dyn SecretStoreBackend>,
|
||||||
|
) -> Result<Option<String>, MobileAuthenticationError> {
|
||||||
let Some((server_id, application_id)) = remote.https_credentials() else {
|
let Some((server_id, application_id)) = remote.https_credentials() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let store = preference_secret_store()?;
|
let store = preference_secret_store(backend);
|
||||||
store.unlock().map_err(preference_secret_error)?;
|
store.unlock().map_err(preference_secret_error)?;
|
||||||
let result = store.https_git_credential_account(server_id, application_id);
|
let result = store.https_git_credential_account(server_id, application_id);
|
||||||
let lock_result = store.lock();
|
let lock_result = store.lock();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Storage-owned projection of lossless entry documents for native mobile viewers.
|
//! Storage-owned projection of lossless entry documents for native mobile viewers.
|
||||||
|
|
||||||
|
use crate::mobile::MobileIcon;
|
||||||
|
|
||||||
use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize};
|
use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize};
|
||||||
|
|
||||||
use crate::document::{
|
use crate::document::{
|
||||||
@@ -22,7 +24,7 @@ pub enum MobileEntrySectionKind {
|
|||||||
pub struct MobileEntryField {
|
pub struct MobileEntryField {
|
||||||
id: u64,
|
id: u64,
|
||||||
label: String,
|
label: String,
|
||||||
system_image: String,
|
icon: MobileIcon,
|
||||||
value: Option<String>,
|
value: Option<String>,
|
||||||
masked_value: String,
|
masked_value: String,
|
||||||
detail: Option<String>,
|
detail: Option<String>,
|
||||||
@@ -42,8 +44,8 @@ impl MobileEntryField {
|
|||||||
&self.label
|
&self.label
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system_image(&self) -> &str {
|
pub fn icon(&self) -> MobileIcon {
|
||||||
&self.system_image
|
self.icon
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn value(&self) -> Option<&str> {
|
pub fn value(&self) -> Option<&str> {
|
||||||
@@ -121,7 +123,7 @@ pub struct MobileEntryEditorField {
|
|||||||
kind: MobileEntryEditorFieldKind,
|
kind: MobileEntryEditorFieldKind,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
label: String,
|
label: String,
|
||||||
system_image: String,
|
icon: MobileIcon,
|
||||||
value: Option<String>,
|
value: Option<String>,
|
||||||
masked_value: String,
|
masked_value: String,
|
||||||
diagnostic: Option<String>,
|
diagnostic: Option<String>,
|
||||||
@@ -149,8 +151,8 @@ impl MobileEntryEditorField {
|
|||||||
&self.label
|
&self.label
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system_image(&self) -> &str {
|
pub fn icon(&self) -> MobileIcon {
|
||||||
&self.system_image
|
self.icon
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn value(&self) -> Option<&str> {
|
pub fn value(&self) -> Option<&str> {
|
||||||
@@ -573,7 +575,7 @@ fn mobile_field(field: &EntryField) -> MobileEntryField {
|
|||||||
MobileEntryField {
|
MobileEntryField {
|
||||||
id: field.id().value(),
|
id: field.id().value(),
|
||||||
label: label(field),
|
label: label(field),
|
||||||
system_image: system_image(metadata.kind()).to_owned(),
|
icon: icon(metadata.kind()),
|
||||||
value,
|
value,
|
||||||
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
|
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
|
||||||
"Empty"
|
"Empty"
|
||||||
@@ -614,7 +616,7 @@ fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField {
|
|||||||
kind,
|
kind,
|
||||||
name: metadata.name().map(str::to_owned),
|
name: metadata.name().map(str::to_owned),
|
||||||
label: label(field),
|
label: label(field),
|
||||||
system_image: system_image(metadata.kind()).to_owned(),
|
icon: icon(metadata.kind()),
|
||||||
value: String::from_utf8(field.value().to_vec()).ok(),
|
value: String::from_utf8(field.value().to_vec()).ok(),
|
||||||
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
|
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
|
||||||
"Empty"
|
"Empty"
|
||||||
@@ -705,15 +707,15 @@ fn display_name(name: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn system_image(kind: EntryFieldKind) -> &'static str {
|
fn icon(kind: EntryFieldKind) -> MobileIcon {
|
||||||
match kind {
|
match kind {
|
||||||
EntryFieldKind::Password => "key.fill",
|
EntryFieldKind::Password => MobileIcon::Key,
|
||||||
EntryFieldKind::Username => "person.fill",
|
EntryFieldKind::Username => MobileIcon::User,
|
||||||
EntryFieldKind::Email => "envelope.fill",
|
EntryFieldKind::Email => MobileIcon::Email,
|
||||||
EntryFieldKind::Url => "globe",
|
EntryFieldKind::Url => MobileIcon::Website,
|
||||||
EntryFieldKind::OtpUri => "timer",
|
EntryFieldKind::OtpUri => MobileIcon::Timer,
|
||||||
EntryFieldKind::Field => "text.alignleft",
|
EntryFieldKind::Field => MobileIcon::Text,
|
||||||
EntryFieldKind::Note => "note.text",
|
EntryFieldKind::Note => MobileIcon::Note,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
//! Storage-owned remote activity and pull-then-push refresh for iPhone Home.
|
//! Storage-owned remote activity and pull-then-push refresh for mobile Home.
|
||||||
|
|
||||||
|
use crate::mobile::MobileIcon;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeSet,
|
collections::BTreeSet,
|
||||||
@@ -62,7 +64,7 @@ pub enum MobileHomeActionKind {
|
|||||||
pub struct MobileHomeAction {
|
pub struct MobileHomeAction {
|
||||||
kind: MobileHomeActionKind,
|
kind: MobileHomeActionKind,
|
||||||
title: &'static str,
|
title: &'static str,
|
||||||
system_image: &'static str,
|
icon: MobileIcon,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MobileHomeAction {
|
impl MobileHomeAction {
|
||||||
@@ -74,8 +76,8 @@ impl MobileHomeAction {
|
|||||||
self.title
|
self.title
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system_image(&self) -> &str {
|
pub fn icon(&self) -> MobileIcon {
|
||||||
self.system_image
|
self.icon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@ pub struct MobileHomeSummaryRow {
|
|||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
detail: String,
|
detail: String,
|
||||||
system_image: String,
|
icon: MobileIcon,
|
||||||
actions: Vec<MobileHomeAction>,
|
actions: Vec<MobileHomeAction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,8 +113,8 @@ impl MobileHomeSummaryRow {
|
|||||||
&self.detail
|
&self.detail
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system_image(&self) -> &str {
|
pub fn icon(&self) -> MobileIcon {
|
||||||
&self.system_image
|
self.icon
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn actions(&self) -> &[MobileHomeAction] {
|
pub fn actions(&self) -> &[MobileHomeAction] {
|
||||||
@@ -125,7 +127,7 @@ pub struct MobileHomeChange {
|
|||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
detail: String,
|
detail: String,
|
||||||
system_image: String,
|
icon: MobileIcon,
|
||||||
kind: MobileHomeChangeKind,
|
kind: MobileHomeChangeKind,
|
||||||
status: MobileHomeChangeStatus,
|
status: MobileHomeChangeStatus,
|
||||||
}
|
}
|
||||||
@@ -143,8 +145,8 @@ impl MobileHomeChange {
|
|||||||
&self.detail
|
&self.detail
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system_image(&self) -> &str {
|
pub fn icon(&self) -> MobileIcon {
|
||||||
&self.system_image
|
self.icon
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn kind(&self) -> MobileHomeChangeKind {
|
pub fn kind(&self) -> MobileHomeChangeKind {
|
||||||
@@ -161,7 +163,7 @@ pub struct MobileHomeCommit {
|
|||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
detail: String,
|
detail: String,
|
||||||
system_image: String,
|
icon: MobileIcon,
|
||||||
timestamp: i64,
|
timestamp: i64,
|
||||||
changes: Vec<MobileHomeChange>,
|
changes: Vec<MobileHomeChange>,
|
||||||
actions: Vec<MobileHomeAction>,
|
actions: Vec<MobileHomeAction>,
|
||||||
@@ -180,8 +182,8 @@ impl MobileHomeCommit {
|
|||||||
&self.detail
|
&self.detail
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system_image(&self) -> &str {
|
pub fn icon(&self) -> MobileIcon {
|
||||||
&self.system_image
|
self.icon
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn timestamp(&self) -> i64 {
|
pub fn timestamp(&self) -> i64 {
|
||||||
@@ -201,7 +203,7 @@ impl MobileHomeCommit {
|
|||||||
pub struct MobileHomeNotice {
|
pub struct MobileHomeNotice {
|
||||||
title: String,
|
title: String,
|
||||||
detail: String,
|
detail: String,
|
||||||
system_image: String,
|
icon: MobileIcon,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MobileHomeNotice {
|
impl MobileHomeNotice {
|
||||||
@@ -213,8 +215,8 @@ impl MobileHomeNotice {
|
|||||||
&self.detail
|
&self.detail
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system_image(&self) -> &str {
|
pub fn icon(&self) -> MobileIcon {
|
||||||
&self.system_image
|
self.icon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,7 +617,7 @@ impl MobileHomeStorage {
|
|||||||
.to_owned(),
|
.to_owned(),
|
||||||
detail: "The repository was updated, but the refresh time could not be cached."
|
detail: "The repository was updated, but the refresh time could not be cached."
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
system_image: "exclamationmark.triangle".to_owned(),
|
icon: MobileIcon::Warning,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
self.page(MobileHomeFreshness::Current, Some(now), notice)
|
self.page(MobileHomeFreshness::Current, Some(now), notice)
|
||||||
@@ -938,7 +940,7 @@ fn page_from_divergence(
|
|||||||
id: "tracked-branch".to_owned(),
|
id: "tracked-branch".to_owned(),
|
||||||
title: format!("{}/{}", divergence.remote().name(), divergence.branch()),
|
title: format!("{}/{}", divergence.remote().name(), divergence.branch()),
|
||||||
detail: "Tracked HTTPS branch".to_owned(),
|
detail: "Tracked HTTPS branch".to_owned(),
|
||||||
system_image: "point.3.connected.trianglepath.dotted".to_owned(),
|
icon: MobileIcon::Branch,
|
||||||
actions: vec![
|
actions: vec![
|
||||||
mobile_action(MobileHomeActionKind::Fetch),
|
mobile_action(MobileHomeActionKind::Fetch),
|
||||||
mobile_action(MobileHomeActionKind::Pull),
|
mobile_action(MobileHomeActionKind::Pull),
|
||||||
@@ -949,10 +951,10 @@ fn page_from_divergence(
|
|||||||
id: "divergence".to_owned(),
|
id: "divergence".to_owned(),
|
||||||
title: divergence_title,
|
title: divergence_title,
|
||||||
detail: divergence_detail,
|
detail: divergence_detail,
|
||||||
system_image: if ahead == 0 && behind == 0 {
|
icon: if ahead == 0 && behind == 0 {
|
||||||
"checkmark.circle".to_owned()
|
MobileIcon::Success
|
||||||
} else {
|
} else {
|
||||||
"arrow.triangle.2.circlepath".to_owned()
|
MobileIcon::Sync
|
||||||
},
|
},
|
||||||
actions: [
|
actions: [
|
||||||
(behind > 0).then(|| mobile_action(MobileHomeActionKind::Pull)),
|
(behind > 0).then(|| mobile_action(MobileHomeActionKind::Pull)),
|
||||||
@@ -974,10 +976,10 @@ fn page_from_divergence(
|
|||||||
} else {
|
} else {
|
||||||
"Commit or discard these changes before pulling".to_owned()
|
"Commit or discard these changes before pulling".to_owned()
|
||||||
},
|
},
|
||||||
system_image: if local_count == 0 {
|
icon: if local_count == 0 {
|
||||||
"checkmark.shield".to_owned()
|
MobileIcon::Protected
|
||||||
} else {
|
} else {
|
||||||
"exclamationmark.triangle".to_owned()
|
MobileIcon::Warning
|
||||||
},
|
},
|
||||||
actions: if local_count > 0 {
|
actions: if local_count > 0 {
|
||||||
vec![mobile_action(MobileHomeActionKind::Commit)]
|
vec![mobile_action(MobileHomeActionKind::Commit)]
|
||||||
@@ -1040,7 +1042,7 @@ fn page_from_local_snapshot(
|
|||||||
id: "local-branch".to_owned(),
|
id: "local-branch".to_owned(),
|
||||||
title: snapshot.branch().to_owned(),
|
title: snapshot.branch().to_owned(),
|
||||||
detail: "Local Git branch · no sync remote configured".to_owned(),
|
detail: "Local Git branch · no sync remote configured".to_owned(),
|
||||||
system_image: "externaldrive.fill".to_owned(),
|
icon: MobileIcon::Repository,
|
||||||
actions: Vec::new(),
|
actions: Vec::new(),
|
||||||
},
|
},
|
||||||
MobileHomeSummaryRow {
|
MobileHomeSummaryRow {
|
||||||
@@ -1051,7 +1053,7 @@ fn page_from_local_snapshot(
|
|||||||
if commit_count == 1 { "" } else { "s" }
|
if commit_count == 1 { "" } else { "s" }
|
||||||
),
|
),
|
||||||
detail: latest.unwrap_or_else(|| "No local history yet".to_owned()),
|
detail: latest.unwrap_or_else(|| "No local history yet".to_owned()),
|
||||||
system_image: "clock.arrow.circlepath".to_owned(),
|
icon: MobileIcon::History,
|
||||||
actions: Vec::new(),
|
actions: Vec::new(),
|
||||||
},
|
},
|
||||||
working_tree_summary(local_paths.len()),
|
working_tree_summary(local_paths.len()),
|
||||||
@@ -1077,10 +1079,10 @@ fn working_tree_summary(local_count: usize) -> MobileHomeSummaryRow {
|
|||||||
} else {
|
} else {
|
||||||
"Commit these local password-store changes".to_owned()
|
"Commit these local password-store changes".to_owned()
|
||||||
},
|
},
|
||||||
system_image: if local_count == 0 {
|
icon: if local_count == 0 {
|
||||||
"checkmark.shield".to_owned()
|
MobileIcon::Protected
|
||||||
} else {
|
} else {
|
||||||
"exclamationmark.triangle".to_owned()
|
MobileIcon::Warning
|
||||||
},
|
},
|
||||||
actions: if local_count > 0 {
|
actions: if local_count > 0 {
|
||||||
vec![mobile_action(MobileHomeActionKind::Commit)]
|
vec![mobile_action(MobileHomeActionKind::Commit)]
|
||||||
@@ -1124,10 +1126,10 @@ fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeComm
|
|||||||
},
|
},
|
||||||
change_count(changes.len())
|
change_count(changes.len())
|
||||||
),
|
),
|
||||||
system_image: if incoming {
|
icon: if incoming {
|
||||||
"arrow.down.circle".to_owned()
|
MobileIcon::Incoming
|
||||||
} else {
|
} else {
|
||||||
"arrow.up.circle".to_owned()
|
MobileIcon::Outgoing
|
||||||
},
|
},
|
||||||
timestamp: commit.timestamp(),
|
timestamp: commit.timestamp(),
|
||||||
changes,
|
changes,
|
||||||
@@ -1147,33 +1149,33 @@ fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChan
|
|||||||
MobileHomeChangeStatus::Deleted => "Deleted",
|
MobileHomeChangeStatus::Deleted => "Deleted",
|
||||||
};
|
};
|
||||||
let file_name = path.file_name().and_then(|name| name.to_str());
|
let file_name = path.file_name().and_then(|name| name.to_str());
|
||||||
let (title, detail, system_image, change_kind) =
|
let (title, detail, icon, change_kind) =
|
||||||
if path.extension().is_some_and(|extension| extension == "gpg") {
|
if path.extension().is_some_and(|extension| extension == "gpg") {
|
||||||
(
|
(
|
||||||
display_path(&path.with_extension("")),
|
display_path(&path.with_extension("")),
|
||||||
format!("{status_name} password entry"),
|
format!("{status_name} password entry"),
|
||||||
"key".to_owned(),
|
MobileIcon::Key,
|
||||||
MobileHomeChangeKind::PasswordEntry,
|
MobileHomeChangeKind::PasswordEntry,
|
||||||
)
|
)
|
||||||
} else if file_name == Some(".gpg-id") {
|
} else if file_name == Some(".gpg-id") {
|
||||||
(
|
(
|
||||||
policy_title(path, "recipients"),
|
policy_title(path, "recipients"),
|
||||||
format!("{status_name} recipient policy"),
|
format!("{status_name} recipient policy"),
|
||||||
"person.2".to_owned(),
|
MobileIcon::Recipients,
|
||||||
MobileHomeChangeKind::RecipientPolicy,
|
MobileHomeChangeKind::RecipientPolicy,
|
||||||
)
|
)
|
||||||
} else if file_name == Some(".gpg-id.sig") {
|
} else if file_name == Some(".gpg-id.sig") {
|
||||||
(
|
(
|
||||||
policy_title(path, "recipient signature"),
|
policy_title(path, "recipient signature"),
|
||||||
format!("{status_name} recipient signature"),
|
format!("{status_name} recipient signature"),
|
||||||
"signature".to_owned(),
|
MobileIcon::Signature,
|
||||||
MobileHomeChangeKind::RecipientSignature,
|
MobileHomeChangeKind::RecipientSignature,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
display_path(path),
|
display_path(path),
|
||||||
format!("{status_name} repository file"),
|
format!("{status_name} repository file"),
|
||||||
"doc".to_owned(),
|
MobileIcon::File,
|
||||||
MobileHomeChangeKind::RepositoryFile,
|
MobileHomeChangeKind::RepositoryFile,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -1181,7 +1183,7 @@ fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChan
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
detail,
|
detail,
|
||||||
system_image,
|
icon,
|
||||||
kind: change_kind,
|
kind: change_kind,
|
||||||
status,
|
status,
|
||||||
}
|
}
|
||||||
@@ -1228,27 +1230,27 @@ fn change_count(count: usize) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice {
|
fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice {
|
||||||
let (title, detail, system_image) = match outcome {
|
let (title, detail, icon) = match outcome {
|
||||||
PullOutcome::UpToDate => (
|
PullOutcome::UpToDate => (
|
||||||
"Already Up to Date",
|
"Already Up to Date",
|
||||||
"The local password store already contains every fetched commit.",
|
"The local password store already contains every fetched commit.",
|
||||||
"checkmark.circle",
|
MobileIcon::Success,
|
||||||
),
|
),
|
||||||
PullOutcome::FastForward => (
|
PullOutcome::FastForward => (
|
||||||
"Password Store Updated",
|
"Password Store Updated",
|
||||||
"Remote commits were applied without changing local history.",
|
"Remote commits were applied without changing local history.",
|
||||||
"arrow.down.circle",
|
MobileIcon::Incoming,
|
||||||
),
|
),
|
||||||
PullOutcome::Merged => (
|
PullOutcome::Merged => (
|
||||||
"Remote Changes Merged",
|
"Remote Changes Merged",
|
||||||
"Remote and local commits were merged by storage.",
|
"Remote and local commits were merged by storage.",
|
||||||
"arrow.triangle.merge",
|
MobileIcon::Merge,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
MobileHomeNotice {
|
MobileHomeNotice {
|
||||||
title: title.to_owned(),
|
title: title.to_owned(),
|
||||||
detail: detail.to_owned(),
|
detail: detail.to_owned(),
|
||||||
system_image: system_image.to_owned(),
|
icon,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1256,7 +1258,7 @@ fn commit_notice(commit: &str) -> MobileHomeNotice {
|
|||||||
MobileHomeNotice {
|
MobileHomeNotice {
|
||||||
title: "Changes Committed".to_owned(),
|
title: "Changes Committed".to_owned(),
|
||||||
detail: format!("Created commit {}.", &commit[..commit.len().min(12)]),
|
detail: format!("Created commit {}.", &commit[..commit.len().min(12)]),
|
||||||
system_image: "checkmark.circle".to_owned(),
|
icon: MobileIcon::Success,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1276,7 +1278,7 @@ fn fetch_notice(outcome: &FetchOutcome) -> MobileHomeNotice {
|
|||||||
} else {
|
} else {
|
||||||
format!("{} has no new objects for this clone.", outcome.remote())
|
format!("{} has no new objects for this clone.", outcome.remote())
|
||||||
},
|
},
|
||||||
system_image: "arrow.clockwise.circle".to_owned(),
|
icon: MobileIcon::Refresh,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1302,22 +1304,18 @@ fn push_notice(outcome: &PushOutcome) -> MobileHomeNotice {
|
|||||||
outcome.branch()
|
outcome.branch()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
system_image: "arrow.up.circle".to_owned(),
|
icon: MobileIcon::Outgoing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mobile_action(kind: MobileHomeActionKind) -> MobileHomeAction {
|
fn mobile_action(kind: MobileHomeActionKind) -> MobileHomeAction {
|
||||||
let (title, system_image) = match kind {
|
let (title, icon) = match kind {
|
||||||
MobileHomeActionKind::Commit => ("Commit", "checkmark.circle"),
|
MobileHomeActionKind::Commit => ("Commit", MobileIcon::Success),
|
||||||
MobileHomeActionKind::Fetch => ("Fetch", "arrow.clockwise"),
|
MobileHomeActionKind::Fetch => ("Fetch", MobileIcon::Refresh),
|
||||||
MobileHomeActionKind::Pull => ("Pull", "arrow.down.circle"),
|
MobileHomeActionKind::Pull => ("Pull", MobileIcon::Incoming),
|
||||||
MobileHomeActionKind::Push => ("Push", "arrow.up.circle"),
|
MobileHomeActionKind::Push => ("Push", MobileIcon::Outgoing),
|
||||||
};
|
};
|
||||||
MobileHomeAction {
|
MobileHomeAction { kind, title, icon }
|
||||||
kind,
|
|
||||||
title,
|
|
||||||
system_image,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn repository_operation(action: MobileHomeActionKind) -> MobileRepositoryOperation {
|
fn repository_operation(action: MobileHomeActionKind) -> MobileRepositoryOperation {
|
||||||
@@ -1393,7 +1391,10 @@ fn is_stale(refreshed_at: Option<i64>, now: i64) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::git::{GitChangeKind, GitError};
|
use crate::{
|
||||||
|
git::{GitChangeKind, GitError},
|
||||||
|
mobile::MobileIcon,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
MobileHomeActionKind, MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind,
|
MobileHomeActionKind, MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind,
|
||||||
@@ -1446,7 +1447,7 @@ mod tests {
|
|||||||
fn actions_and_progress_are_typed_before_swift_presentation() {
|
fn actions_and_progress_are_typed_before_swift_presentation() {
|
||||||
let push = mobile_action(MobileHomeActionKind::Push);
|
let push = mobile_action(MobileHomeActionKind::Push);
|
||||||
assert_eq!(push.title(), "Push");
|
assert_eq!(push.title(), "Push");
|
||||||
assert_eq!(push.system_image(), "arrow.up.circle");
|
assert_eq!(push.icon(), MobileIcon::Outgoing);
|
||||||
let progress = progress_copy(Some(MobileHomeActionKind::Push), MobileHomePhase::Sending);
|
let progress = progress_copy(Some(MobileHomeActionKind::Push), MobileHomePhase::Sending);
|
||||||
assert_eq!(progress.action(), Some(MobileHomeActionKind::Push));
|
assert_eq!(progress.action(), Some(MobileHomeActionKind::Push));
|
||||||
assert_eq!(progress.title(), "Pushing Changes");
|
assert_eq!(progress.title(), "Pushing Changes");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Storage-owned first-run setup for the native iPhone application.
|
//! Storage-owned first-run setup for the native mobile applications.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
error::Error,
|
||||||
@@ -363,7 +363,7 @@ impl MobileOnboardingOperation {
|
|||||||
result?;
|
result?;
|
||||||
Ok(MobileOnboardingOutcome {
|
Ok(MobileOnboardingOutcome {
|
||||||
title: "Local Password Store Ready".to_owned(),
|
title: "Local Password Store Ready".to_owned(),
|
||||||
detail: "A local Git repository and protected GPG key are ready on this iPhone."
|
detail: "A local Git repository and protected GPG key are ready on this device."
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Storage-owned, locked password navigation for native mobile frontends.
|
//! Storage-owned, locked password navigation for native mobile frontends.
|
||||||
|
|
||||||
|
use crate::mobile::MobileIcon;
|
||||||
|
|
||||||
use std::{error::Error, fmt};
|
use std::{error::Error, fmt};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -20,7 +22,7 @@ pub struct MobilePasswordRow {
|
|||||||
path: String,
|
path: String,
|
||||||
title: String,
|
title: String,
|
||||||
detail: String,
|
detail: String,
|
||||||
system_image: String,
|
icon: MobileIcon,
|
||||||
kind: MobilePasswordRowKind,
|
kind: MobilePasswordRowKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +43,8 @@ impl MobilePasswordRow {
|
|||||||
&self.detail
|
&self.detail
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system_image(&self) -> &str {
|
pub fn icon(&self) -> MobileIcon {
|
||||||
&self.system_image
|
self.icon
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn kind(&self) -> MobilePasswordRowKind {
|
pub fn kind(&self) -> MobilePasswordRowKind {
|
||||||
@@ -168,25 +170,25 @@ fn search_row(path: &crate::repository::EntryPath, text: &str) -> MobilePassword
|
|||||||
path: text.to_owned(),
|
path: text.to_owned(),
|
||||||
title,
|
title,
|
||||||
detail,
|
detail,
|
||||||
system_image: "key.fill".to_owned(),
|
icon: MobileIcon::Key,
|
||||||
kind: MobilePasswordRowKind::Entry,
|
kind: MobilePasswordRowKind::Entry,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
|
fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
|
||||||
let path = node.path().to_owned();
|
let path = node.path().to_owned();
|
||||||
let (kind, prefix, detail, system_image) = match node.kind() {
|
let (kind, prefix, detail, icon) = match node.kind() {
|
||||||
TreeNodeKind::Directory => (
|
TreeNodeKind::Directory => (
|
||||||
MobilePasswordRowKind::Directory,
|
MobilePasswordRowKind::Directory,
|
||||||
"directory",
|
"directory",
|
||||||
entry_count_detail(node),
|
entry_count_detail(node),
|
||||||
"folder",
|
MobileIcon::Folder,
|
||||||
),
|
),
|
||||||
TreeNodeKind::Entry => (
|
TreeNodeKind::Entry => (
|
||||||
MobilePasswordRowKind::Entry,
|
MobilePasswordRowKind::Entry,
|
||||||
"entry",
|
"entry",
|
||||||
"Locked password".to_owned(),
|
"Locked password".to_owned(),
|
||||||
"key.fill",
|
MobileIcon::Key,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
MobilePasswordRow {
|
MobilePasswordRow {
|
||||||
@@ -194,7 +196,7 @@ fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
|
|||||||
path,
|
path,
|
||||||
title: node.name().to_owned(),
|
title: node.name().to_owned(),
|
||||||
detail,
|
detail,
|
||||||
system_image: system_image.to_owned(),
|
icon,
|
||||||
kind,
|
kind,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
//! OS facts accepted by mobile frontends. Storage validates and interprets them.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
error::Error,
|
||||||
|
fmt,
|
||||||
|
path::{Component, Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
presentation::{QrError, QrMatrix},
|
||||||
|
repository::SecretBytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobilePlatformError {
|
||||||
|
InvalidPaths,
|
||||||
|
InvalidCredentialRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MobilePlatformError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::InvalidPaths => "invalid mobile platform paths",
|
||||||
|
Self::InvalidCredentialRequest => "invalid credential-provider request",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for MobilePlatformError {}
|
||||||
|
|
||||||
|
/// The OS supplies private roots; the repository layout is selected here.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct MobilePlatformPaths {
|
||||||
|
data: PathBuf,
|
||||||
|
cache: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobilePlatformPaths {
|
||||||
|
pub fn new(data: PathBuf, cache: PathBuf) -> Result<Self, MobilePlatformError> {
|
||||||
|
for path in [&data, &cache] {
|
||||||
|
if !path.is_absolute()
|
||||||
|
|| path.parent().is_none()
|
||||||
|
|| path.components().any(|component| {
|
||||||
|
!matches!(component, Component::RootDir | Component::Normal(_))
|
||||||
|
})
|
||||||
|
|| path
|
||||||
|
.to_str()
|
||||||
|
.is_none_or(|value| value.len() > 4096 || value.contains('\0'))
|
||||||
|
{
|
||||||
|
return Err(MobilePlatformError::InvalidPaths);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data.starts_with(&cache) || cache.starts_with(&data) {
|
||||||
|
return Err(MobilePlatformError::InvalidPaths);
|
||||||
|
}
|
||||||
|
Ok(Self { data, cache })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data(&self) -> &Path {
|
||||||
|
&self.data
|
||||||
|
}
|
||||||
|
pub fn cache(&self) -> &Path {
|
||||||
|
&self.cache
|
||||||
|
}
|
||||||
|
pub fn configuration(&self) -> PathBuf {
|
||||||
|
self.data.join("config.toml")
|
||||||
|
}
|
||||||
|
pub fn vault(&self) -> PathBuf {
|
||||||
|
self.data.join("vault")
|
||||||
|
}
|
||||||
|
pub fn keys(&self) -> PathBuf {
|
||||||
|
self.data.join("keys")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for MobilePlatformPaths {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("MobilePlatformPaths([REDACTED])")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Y plane, with OS-provided row/pixel strides. No camera or QR policy lives in the UI.
|
||||||
|
pub struct MobileCameraFrame {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub row_stride: u32,
|
||||||
|
pub pixel_stride: u32,
|
||||||
|
pub luminance: SecretBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileCameraFrame {
|
||||||
|
pub fn decode(&self) -> Result<SecretBytes, QrError> {
|
||||||
|
let width = self.width as usize;
|
||||||
|
let height = self.height as usize;
|
||||||
|
let row = self.row_stride as usize;
|
||||||
|
let pixel = self.pixel_stride as usize;
|
||||||
|
let bytes = self.luminance.expose();
|
||||||
|
if !(1..=4096).contains(&width)
|
||||||
|
|| !(1..=4096).contains(&height)
|
||||||
|
|| !(1..=4).contains(&pixel)
|
||||||
|
|| bytes.len() > 32 * 1024 * 1024
|
||||||
|
{
|
||||||
|
return Err(QrError::InvalidImage);
|
||||||
|
}
|
||||||
|
let last_column = (width - 1)
|
||||||
|
.checked_mul(pixel)
|
||||||
|
.ok_or(QrError::InvalidImage)?;
|
||||||
|
let last = (height - 1)
|
||||||
|
.checked_mul(row)
|
||||||
|
.and_then(|offset| offset.checked_add(last_column))
|
||||||
|
.ok_or(QrError::InvalidImage)?;
|
||||||
|
if row <= last_column || last >= bytes.len() {
|
||||||
|
return Err(QrError::InvalidImage);
|
||||||
|
}
|
||||||
|
QrMatrix::decode_luminance(width, height, |x, y| bytes[y * row + x * pixel])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for MobileCameraFrame {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("MobileCameraFrame([REDACTED])")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MobileLifecycleEvent {
|
||||||
|
Foreground,
|
||||||
|
Background,
|
||||||
|
DeviceLocked,
|
||||||
|
UserActivity,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A caller identity is an OS assertion, never an authorization grant.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct MobileCredentialRequest {
|
||||||
|
request_id: String,
|
||||||
|
package: String,
|
||||||
|
signing_certificate_sha256: String,
|
||||||
|
origins: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileCredentialRequest {
|
||||||
|
pub fn new(
|
||||||
|
request_id: String,
|
||||||
|
package: String,
|
||||||
|
certificate: String,
|
||||||
|
origins: Vec<String>,
|
||||||
|
) -> Result<Self, MobilePlatformError> {
|
||||||
|
let invalid = MobilePlatformError::InvalidCredentialRequest;
|
||||||
|
if request_id.is_empty()
|
||||||
|
|| request_id.len() > 256
|
||||||
|
|| request_id.chars().any(char::is_control)
|
||||||
|
|| package.len() > 255
|
||||||
|
|| !package.contains('.')
|
||||||
|
|| package.split('.').any(|part| {
|
||||||
|
part.is_empty()
|
||||||
|
|| !part.bytes().enumerate().all(|(i, ch)| {
|
||||||
|
ch == b'_' || ch.is_ascii_alphabetic() || (i > 0 && ch.is_ascii_digit())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|| certificate.len() != 64
|
||||||
|
|| !certificate.bytes().all(|ch| ch.is_ascii_hexdigit())
|
||||||
|
|| origins.len() > 64
|
||||||
|
{
|
||||||
|
return Err(invalid);
|
||||||
|
}
|
||||||
|
let mut validated = Vec::new();
|
||||||
|
for origin in origins {
|
||||||
|
if origin.len() > 4096
|
||||||
|
|| origin.chars().any(char::is_whitespace)
|
||||||
|
|| origin.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(invalid);
|
||||||
|
}
|
||||||
|
let url = url::Url::parse(&origin).map_err(|_| invalid)?;
|
||||||
|
if !matches!(url.scheme(), "https" | "http")
|
||||||
|
|| url.host_str().is_none()
|
||||||
|
|| !url.username().is_empty()
|
||||||
|
|| url.password().is_some()
|
||||||
|
|| url.query().is_some()
|
||||||
|
|| url.fragment().is_some()
|
||||||
|
|| url.path() != "/"
|
||||||
|
{
|
||||||
|
return Err(invalid);
|
||||||
|
}
|
||||||
|
let origin = url.origin().ascii_serialization();
|
||||||
|
if !validated.contains(&origin) {
|
||||||
|
validated.push(origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
request_id,
|
||||||
|
package,
|
||||||
|
signing_certificate_sha256: certificate.to_ascii_lowercase(),
|
||||||
|
origins: validated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_id(&self) -> &str {
|
||||||
|
&self.request_id
|
||||||
|
}
|
||||||
|
pub fn package(&self) -> &str {
|
||||||
|
&self.package
|
||||||
|
}
|
||||||
|
pub fn signing_certificate_sha256(&self) -> &str {
|
||||||
|
&self.signing_certificate_sha256
|
||||||
|
}
|
||||||
|
pub fn origins(&self) -> &[String] {
|
||||||
|
&self.origins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for MobileCredentialRequest {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("MobileCredentialRequest([REDACTED])")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roots_and_caller_facts_are_validated_without_creating_data_or_grants() {
|
||||||
|
let paths =
|
||||||
|
MobilePlatformPaths::new("/private/app/no-backup".into(), "/private/app/cache".into())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
paths.configuration(),
|
||||||
|
Path::new("/private/app/no-backup/config.toml")
|
||||||
|
);
|
||||||
|
for (data, cache) in [
|
||||||
|
("relative", "/cache"),
|
||||||
|
("/app/../other", "/cache"),
|
||||||
|
("/app", "/app/cache"),
|
||||||
|
("/", "/cache"),
|
||||||
|
] {
|
||||||
|
assert!(MobilePlatformPaths::new(data.into(), cache.into()).is_err());
|
||||||
|
}
|
||||||
|
let request = MobileCredentialRequest::new(
|
||||||
|
"opaque-id".into(),
|
||||||
|
"de.example.app".into(),
|
||||||
|
"A".repeat(64),
|
||||||
|
vec!["https://EXAMPLE.test:443/".into()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(request.origins(), ["https://example.test"]);
|
||||||
|
for origin in [
|
||||||
|
"https://user:secret@example.test",
|
||||||
|
"file:///tmp",
|
||||||
|
"https://example.test/path",
|
||||||
|
"https://example.test/?token=secret",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
MobileCredentialRequest::new(
|
||||||
|
"id".into(),
|
||||||
|
"de.example.app".into(),
|
||||||
|
"A".repeat(64),
|
||||||
|
vec![origin.into()]
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!format!("{request:?}").contains("example"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strided_camera_frame_decodes_in_rust_and_rejects_truncated_or_oversized_planes() {
|
||||||
|
let secret = SecretBytes::new(b"otpauth://totp/Test?secret=JBSWY3DPEHPK3PXP".to_vec());
|
||||||
|
let qr = QrMatrix::encode(&secret).unwrap();
|
||||||
|
let side = (qr.width() + 8) * 8;
|
||||||
|
let row = side * 2 + 7;
|
||||||
|
let mut plane = vec![255; side * row];
|
||||||
|
for y in 0..side {
|
||||||
|
for x in 0..side {
|
||||||
|
if x / 8 >= 4 && y / 8 >= 4 && qr.is_dark(x / 8 - 4, y / 8 - 4) == Some(true) {
|
||||||
|
plane[y * row + x * 2] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut frame = MobileCameraFrame {
|
||||||
|
width: side as u32,
|
||||||
|
height: side as u32,
|
||||||
|
row_stride: row as u32,
|
||||||
|
pixel_stride: 2,
|
||||||
|
luminance: SecretBytes::new(plane),
|
||||||
|
};
|
||||||
|
assert_eq!(frame.decode().unwrap().expose(), secret.expose());
|
||||||
|
frame.row_stride = 1;
|
||||||
|
assert!(frame.decode().is_err());
|
||||||
|
frame.width = 4097;
|
||||||
|
assert!(frame.decode().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -257,11 +257,19 @@ impl QrMatrix {
|
|||||||
.decode()
|
.decode()
|
||||||
.map_err(|_| QrError::InvalidImage)?
|
.map_err(|_| QrError::InvalidImage)?
|
||||||
.into_luma8();
|
.into_luma8();
|
||||||
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(
|
Self::decode_luminance(
|
||||||
grayscale.width() as usize,
|
grayscale.width() as usize,
|
||||||
grayscale.height() as usize,
|
grayscale.height() as usize,
|
||||||
|x, y| grayscale.get_pixel(x as u32, y as u32).0[0],
|
|x, y| grayscale.get_pixel(x as u32, y as u32).0[0],
|
||||||
);
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode_luminance(
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
pixel: impl Fn(usize, usize) -> u8,
|
||||||
|
) -> Result<SecretBytes, QrError> {
|
||||||
|
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(width, height, pixel);
|
||||||
let grid = prepared
|
let grid = prepared
|
||||||
.detect_grids()
|
.detect_grids()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -369,6 +369,51 @@ pub trait SecretStoreBackend: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<T: SecretStoreBackend + ?Sized> SecretStoreBackend for std::sync::Arc<T> {
|
||||||
|
fn create(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
(**self).create(locator, protection, value)
|
||||||
|
}
|
||||||
|
fn retrieve(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
(**self).retrieve(locator, protection)
|
||||||
|
}
|
||||||
|
fn replace(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
value: &[u8],
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
(**self).replace(locator, protection, value)
|
||||||
|
}
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
locator: &SecretLocator,
|
||||||
|
protection: SecretProtection,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
(**self).delete(locator, protection)
|
||||||
|
}
|
||||||
|
fn platform_https_git_credential(
|
||||||
|
&self,
|
||||||
|
endpoint: &url::Url,
|
||||||
|
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
|
||||||
|
(**self).platform_https_git_credential(endpoint)
|
||||||
|
}
|
||||||
|
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
(**self).lock()
|
||||||
|
}
|
||||||
|
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
(**self).unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CachedSecret {
|
struct CachedSecret {
|
||||||
value: SecretBytes,
|
value: SecretBytes,
|
||||||
expires_at: Instant,
|
expires_at: Instant,
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ fn apple_sources_preserve_the_mobile_security_boundary() {
|
|||||||
let watch = workspace_file("apple/Sources/Watch/IronStorageWatchApp.swift");
|
let watch = workspace_file("apple/Sources/Watch/IronStorageWatchApp.swift");
|
||||||
let autofill = workspace_file("apple/Sources/AutoFill/CredentialProviderViewController.swift");
|
let autofill = workspace_file("apple/Sources/AutoFill/CredentialProviderViewController.swift");
|
||||||
let bridges = format!(
|
let bridges = format!(
|
||||||
"{}\n{}",
|
"{}\n{}\n{}\n{}",
|
||||||
workspace_file("crates/apple/src/lib.rs"),
|
workspace_file("crates/apple/src/lib.rs"),
|
||||||
workspace_file("crates/watch-apple/src/lib.rs")
|
workspace_file("crates/watch-apple/src/lib.rs"),
|
||||||
|
workspace_file("crates/mobile-bridge/src/lib.rs"),
|
||||||
|
workspace_file("crates/mobile-bridge/src/platform.rs")
|
||||||
);
|
);
|
||||||
|
|
||||||
for (name, source) in [
|
for (name, source) in [
|
||||||
|
|||||||
@@ -26,6 +26,74 @@ use support::compatibility::FixtureSet;
|
|||||||
|
|
||||||
type TestResult<T = ()> = Result<T, Box<dyn Error>>;
|
type TestResult<T = ()> = Result<T, Box<dyn Error>>;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_configuration()
|
||||||
|
-> TestResult {
|
||||||
|
use ironstorage::{
|
||||||
|
config::Config, mobile_authentication::MobileAuthentication,
|
||||||
|
mobile_platform::MobileLifecycleEvent,
|
||||||
|
};
|
||||||
|
let fixture = FixtureSet::load()?;
|
||||||
|
let vault = fixture.materialize_store("basic")?;
|
||||||
|
let root = tempfile::tempdir()?;
|
||||||
|
let source = root.path().join("config.toml");
|
||||||
|
std::fs::write(
|
||||||
|
&source,
|
||||||
|
format!(
|
||||||
|
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n[[git.remotes]]\nname = \"origin\"\nurl = \"https://example.test/team/passwords.git\"\nserver_id = \"server-example\"\napplication_id = \"repository-example\"\n",
|
||||||
|
vault.path().to_str().expect("fixture UTF8"),
|
||||||
|
fixture.path("keys").to_str().expect("fixture UTF8"),
|
||||||
|
fixture.key("alice")?.primary_fingerprint,
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
let backend = MemoryBackend::default();
|
||||||
|
let clock = ManualClock::default();
|
||||||
|
let authentication = MobileAuthentication::with_platform(
|
||||||
|
Config::load(Some(&source))?,
|
||||||
|
Arc::new(backend.clone()),
|
||||||
|
Arc::new(clock.clone()),
|
||||||
|
)?;
|
||||||
|
assert!(
|
||||||
|
!authentication
|
||||||
|
.lifecycle(MobileLifecycleEvent::Foreground)?
|
||||||
|
.unlocked()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
authentication
|
||||||
|
.preferences(false, false, false)?
|
||||||
|
.application_account(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
backend.retrieves(),
|
||||||
|
1,
|
||||||
|
"preferences must use the injected backend"
|
||||||
|
);
|
||||||
|
authentication.set_authentication_timeout(30)?;
|
||||||
|
authentication.unlock_entry(
|
||||||
|
"email/personal",
|
||||||
|
Some(SecretBytes::new(
|
||||||
|
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||||
|
)),
|
||||||
|
)?;
|
||||||
|
assert!(authentication.state()?.unlocked());
|
||||||
|
clock.advance(Duration::from_secs(20));
|
||||||
|
assert_eq!(authentication.state()?.remaining_seconds(), 10);
|
||||||
|
authentication.lifecycle(MobileLifecycleEvent::UserActivity)?;
|
||||||
|
assert_eq!(authentication.state()?.remaining_seconds(), 30);
|
||||||
|
let editor = authentication.begin_entry_editor("email/personal")?;
|
||||||
|
authentication.lifecycle(MobileLifecycleEvent::Background)?;
|
||||||
|
assert!(!authentication.state()?.unlocked());
|
||||||
|
assert!(authentication.entry_editor(editor.id()).is_err());
|
||||||
|
let baseline_locks = backend.locks();
|
||||||
|
drop(authentication);
|
||||||
|
assert!(
|
||||||
|
backend.locks() > baseline_locks,
|
||||||
|
"final owner locks the OS backend"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct BackendState {
|
struct BackendState {
|
||||||
values: BTreeMap<SecretLocator, SecretBytes>,
|
values: BTreeMap<SecretLocator, SecretBytes>,
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
|||||||
assert_eq!(mobile_custom[1].value(), Some(""));
|
assert_eq!(mobile_custom[1].value(), Some(""));
|
||||||
let mobile_otp = mobile_fields
|
let mobile_otp = mobile_fields
|
||||||
.iter()
|
.iter()
|
||||||
.find(|field| field.system_image() == "timer")
|
.find(|field| field.icon() == ironstorage::mobile::MobileIcon::Timer)
|
||||||
.expect("OTP field");
|
.expect("OTP field");
|
||||||
assert!(mobile_otp.sensitive());
|
assert!(mobile_otp.sensitive());
|
||||||
assert_eq!(mobile_otp.value(), None);
|
assert_eq!(mobile_otp.value(), None);
|
||||||
@@ -192,7 +192,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
|||||||
}));
|
}));
|
||||||
let mobile_note = mobile_fields
|
let mobile_note = mobile_fields
|
||||||
.iter()
|
.iter()
|
||||||
.find(|field| field.system_image() == "note.text")
|
.find(|field| field.icon() == ironstorage::mobile::MobileIcon::Note)
|
||||||
.expect("multiline note");
|
.expect("multiline note");
|
||||||
assert!(!mobile_note.sensitive());
|
assert!(!mobile_note.sensitive());
|
||||||
assert!(mobile_note.multiline());
|
assert!(mobile_note.multiline());
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const WORKSPACE: &str = include_str!("../../../Cargo.toml");
|
const WORKSPACE: &str = include_str!("../../../Cargo.toml");
|
||||||
const STORAGE_MANIFEST: &str = include_str!("../Cargo.toml");
|
const STORAGE_MANIFEST: &str = include_str!("../Cargo.toml");
|
||||||
const APPLE_MANIFEST: &str = include_str!("../../apple/Cargo.toml");
|
const APPLE_MANIFEST: &str = include_str!("../../apple/Cargo.toml");
|
||||||
|
const ANDROID_MANIFEST: &str = include_str!("../../android/Cargo.toml");
|
||||||
|
const MOBILE_MANIFEST: &str = include_str!("../../mobile-bridge/Cargo.toml");
|
||||||
const CLI_MANIFEST: &str = include_str!("../../../apps/cli/Cargo.toml");
|
const CLI_MANIFEST: &str = include_str!("../../../apps/cli/Cargo.toml");
|
||||||
const TUI_MANIFEST: &str = include_str!("../../../apps/tui/Cargo.toml");
|
const TUI_MANIFEST: &str = include_str!("../../../apps/tui/Cargo.toml");
|
||||||
const DESKTOP_MANIFEST: &str = include_str!("../../../apps/desktop/Cargo.toml");
|
const DESKTOP_MANIFEST: &str = include_str!("../../../apps/desktop/Cargo.toml");
|
||||||
@@ -18,8 +20,16 @@ fn release_feature_and_advisory_boundaries_stay_explicit() {
|
|||||||
for manifest in [CLI_MANIFEST, TUI_MANIFEST, DESKTOP_MANIFEST] {
|
for manifest in [CLI_MANIFEST, TUI_MANIFEST, DESKTOP_MANIFEST] {
|
||||||
assert!(manifest.contains("features = [\"ssh\"]"));
|
assert!(manifest.contains("features = [\"ssh\"]"));
|
||||||
}
|
}
|
||||||
assert!(APPLE_MANIFEST.contains("default-features = false, features = [\"full\"]"));
|
assert!(MOBILE_MANIFEST.contains("default-features = false, features = [\"full\"]"));
|
||||||
assert!(!APPLE_MANIFEST.contains("features = [\"ssh\"]"));
|
assert!(
|
||||||
|
APPLE_MANIFEST.contains(
|
||||||
|
"ironstorage-mobile = { path = \"../mobile-bridge\", features = [\"watch\"] }"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert!(ANDROID_MANIFEST.contains("ironstorage-mobile = { path = \"../mobile-bridge\" }"));
|
||||||
|
for manifest in [APPLE_MANIFEST, ANDROID_MANIFEST, MOBILE_MANIFEST] {
|
||||||
|
assert!(!manifest.contains("features = [\"ssh\"]"));
|
||||||
|
}
|
||||||
assert!(AUDIT_WORKFLOW.contains("tool: rust@1.92.0,cargo-audit@0.22.2"));
|
assert!(AUDIT_WORKFLOW.contains("tool: rust@1.92.0,cargo-audit@0.22.2"));
|
||||||
assert!(AUDIT_WORKFLOW.contains("fallback: none"));
|
assert!(AUDIT_WORKFLOW.contains("fallback: none"));
|
||||||
assert!(!AUDIT_WORKFLOW.contains("cargo install"));
|
assert!(!AUDIT_WORKFLOW.contains("cargo install"));
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ reads repository files, parses entries/URIs or error labels, generates passwords
|
|||||||
or codes, or independently decides credential matches or authentication policy.
|
or codes, or independently decides credential matches or authentication policy.
|
||||||
|
|
||||||
Use typed semantic icons/actions, stable IDs, generation/revision tokens,
|
Use typed semantic icons/actions, stable IDs, generation/revision tokens,
|
||||||
view-ready records and typed errors. #91 removes SF Symbol and Watch assumptions
|
view-ready records and typed errors. #91's `crates/mobile-bridge` removes SF Symbol
|
||||||
from shared phone contracts. Keep Swift compatibility through mechanical
|
and Watch assumptions from shared phone contracts and generates Swift and Kotlin
|
||||||
adapters. #92 generates Kotlin and Swift from one shared UniFFI surface rather
|
from one source. `crates/apple` opts into Watch; `crates/android` packages the
|
||||||
than copying crates/apple conversions. A thin Android bridge may adapt platform
|
phone surface through #92's native build gates. A thin Android bridge may adapt platform
|
||||||
callbacks; it must contain no password-store decisions or handwritten unsafe
|
callbacks; it must contain no password-store decisions or handwritten unsafe
|
||||||
Rust. Generated/dependency FFI stays isolated from project-authored safe Rust.
|
Rust. Generated/dependency FFI stays isolated from project-authored safe Rust.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Shared mobile contract (#91)
|
||||||
|
|
||||||
|
`crates/mobile-bridge` is the single UniFFI source for phone records, enums,
|
||||||
|
conversions, objects, operations and OS callbacks. `crates/android` packages it
|
||||||
|
without features. `crates/apple` packages it with the explicit `watch` feature.
|
||||||
|
Neither packaging crate copies converters. The component namespace is
|
||||||
|
`ironstorage_mobile`; the native library names remain `ironstorage_android` and
|
||||||
|
`ironstorage_apple`. Swift source/header names follow the shared namespace.
|
||||||
|
|
||||||
|
## Public workflow inventory
|
||||||
|
|
||||||
|
| Storage owner | Shared phone exports | Identity / concurrency boundary |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| mobile | shell, fixtures, tab selection | typed tabs/states/semantic icons |
|
||||||
|
| mobile_home / git | Home page, commits/changes/actions, controlled operations/progress | typed actions, repository operation serialization and cancellation |
|
||||||
|
| mobile_passwords | browse, search, folder/entry rows | storage entry paths and row IDs |
|
||||||
|
| mobile_authentication / authentication | unlock, reveal/copy, preferences, identity/appearance/timeout, lifecycle | generation-scoped authentication handles, monotonic expiry |
|
||||||
|
| mobile_entry / document / generate | structured fields, editor sessions, OTP image import, generation, save/discard | session u64 and field IDs; original ciphertext/conflict checks |
|
||||||
|
| mobile_mutation / mutation | preparation, request, commit, outcome | opaque revision token validated at execution; explicit overwrite decisions |
|
||||||
|
| mobile_totp / otp | discovery/progress/cancel, cached search, detail/code/copy | storage-owned catalog, Unix-time inputs and expiry |
|
||||||
|
| mobile_onboarding / config | server discovery, clone and local setup, token replacement | typed transport/phase errors, controlled cancellation |
|
||||||
|
| mobile_key_transfer / presentation | export/import, frames, progress, key selection | storage-owned QR envelopes and bounded decoding |
|
||||||
|
| mobile_watch | **Apple opt-in only:** preferences, selection, snapshots/status/receipts | no Watch enum, field, method or control in the Android contract |
|
||||||
|
|
||||||
|
`MobileIcon` expresses intent, never an asset name. UIKit maps it to SF Symbols
|
||||||
|
in `MobileIcons.swift`. Android's core maps it exhaustively to its presentation
|
||||||
|
enum; Compose maps navigation intents to vector drawables. Asset names and
|
||||||
|
selected variants belong to the native UI. Actions already use typed enums.
|
||||||
|
|
||||||
|
## OS boundaries
|
||||||
|
|
||||||
|
- `MobilePlatformPaths` accepts absolute private data and cache roots, rejects
|
||||||
|
traversal and overlapping roots, and lets storage derive config/vault/key
|
||||||
|
paths. Validation does not create or migrate files. #90 owns initialization,
|
||||||
|
permissions, relocation, backup and cache policy.
|
||||||
|
- `MobileSecretBackend` performs create/retrieve/replace/delete/lock/unlock on
|
||||||
|
a typed locator and protection supplied by storage. Values are opaque encoded
|
||||||
|
storage records. OS errors are enums. It cannot select accounts, protection,
|
||||||
|
enrollment recovery, cache lifetime or authentication policy. Its adapter is
|
||||||
|
usable by `mobileAuthenticationWithPlatform`; #93 implements Android Keystore.
|
||||||
|
Preferences use that same injected backend, never a hidden native fallback.
|
||||||
|
- `MobileClock` returns elapsed monotonic milliseconds from a stable origin.
|
||||||
|
It cannot mint or refresh leases. Rust uses the existing authentication clock
|
||||||
|
and generation checks. Unix timestamps required by TOTP/Git remain explicit
|
||||||
|
inputs, not values parsed from rendered countdowns.
|
||||||
|
- `MobileLifecycleEvent` reports foreground, background, device lock, real user
|
||||||
|
activity, or cancellation. Storage revokes leases/editors on background/lock,
|
||||||
|
checks expiry on foreground, and extends activity only for an active lease.
|
||||||
|
- `MobileClipboard` reads/writes/clears OS text and waits for a requested timeout
|
||||||
|
or cancellation. Rust's existing manager restores the prior text or clears
|
||||||
|
only if the copied value is still current; newer clipboard content survives.
|
||||||
|
Call off the UI thread; the facade serializes access to one OS clipboard.
|
||||||
|
- `MobileCameraFrame` supplies luminance bytes, dimensions and row/pixel stride.
|
||||||
|
Storage validates bounds (4096 pixels per dimension, 32 MiB plane, pixel
|
||||||
|
stride 1–4) and decodes QR payloads. Native UI never interprets envelopes.
|
||||||
|
- `MobileCredentialRequest` carries an opaque request ID, OS-asserted package,
|
||||||
|
signing-certificate SHA-256 and web origins. Rust validates/canonicalizes the
|
||||||
|
facts. A validated request is **not** an authorization grant. #109 implements
|
||||||
|
matching, selected-candidate authentication and scoped grants in storage.
|
||||||
|
|
||||||
|
## Ownership, cancellation and diagnostics
|
||||||
|
|
||||||
|
UniFFI objects are reference-counted Rust owners. Operation objects retain their
|
||||||
|
authentication owner while work runs. Kotlin `close()` / Swift deinitialization
|
||||||
|
release native handles; the final storage authentication owner destroys the
|
||||||
|
lease and locks its store. Frontends cancel the exact operation on navigation
|
||||||
|
or coroutine cancellation before releasing it. `MobileAuthentication.cancel`
|
||||||
|
revokes its generation and discards editor state; it is not interchangeable
|
||||||
|
with cancelling an individual discovery/Git operation. Late results are
|
||||||
|
discarded by facade revision IDs. Polling, repaint and progress never count as
|
||||||
|
user activity. Editor IDs and field IDs are stable within their owning session;
|
||||||
|
revision tokens are opaque and must be returned unchanged to storage.
|
||||||
|
|
||||||
|
Secrets and payloads are owned values, not persisted view state. Rust secret
|
||||||
|
buffers zeroize on drop. Swift/Kotlin cannot promise complete managed-memory
|
||||||
|
erasure, so callers release revealed/copy/OTP/frame values when their operation
|
||||||
|
ends and never log or save them. Generated records, associated-value enums and
|
||||||
|
errors have redacted diagnostic rendering; typed fields remain available for
|
||||||
|
deliberate UI presentation. Unexpected native/callback errors redact their
|
||||||
|
payloads. Mechanical generator hardening is version-checked and preserves wire
|
||||||
|
layouts and every UniFFI contract/API checksum.
|
||||||
|
|
||||||
|
## Contract gates
|
||||||
|
|
||||||
|
Every Android binding generation generates both Swift and Kotlin from the
|
||||||
|
compiled phone component. The build tool verifies all records/fields, types,
|
||||||
|
functions and operation checksums against metadata, writes `inventory.tsv`, and
|
||||||
|
rejects any Watch item in the phone surface. The Apple opt-in build generates
|
||||||
|
both languages too. `compare-contract` compares complete shared signatures;
|
||||||
|
only Watch items and the opt-in Watch inputs to `preferences` differ.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build -p ironstorage-apple --lib
|
||||||
|
cargo run -p ironstorage-android-build -- contract-bindings \
|
||||||
|
target/debug/libironstorage_apple.dylib android/uniffi.toml /tmp/ironstorage-watch-contract watch
|
||||||
|
cargo run -p ironstorage-android-build -- compare-contract \
|
||||||
|
android/rust-bridge/build/generated/bindings-raw/debug/inventory.tsv \
|
||||||
|
/tmp/ironstorage-watch-contract/inventory.tsv
|
||||||
|
```
|
||||||
|
|
||||||
|
On non-macOS build hosts, use the host shared-library extension. Apple generation
|
||||||
|
copies the verified/hardened Swift source and header into `apple/Generated`.
|
||||||
|
Contract tests exercise typed errors, checksum failure, callback ownership,
|
||||||
|
cancellation/concurrency, camera bounds, large foreign payloads and redaction.
|
||||||
|
All app validation uses persistent emulator devices; no physical-device gate is
|
||||||
|
part of this milestone's testing scope.
|
||||||
|
|
||||||
|
The native Swift probe in `tools/android-build/tests/mobile_contract.swift` runs
|
||||||
|
against the generated source and the host Apple library:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcrun swiftc -swift-version 5 -module-cache-path /tmp/ironstorage-swift-cache \
|
||||||
|
-I apple/Generated \
|
||||||
|
-Xcc -fmodule-map-file=apple/Generated/ironstorage_mobileFFI.modulemap \
|
||||||
|
-L target/debug -lironstorage_apple \
|
||||||
|
-Xlinker -rpath -Xlinker "$PWD/target/debug" \
|
||||||
|
apple/Generated/ironstorage_mobile.swift \
|
||||||
|
tools/android-build/tests/mobile_contract.swift -o /tmp/ironstorage-swift-contract
|
||||||
|
/tmp/ironstorage-swift-contract
|
||||||
|
```
|
||||||
|
|
||||||
|
The Android native probe is `MobileContractTest`: run it with the existing
|
||||||
|
`NativeRuntimeTest` on persistent 4 KB and strict 16 KB ARM64 emulators. UI and
|
||||||
|
native instrumentation suites run sequentially to preserve input focus. The
|
||||||
|
storage integration test exercises a real encrypted fixture with the injected
|
||||||
|
secret backend/clock, activity renewal, background revocation, editor invalidation
|
||||||
|
and final-owner cleanup without global configuration.
|
||||||
@@ -36,7 +36,7 @@ each row. Every issue linked here belongs to milestone 23.
|
|||||||
| iPhone AutoFill credential selection (CredentialProviderViewController) and shared identity/access domain | desktop/read/document/secret_store today; storage-owned provider contract to add | AndroidX CredentialProviderService (API 34+) and framework AutofillService (API 26+) | #109; typed caller/site identity, non-secret candidates, explicit selection/auth, forged/stale requests, permissions and cross-process lifetime |
|
| iPhone AutoFill credential selection (CredentialProviderViewController) and shared identity/access domain | desktop/read/document/secret_store today; storage-owned provider contract to add | AndroidX CredentialProviderService (API 34+) and framework AutofillService (API 26+) | #109; typed caller/site identity, non-secret candidates, explicit selection/auth, forged/stale requests, permissions and cross-process lifetime |
|
||||||
| Camera permissions, private paths, secret-store mechanics, clipboard, scene/process events, screen privacy | Rust repository/config/secret_store/authentication/presentation; platform callbacks contain facts only | Android framework adapters, FLAG_SECURE, backup rules, ClipboardManager | #90/#93/#98/#100; backup inspection, sandbox paths, pressure/update, screen/clipboard/lifecycle tests |
|
| Camera permissions, private paths, secret-store mechanics, clipboard, scene/process events, screen privacy | Rust repository/config/secret_store/authentication/presentation; platform callbacks contain facts only | Android framework adapters, FLAG_SECURE, backup rules, ClipboardManager | #90/#93/#98/#100; backup inspection, sandbox paths, pressure/update, screen/clipboard/lifecycle tests |
|
||||||
| Accessible labels/actions, large text, keyboard, responsive layout and localization | Typed Rust state/actions; UI accessibility is Kotlin | Compose semantics, TalkBack, adaptive layout, resources | #105; measured keyboard/TalkBack/large-font/multi-window/RTL/rotation coverage |
|
| Accessible labels/actions, large text, keyboard, responsive layout and localization | Typed Rust state/actions; UI accessibility is Kotlin | Compose semantics, TalkBack, adaptive layout, resources | #105; measured keyboard/TalkBack/large-font/multi-window/RTL/rotation coverage |
|
||||||
| Watch pairing/install state in Settings; TOTP Watch selection, snapshots, receipts, transfer and watchOS detail | mobile_watch; Watch fields currently in mobile_totp/mobile_authentication and Apple bridge | Excluded from Android records and UI by #91 | Deliberate Watch-only exclusion; no unsupported placeholder controls or Wear implementation |
|
| Watch pairing/install state in Settings; TOTP Watch selection, snapshots, receipts, transfer and watchOS detail | mobile_watch; shared bridge's explicit `watch` feature enabled only by crates/apple | Absent from Android records and UI (#91) | Deliberate Watch-only exclusion; no unsupported placeholder controls or Wear implementation |
|
||||||
|
|
||||||
Name search matches current mobile behavior; CLI/TUI decrypted grep is not an
|
Name search matches current mobile behavior; CLI/TUI decrypted grep is not an
|
||||||
iPhone parity claim. Android has no separate filesystem-based search. The
|
iPhone parity claim. Android has no separate filesystem-based search. The
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ publish = false
|
|||||||
ironstorage.workspace = true
|
ironstorage.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
uniffi = { workspace = true, features = ["cli"] }
|
uniffi = { workspace = true, features = ["cli"] }
|
||||||
|
uniffi_bindgen = "=0.32.0"
|
||||||
|
camino = "1.2"
|
||||||
object = { version = "0.36.7", default-features = false, features = ["read", "elf"] }
|
object = { version = "0.36.7", default-features = false, features = ["read", "elf"] }
|
||||||
zip = { version = "2.4.2", default-features = false, features = ["deflate"] }
|
zip = { version = "2.4.2", default-features = false, features = ["deflate"] }
|
||||||
|
|
||||||
|
|||||||
@@ -61,14 +61,14 @@ fn harden_source(source: &str) -> Result<String, Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
let source = source.replace(internal, "class InternalException(@Suppress(\"UNUSED_PARAMETER\") message: String) : kotlin.Exception(\"Native Rust failure ([REDACTED])\")")
|
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])\"");
|
.replace(callback, "val err = \"Native callback failed ([REDACTED])\"");
|
||||||
let records = Regex::new(r"(?ms)^data class (\w+) \((.*?)\n\)([^\{]*)\{\n")?;
|
let records = Regex::new(r"(?ms)^([ \t]*)data class (\w+)\s*\((.*?)\)[^\{]*\{\n")?;
|
||||||
if !records.is_match(&source) {
|
if !records.is_match(&source) {
|
||||||
return Err("UniFFI record template changed; review diagnostic hardening".into());
|
return Err("UniFFI record template changed; review diagnostic hardening".into());
|
||||||
}
|
}
|
||||||
let source = records.replace_all(&source, |captures: ®ex::Captures<'_>| {
|
let source = records.replace_all(&source, |captures: ®ex::Captures<'_>| {
|
||||||
format!(
|
format!(
|
||||||
"{} override fun toString(): String = \"{}([REDACTED])\"\n",
|
"{} override fun toString(): String = \"{}([REDACTED])\"\n",
|
||||||
&captures[0], &captures[1]
|
&captures[0], &captures[2]
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
let errors = Regex::new(r#"(?m)(override val message\n\s*)get\(\) = ".*"$"#)?;
|
let errors = Regex::new(r#"(?m)(override val message\n\s*)get\(\) = ".*"$"#)?;
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
//! Generate both languages from compiled metadata and verify their whole public inventory.
|
||||||
|
|
||||||
|
use std::{collections::BTreeSet, error::Error, fs, path::Path};
|
||||||
|
|
||||||
|
use camino::Utf8PathBuf;
|
||||||
|
use regex::Regex;
|
||||||
|
use uniffi_bindgen::{EmptyCrateConfigSupplier, interface::AsType};
|
||||||
|
|
||||||
|
fn utf8(path: &Path) -> Result<Utf8PathBuf, Box<dyn Error>> {
|
||||||
|
Ok(Utf8PathBuf::from_path_buf(path.to_owned()).map_err(|_| "non-UTF8 binding path")?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate(
|
||||||
|
library: &Path,
|
||||||
|
config: &Path,
|
||||||
|
output: &Path,
|
||||||
|
watch: bool,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
use uniffi::{GenerateOptions, TargetLanguage};
|
||||||
|
let library = utf8(library)?;
|
||||||
|
let config = utf8(config)?;
|
||||||
|
let out = utf8(output)?;
|
||||||
|
fs::create_dir_all(output)?;
|
||||||
|
for (language, directory) in [
|
||||||
|
(TargetLanguage::Swift, "swift"),
|
||||||
|
(TargetLanguage::Kotlin, "kotlin"),
|
||||||
|
] {
|
||||||
|
uniffi::generate(GenerateOptions {
|
||||||
|
languages: vec![language],
|
||||||
|
source: library.clone(),
|
||||||
|
out_dir: out.join(directory),
|
||||||
|
config_override: Some(config.clone()),
|
||||||
|
format: false,
|
||||||
|
..Default::default()
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let cis = uniffi_bindgen::library_mode::find_cis(&library, &EmptyCrateConfigSupplier)?;
|
||||||
|
if cis.len() != 1 || cis[0].namespace() != "ironstorage_mobile" {
|
||||||
|
return Err("expected one shared mobile UniFFI component".into());
|
||||||
|
}
|
||||||
|
let ci = &cis[0];
|
||||||
|
let swift = fs::read_to_string(output.join("swift/ironstorage_mobile.swift"))?;
|
||||||
|
let kotlin = read_kotlin(&output.join("kotlin"))?;
|
||||||
|
let mut inventory = BTreeSet::new();
|
||||||
|
let mut names = BTreeSet::new();
|
||||||
|
for record in ci.record_definitions() {
|
||||||
|
names.insert(record.name().to_owned());
|
||||||
|
inventory.insert(format!("record\t{}", record.name()));
|
||||||
|
for field in record.fields() {
|
||||||
|
let name = camel(field.name());
|
||||||
|
if !swift.contains(&format!("var {name}:")) || !kotlin.contains(&format!("`{name}`:")) {
|
||||||
|
return Err(format!(
|
||||||
|
"record field missing in a generated language: {}.{name}",
|
||||||
|
record.name()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
inventory.insert(format!(
|
||||||
|
"field\t{}.{}\t{:?}",
|
||||||
|
record.name(),
|
||||||
|
field.name(),
|
||||||
|
field.as_type()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for value in ci.enum_definitions() {
|
||||||
|
names.insert(value.name().to_owned());
|
||||||
|
inventory.insert(format!("enum\t{}", value.name()));
|
||||||
|
for variant in value.variants() {
|
||||||
|
inventory.insert(format!(
|
||||||
|
"variant\t{}.{}\t{:?}",
|
||||||
|
value.name(),
|
||||||
|
variant.name(),
|
||||||
|
variant
|
||||||
|
.fields()
|
||||||
|
.iter()
|
||||||
|
.map(|field| field.as_type())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for object in ci.object_definitions() {
|
||||||
|
names.insert(object.name().to_owned());
|
||||||
|
inventory.insert(format!("object\t{}", object.name()));
|
||||||
|
for method in object.methods() {
|
||||||
|
inventory.insert(format!(
|
||||||
|
"method\t{}.{}\t{:?}\t{:?}",
|
||||||
|
object.name(),
|
||||||
|
method.name(),
|
||||||
|
method
|
||||||
|
.arguments()
|
||||||
|
.iter()
|
||||||
|
.map(|argument| argument.as_type())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
method.return_type()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for function in ci.function_definitions() {
|
||||||
|
let name = camel(function.name());
|
||||||
|
if !swift.contains(&format!("func {name}("))
|
||||||
|
|| !kotlin.contains(&format!("fun `{name}`("))
|
||||||
|
&& !kotlin.contains(&format!("fun {name}("))
|
||||||
|
{
|
||||||
|
return Err(format!("function missing from generated language: {name}").into());
|
||||||
|
}
|
||||||
|
inventory.insert(format!(
|
||||||
|
"function\t{}\t{:?}\t{:?}",
|
||||||
|
function.name(),
|
||||||
|
function
|
||||||
|
.arguments()
|
||||||
|
.iter()
|
||||||
|
.map(|argument| argument.as_type())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
function.return_type()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for name in names {
|
||||||
|
let kotlin_name = if ci.is_name_used_as_error(&name) {
|
||||||
|
format!("{}Exception", name.strip_suffix("Error").unwrap_or(&name))
|
||||||
|
} else {
|
||||||
|
name.clone()
|
||||||
|
};
|
||||||
|
if !swift.contains(&name) || !kotlin.contains(&kotlin_name) {
|
||||||
|
return Err(format!("type missing from generated language: {name}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both generated languages must verify every native operation checksum, not just its name.
|
||||||
|
for checksum in ci.iter_checksum_ffi_functions() {
|
||||||
|
if !swift.contains(checksum.name()) || !kotlin.contains(checksum.name()) {
|
||||||
|
return Err(format!("missing integrity check: {}", checksum.name()).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !watch
|
||||||
|
&& inventory
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.to_ascii_lowercase().contains("watch"))
|
||||||
|
{
|
||||||
|
return Err("Watch data leaked into the phone contract".into());
|
||||||
|
}
|
||||||
|
if watch
|
||||||
|
&& !inventory
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.contains("MobileWatchSnapshotTransfer"))
|
||||||
|
{
|
||||||
|
return Err("Apple contract lost Watch transfer".into());
|
||||||
|
}
|
||||||
|
fs::write(
|
||||||
|
output.join("inventory.tsv"),
|
||||||
|
inventory.into_iter().collect::<Vec<_>>().join("\n") + "\n",
|
||||||
|
)?;
|
||||||
|
prepare_swift(
|
||||||
|
&output.join("swift/ironstorage_mobile.swift"),
|
||||||
|
&output.join("swift/ironstorage_mobile.swift"),
|
||||||
|
)?;
|
||||||
|
let header = output.join("swift/ironstorage_mobileFFI.h");
|
||||||
|
fs::write(&header, normalized_lines(&fs::read_to_string(&header)?))?;
|
||||||
|
println!("Verified Swift/Kotlin inventory and operation checksums (watch={watch})");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compare(phone: &Path, watch: &Path) -> Result<(), Box<dyn Error>> {
|
||||||
|
let phone = fs::read_to_string(phone)?;
|
||||||
|
let watch = fs::read_to_string(watch)?;
|
||||||
|
let shared = |source: &str| -> BTreeSet<String> {
|
||||||
|
source
|
||||||
|
.lines()
|
||||||
|
.filter(|line| {
|
||||||
|
!line.to_ascii_lowercase().contains("watch")
|
||||||
|
&& !line.starts_with("method\tMobileAuthentication.preferences\t")
|
||||||
|
})
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let phone = shared(&phone);
|
||||||
|
let watch = shared(&watch);
|
||||||
|
if phone != watch {
|
||||||
|
return Err(format!(
|
||||||
|
"phone/Apple public API drift: phone-only={:?}, Apple-only={:?}",
|
||||||
|
phone.difference(&watch).collect::<Vec<_>>(),
|
||||||
|
watch.difference(&phone).collect::<Vec<_>>()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"Verified {} identical shared phone feature/signature entries",
|
||||||
|
phone.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn camel(name: &str) -> String {
|
||||||
|
let mut parts = name.split('_');
|
||||||
|
let mut output = parts.next().unwrap_or_default().to_owned();
|
||||||
|
for part in parts {
|
||||||
|
let mut chars = part.chars();
|
||||||
|
if let Some(first) = chars.next() {
|
||||||
|
output.extend(first.to_uppercase());
|
||||||
|
output.extend(chars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_kotlin(path: &Path) -> Result<String, Box<dyn Error>> {
|
||||||
|
let mut source = String::new();
|
||||||
|
for entry in fs::read_dir(path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
if entry.file_type()?.is_dir() {
|
||||||
|
source.push_str(&read_kotlin(&entry.path())?);
|
||||||
|
} else if entry.path().extension().is_some_and(|ext| ext == "kt") {
|
||||||
|
source.push_str(&fs::read_to_string(entry.path())?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_lines(source: &str) -> String {
|
||||||
|
source
|
||||||
|
.lines()
|
||||||
|
.map(str::trim_end)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
.trim_end()
|
||||||
|
.to_owned()
|
||||||
|
+ "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare_swift(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
|
||||||
|
let source = fs::read_to_string(input)?;
|
||||||
|
if source.contains("// IronStorage diagnostic redaction") {
|
||||||
|
return Err("Swift diagnostics already prepared".into());
|
||||||
|
}
|
||||||
|
let records = Regex::new(r"(?m)^public struct (Mobile\w+)[ :]")?;
|
||||||
|
let mut names = records
|
||||||
|
.captures_iter(&source)
|
||||||
|
.map(|capture| capture[1].to_owned())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
// Error/associated-value enums also expose payloads through Swift's default reflection.
|
||||||
|
let error_names = Regex::new(r"(?m)^enum (Mobile\w+Error):")?;
|
||||||
|
names.extend(
|
||||||
|
error_names
|
||||||
|
.captures_iter(&source)
|
||||||
|
.map(|capture| capture[1].to_owned()),
|
||||||
|
);
|
||||||
|
for name in ["MobileSecretLocator", "MobileClipboardContent"] {
|
||||||
|
if source.contains(&format!("enum {name}")) {
|
||||||
|
names.insert(name.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if names.is_empty() {
|
||||||
|
return Err("Swift record template changed".into());
|
||||||
|
}
|
||||||
|
let panic_payload =
|
||||||
|
"throw UniffiInternalError.rustPanic(try FfiConverterString.lift(callStatus.errorBuf))";
|
||||||
|
let callback_payload = "FfiConverterString.lower(String(describing: error))";
|
||||||
|
if source.contains("fileprivate enum UniffiInternalError")
|
||||||
|
&& (!source.contains(panic_payload) || source.matches(callback_payload).count() != 2)
|
||||||
|
{
|
||||||
|
return Err("UniFFI Swift unexpected-error template changed".into());
|
||||||
|
}
|
||||||
|
let source = source.replace(panic_payload, "_ = try FfiConverterString.lift(callStatus.errorBuf)\n throw UniffiInternalError.rustPanic(\"Native Rust failure ([REDACTED])\")")
|
||||||
|
.replace(callback_payload, "FfiConverterString.lower(\"Native callback failed ([REDACTED])\")")
|
||||||
|
.replace("} catch let error {\n callStatus.pointee.code = CALL_UNEXPECTED_ERROR", "} catch {\n callStatus.pointee.code = CALL_UNEXPECTED_ERROR")
|
||||||
|
.replace("case let .rustPanic(message): return message", "case .rustPanic: return \"Native Rust failure ([REDACTED])\"");
|
||||||
|
let mut output_source = source;
|
||||||
|
output_source.push_str(
|
||||||
|
"\n// IronStorage diagnostic redaction; wire values and checksums remain generated.\n",
|
||||||
|
);
|
||||||
|
for name in names {
|
||||||
|
output_source.push_str(&format!("extension {name}: CustomStringConvertible, CustomDebugStringConvertible {{\n public var description: String {{ \"{name}([REDACTED])\" }}\n public var debugDescription: String {{ description }}\n}}\n"));
|
||||||
|
}
|
||||||
|
fs::write(output, normalized_lines(&output_source))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn inventory_comparison_rejects_missing_methods_and_type_changes() {
|
||||||
|
let root =
|
||||||
|
std::env::temp_dir().join(format!("ironstorage-contract-{}", std::process::id()));
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
let phone = root.join("phone.tsv");
|
||||||
|
let watch = root.join("watch.tsv");
|
||||||
|
fs::write(&phone, "method\tMobileAuthentication.state\t[]\tBool\n").unwrap();
|
||||||
|
fs::write(&watch, "method\tMobileAuthentication.state\t[]\tString\n").unwrap();
|
||||||
|
assert!(compare(&phone, &watch).is_err());
|
||||||
|
fs::write(
|
||||||
|
&watch,
|
||||||
|
"method\tMobileAuthentication.state\t[]\tBool\nfield\tMobileTotpPage.watch\tWatch\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
compare(&phone, &watch).unwrap();
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn swift_record_and_error_reflection_are_redacted() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"ironstorage-swift-redaction-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
let input = root.join("raw.swift");
|
||||||
|
let output = root.join("prepared.swift");
|
||||||
|
fs::write(&input, "public struct MobileEntryCopy: Equatable { public var value: String }\npublic\nenum MobileAuthenticationFfiError: Swift.Error {}\n").unwrap();
|
||||||
|
prepare_swift(&input, &output).unwrap();
|
||||||
|
let hardened = fs::read_to_string(output).unwrap();
|
||||||
|
assert!(hardened.contains("MobileEntryCopy([REDACTED])"));
|
||||||
|
assert!(hardened.contains("MobileAuthenticationFfiError([REDACTED])"));
|
||||||
|
assert!(hardened.contains("public var value: String"));
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use ironstorage::mobile::{MobileShell, MobileShellState};
|
|||||||
use std::{env, error::Error, fs, path::Path};
|
use std::{env, error::Error, fs, path::Path};
|
||||||
|
|
||||||
mod bindings;
|
mod bindings;
|
||||||
|
mod contract;
|
||||||
mod native;
|
mod native;
|
||||||
mod toolchain;
|
mod toolchain;
|
||||||
|
|
||||||
@@ -16,6 +17,20 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
fs::write(path, fixtures())?;
|
fs::write(path, fixtures())?;
|
||||||
}
|
}
|
||||||
[_, command, path] if command == "check" => check(Path::new(path))?,
|
[_, command, path] if command == "check" => check(Path::new(path))?,
|
||||||
|
[_, command, library, config, output, mode] if command == "contract-bindings" => {
|
||||||
|
contract::generate(
|
||||||
|
Path::new(library),
|
||||||
|
Path::new(config),
|
||||||
|
Path::new(output),
|
||||||
|
mode == "watch",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
[_, command, phone, watch] if command == "compare-contract" => {
|
||||||
|
contract::compare(Path::new(phone), Path::new(watch))?;
|
||||||
|
}
|
||||||
|
[_, command, input, output] if command == "prepare-swift" => {
|
||||||
|
contract::prepare_swift(Path::new(input), Path::new(output))?;
|
||||||
|
}
|
||||||
[_, command, input, output] if command == "prepare-bindings" => {
|
[_, command, input, output] if command == "prepare-bindings" => {
|
||||||
bindings::prepare(Path::new(input), Path::new(output))?;
|
bindings::prepare(Path::new(input), Path::new(output))?;
|
||||||
}
|
}
|
||||||
@@ -68,11 +83,12 @@ fn fixtures() -> String {
|
|||||||
output.push_str(&format!(" ShellState.{state:?} to listOf(\n"));
|
output.push_str(&format!(" ShellState.{state:?} to listOf(\n"));
|
||||||
for page in MobileShell::fixture(state).pages() {
|
for page in MobileShell::fixture(state).pages() {
|
||||||
output.push_str(&format!(
|
output.push_str(&format!(
|
||||||
" ShellPage(ShellTab.{:?}, {}, {}, {}),\n",
|
" ShellPage(ShellTab.{:?}, {}, {}, {}, ShellIcon.{:?}),\n",
|
||||||
page.tab(),
|
page.tab(),
|
||||||
kotlin_string(page.title()),
|
kotlin_string(page.title()),
|
||||||
kotlin_string(page.state_title()),
|
kotlin_string(page.state_title()),
|
||||||
kotlin_string(page.state_detail()),
|
kotlin_string(page.state_detail()),
|
||||||
|
page.icon(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
output.push_str(" ),\n");
|
output.push_str(" ),\n");
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Runs against the generated Swift component and host Rust library.
|
||||||
|
final class ContractClipboard: MobileClipboard, @unchecked Sendable {
|
||||||
|
var text = "previous"
|
||||||
|
var writtenBytes = 0
|
||||||
|
var timeout: UInt64 = 0
|
||||||
|
var newer: String?
|
||||||
|
var cancelled = false
|
||||||
|
|
||||||
|
func read() throws -> MobileClipboardContent { .text(value: Data(text.utf8)) }
|
||||||
|
func write(value: Data) throws {
|
||||||
|
writtenBytes = max(writtenBytes, value.count)
|
||||||
|
text = String(decoding: value, as: UTF8.self)
|
||||||
|
}
|
||||||
|
func clear() throws { text = "" }
|
||||||
|
func waitForTimeout(milliseconds: UInt64) -> MobileClipboardWait {
|
||||||
|
timeout = milliseconds
|
||||||
|
if let newer { text = newer }
|
||||||
|
return cancelled ? .cancelled : .elapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
enum MobileContractProbe {
|
||||||
|
static func main() throws {
|
||||||
|
let secret = String(repeating: "contract-payload-", count: 70_000)
|
||||||
|
let copy = MobileEntryCopy(value: secret, timeoutSeconds: 45)
|
||||||
|
for diagnostic in [String(describing: copy), String(reflecting: copy)] {
|
||||||
|
precondition(!diagnostic.contains(secret))
|
||||||
|
}
|
||||||
|
let error = MobileAuthenticationFfiError.Failed(kind: .entry, title: secret, detail: secret)
|
||||||
|
precondition(!String(reflecting: error).contains(secret))
|
||||||
|
precondition(!error.localizedDescription.contains(secret))
|
||||||
|
precondition(!String(reflecting: MobileClipboardContent.text(value: Data(secret.utf8))).contains(secret))
|
||||||
|
precondition(!String(reflecting: MobileSecretLocator.openPgpPassphrase(fingerprint: secret)).contains(secret))
|
||||||
|
for state in [MobileShellState.loading, .empty, .ready, .locked, .error] {
|
||||||
|
let shell = mobileShellFixture(state: state)
|
||||||
|
precondition(shell.pages.count == 5)
|
||||||
|
precondition(shell.pages.allSatisfy { $0.state == state })
|
||||||
|
precondition(shell.pages.map(\.icon) == [.home, .key, .search, .timer, .settings])
|
||||||
|
}
|
||||||
|
let clipboard = ContractClipboard()
|
||||||
|
let restored = try copyMobileEntry(copy: copy, clipboard: clipboard)
|
||||||
|
precondition(restored == .restoredPrevious)
|
||||||
|
precondition(clipboard.text == "previous" && clipboard.writtenBytes == secret.utf8.count)
|
||||||
|
precondition(clipboard.timeout == 45_000)
|
||||||
|
clipboard.newer = "newer"
|
||||||
|
let preserved = try copyMobileEntry(copy: copy, clipboard: clipboard)
|
||||||
|
precondition(preserved == .preservedNewer)
|
||||||
|
precondition(clipboard.text == "newer")
|
||||||
|
clipboard.newer = nil
|
||||||
|
clipboard.cancelled = true
|
||||||
|
do { _ = try copyMobileEntry(copy: copy, clipboard: clipboard); preconditionFailure("cancellation must fail") }
|
||||||
|
catch MobilePlatformFfiError.Cancelled {}
|
||||||
|
precondition(clipboard.text == "newer")
|
||||||
|
DispatchQueue.concurrentPerform(iterations: 16) { _ in
|
||||||
|
let operation = mobileTotpOperation()
|
||||||
|
operation.cancel()
|
||||||
|
precondition(operation.progress().phase == .cancelled)
|
||||||
|
}
|
||||||
|
let request = try validateMobileCredentialRequest(request: MobileCredentialRequest(requestId: "opaque", package: "de.example.app", signingCertificateSha256: String(repeating: "A", count: 64), origins: ["https://EXAMPLE.test:443/"]))
|
||||||
|
precondition(request.origins == ["https://example.test"])
|
||||||
|
print("Swift native contract: fixtures/icons, callbacks, large payload, redaction, cancellation and concurrency passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user