Share the platform-neutral Rust mobile contract (#91)
Dependency security audit / rustsec (push) Failing after 22s

This commit is contained in:
hugo committed 2026-10-08 13:25:09 +02:00
1 parent fcb36a0684
commit 180851087b
52 files changed
+8932 -4263

No files matched your search

Generated
+12 -3
View File
@@ -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"
+1
View File
@@ -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
@@ -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,
@@ -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>
+15 -11
View File
@@ -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")
}
@@ -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
}
}
}
@@ -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
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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
View File
@@ -1 +1 @@
#include "Generated/ironstorage_appleFFI.h" #include "Generated/ironstorage_mobileFFI.h"
+82
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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" }
+2 -2
View File
@@ -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!();
+1 -2
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -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
+441
View File
@@ -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(),
})
}
+6
View File
@@ -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 {
+2
View File
@@ -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")]
+41 -28
View File
@@ -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()
})); }));
+78 -21
View File
@@ -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();
+18 -16
View File
@@ -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,
} }
} }
+59 -58
View File
@@ -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");
+2 -2
View File
@@ -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(),
}) })
} }
+10 -8
View File
@@ -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,
} }
} }
+295
View File
@@ -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());
}
}
+10 -2
View File
@@ -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()
+45
View File
@@ -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,
+4 -2
View File
@@ -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>,
+2 -2
View File
@@ -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());
+12 -2
View File
@@ -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"));
+4 -4
View File
@@ -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.
+127
View File
@@ -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.
+1 -1
View File
@@ -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
+2
View File
@@ -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"] }
+2 -2
View File
@@ -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: &regex::Captures<'_>| { let source = records.replace_all(&source, |captures: &regex::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\(\) = ".*"$"#)?;
+315
View File
@@ -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();
}
}
+17 -1
View File
@@ -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")
}
}