diff --git a/.envrc.example b/.envrc.example index 11611cb09..f79c31b52 100644 --- a/.envrc.example +++ b/.envrc.example @@ -14,6 +14,10 @@ export COVE_KEYSTORE_PASSWORD="your-keystore-password-here" export COVE_KEY_ALIAS="upload" export COVE_KEY_PASSWORD="your-key-password-here" +# Google Play internal releases: just rela (requires fastlane) +# grant this service account access to testing releases for org.bitcoinppl.cove +# export GOOGLE_PLAY_JSON_KEY_PATH="$HOME/.secrets/cove-google-play.json" + # iOS device aliases for: just brall --device main|se # export IOS_DEVICE_MAIN="00008120-0006243420214032" # export IOS_DEVICE_SE="00008xxx-xxxxxxxxxxxxxxxx" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f71e34f93..2aa59cbda 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -85,7 +85,7 @@ This pattern is used throughout the codebase for shared resources and is safe to redb stores typed table metadata for each table and validates the key and value `TypeName` on `open_table`. Treat changes to `TableDefinition`, `Value::type_name()`, persisted database type names, and module paths for persisted types as compatibility-sensitive. `std::any::type_name::()` uses the type's defining module path, not a public re-export path, so moving a persisted type into or out of a nested module can change on-disk expectations even when serialized bytes stay identical. Preserve exact historical type names or add a compatibility/migration path, and test every install path that could have created the table, including short-lived beta/internal builds. See [docs/redb.md](docs/redb.md) for the redb compatibility checklist. -**Destructive operations.** The process-wide wallet lifecycle coordinator owns persistent wallet construction, writes, actor registration, deletion, and full wipe. Deletion cannot get a prepared capability until current construction and persistence operations drain and all registered actors terminate. A failed or timed-out terminal request is cancelled, resumes the manager when possible, and returns a typed retry authorization; it never permits a force-delete path. Full wipe also requires a drained Cloud Backup reset permit. Inventory covers every network and main or decoy mode, and deletion uses those exact durable locations. Wallet deletion removes secrets first, address-switch and BDK artifacts second, wallet data third, and exact metadata rows last. Missing artifacts are idempotent, but a missing prepared metadata row is an invariant failure. The main database resets only after every local secret, artifact, restore marker, lock, and diagnostics log is removed. Address-type switches need no journal: the replacement store is published with one atomic rename, every post-publication path moves live state forward, and wallet load heals metadata to match the store it finds. +**Destructive operations.** The process-wide wallet lifecycle coordinator owns persistent wallet construction, writes, actor registration, deletion, and full wipe. Deletion cannot get a prepared capability until current construction and persistence operations drain and all registered actors terminate. A failed or timed-out terminal request is cancelled, resumes the manager when possible, and returns a typed retry authorization; it never permits a force-delete path. Full wipe also requires a drained Cloud Backup reset permit. Inventory covers every network and main or decoy mode, and deletion uses those exact durable locations. Wallet deletion removes secrets first, address-switch and BDK artifacts second, wallet data third, and exact metadata rows last. Missing artifacts are idempotent, but a missing prepared metadata row is an invariant failure. The main database resets only after every local secret, artifact, restore marker, lock, and diagnostics log is removed. A successful full wipe returns a `FullWipeCompletion` with the committed post-wipe app and authentication state. Frontends apply it before releasing authentication and clear only the presentation state they own, so no frontend decides on its own what a wiped app looks like. Address-type switches need no journal: the replacement store is published with one atomic rename, every post-publication path moves live state forward, and wallet load heals metadata to match the store it finds. **Database tables:** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a67ee069..a703af50e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,10 +48,14 @@ Set `ASC_API_KEY_PATH`, `ASC_API_KEY_ID`, and `ASC_API_ISSUER_ID`. The API key m ### Android ```bash -just build-android-release # alias: just bar +just release-android # alias: just rela ``` -Then build a signed APK/AAB via Android Studio (Build → Generate Signed Bundle/APK). +This bumps the Android versionCode, rebuilds the release Android bindings, builds the signed Play bundle, and uploads it to Google Play internal testing. + +Set `GOOGLE_PLAY_JSON_KEY_PATH` to a readable Google Play service account JSON file. Install fastlane (`brew install fastlane`). The service account must have access to testing releases for `org.bitcoinppl.cove`. + +Use `just upload-google-play` (`just ugp`) when the versionCode was already bumped and the signed bundle already exists. ## Development Workflow diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b7ab432a0..1fe6b2c89 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -33,7 +33,7 @@ android { applicationId = "org.bitcoinppl.cove" minSdk = 33 targetSdk = 36 - versionCode = 39 + versionCode = 41 versionName = "1.4.0" manifestPlaceholders["keyTeleportAppLinksEnabled"] = "false" diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt index d3d30445b..454fa5f9f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -369,11 +369,18 @@ class AppManager private constructor() : FfiReconcile { * clears all cached data and reinitializes */ fun reset() { + clearSessionForReset() + resetRustProjection() + } + + private fun clearSessionForReset() { // close managers before clearing them clearWalletManager() clearKeyTeleportManager() corruptedWalletDeletionRetry = null + } + private fun resetRustProjection() { database = Database() needsOnboarding = withRustOr(needsOnboarding) { @@ -381,10 +388,39 @@ class AppManager private constructor() : FfiReconcile { } val routerState = - withRustOr(null) { + withRustOr(null) { state() - } - router.reset(routerState?.router) + } ?: return + + router.reset(routerState.router) + } + + /** + * Apply the committed post-wipe state from Rust before authentication is released + * + * Rust decides what a wiped app looks like; this only clears Android-owned presentation state + */ + internal fun applyWipeCompletion(completion: FullWipeCompletion) { + // replace everything visible first so a cleanup failure below cannot leave pre-wipe state on screen + router.isSidebarVisible = false + isLoading = false + alertState = null + sheetState = null + needsOnboarding = completion.needsOnboarding + selectedNetwork = completion.selectedNetwork + colorSchemeSelection = completion.colorScheme + selectedNode = completion.selectedNode + selectedFiatCurrency = completion.fiatCurrency + wallets = completion.wallets + router.reset(completion.router) + + // the data is already gone, so a manager cleanup failure must not keep the app locked + runCatching { + clearSessionForReset() + database = Database() + }.onFailure { error -> + Log.e(tag, "failed to clear the app session after wipe", error) + } } val currentRoute: Route @@ -822,17 +858,15 @@ class AppManager private constructor() : FfiReconcile { unverifiedWalletIds() } - internal fun dangerousWipeAllData() { + internal fun dangerousWipeAllData(): FullWipeCompletion = withRust { dangerousWipeAllData() } - } - internal fun retryDangerousWipeAllData(attemptId: ShutdownAttemptId) { + internal fun retryDangerousWipeAllData(attemptId: ShutdownAttemptId): FullWipeCompletion = withRust { retryDangerousWipeAllData(attemptId) } - } internal fun cancelDangerousWipe(attemptId: ShutdownAttemptId) { withRust { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt index 6eba76695..46c8ecea3 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt @@ -56,9 +56,12 @@ sealed interface WipePresentationState { data object Idle : WipePresentationState data object Running : WipePresentationState data class ShutdownBlocked(val attemptId: ShutdownAttemptId) : WipePresentationState - data class Failed(val message: String) : WipePresentationState + data object Failed : WipePresentationState } +internal const val WIPE_FAILURE_TITLE = "Unable to Open Cove" +internal const val WIPE_FAILURE_MESSAGE = "Please try again." + /** * auth manager - manages authentication state * ported from iOS AuthManager.swift @@ -72,8 +75,7 @@ class AuthManager internal constructor( private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val wipeCommand = OwnerScopedCommand(mainScope) - private var rust: RustAuthManager = RustAuthManager() - private set + private val rust = RustAuthManager() private val isRustClosed = AtomicBoolean(false) private val rustGuard = RustHandleGuard( @@ -190,9 +192,7 @@ class AuthManager internal constructor( } internal fun completeMainBiometricAuthentication() { - if (isInDecoyMode()) { - switchToMainMode() - } + if (isInDecoyMode() && !switchToMainMode()) return recordMainCredentialAuthentication() unlock() @@ -258,8 +258,8 @@ class AuthManager internal constructor( } private fun unlockWithMainPin(): UnlockMode { - if (Database().globalConfig().isInDecoyMode()) { - switchToMainMode() + if (Database().globalConfig().isInDecoyMode() && !switchToMainMode()) { + return UnlockMode.LOCKED } recordMainCredentialAuthentication() @@ -318,49 +318,56 @@ class AuthManager internal constructor( } } - result.exceptionOrNull()?.let { error -> - val lifecycle = (error as? AppException.WalletLifecycle)?.v1 + val wipeError = result.exceptionOrNull() + if (wipeError != null) { + val lifecycle = (wipeError as? AppException.WalletLifecycle)?.v1 if (lifecycle is WalletLifecycleFailure.ShutdownBlocked) { wipePresentationState = WipePresentationState.ShutdownBlocked(lifecycle.attemptId) } else { - android.util.Log.e(tag, "failed to wipe all data", error) - wipePresentationState = - WipePresentationState.Failed(error.message ?: "Unable to remove local data") + android.util.Log.e(tag, "failed to wipe all data", wipeError) + wipePresentationState = WipePresentationState.Failed } return UnlockMode.LOCKED } - val oldRust = rust - rust = RustAuthManager() - rustGuard.markOpen() - rust.listenForUpdates(this) - oldRust.close() + val completion = result.getOrThrow() + App.applyWipeCompletion(completion) + apply(completion.auth) + unlock() - type = AuthType.NONE wipePresentationState = WipePresentationState.Idle - App.reset() - return UnlockMode.WIPE } + private fun apply(settings: AuthSettings) { + type = settings.authType + isWipeDataPinEnabled = settings.isWipeDataPinEnabled + isDecoyPinEnabled = settings.isDecoyPinEnabled + isUsingBiometrics = false + } + private fun recordMainCredentialAuthentication() { mainCredentialGeneration += 1 } /** * switch to main mode from decoy mode + * + * returns false when the switch failed so callers keep the app locked + * instead of unlocking into the decoy projection with the main credential */ - fun switchToMainMode() { + fun switchToMainMode(): Boolean = try { withRust { switchToMainMode() } resetAppAndSelectWallet() + true } catch (e: Exception) { android.util.Log.e(tag, "failed to switch to main mode", e) + false } - } override fun reconcile(message: AuthManagerReconcileMessage) { logDebug("reconcile: $message") @@ -371,17 +378,11 @@ class AuthManager internal constructor( } is AuthManagerReconcileMessage.WipeDataPinChanged -> { - isWipeDataPinEnabled = - withRustOr(isWipeDataPinEnabled) { - isWipeDataPinEnabled() - } + isWipeDataPinEnabled = message.v1 } is AuthManagerReconcileMessage.DecoyPinChanged -> { - isDecoyPinEnabled = - withRustOr(isDecoyPinEnabled) { - isDecoyPinEnabled() - } + isDecoyPinEnabled = message.v1 } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 60efc0e98..867e5ce8d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -491,7 +491,10 @@ class MainActivity : FragmentActivity() { return@setContent } - if (bdkMigrationWarning != null) { + val app = remember { AppManager.getInstance() } + val auth = remember { AuthManager.getInstance() } + + if (bdkMigrationWarning != null && auth.wipePresentationState != WipePresentationState.Running) { AlertDialog( onDismissRequest = { bdkMigrationWarning = null }, title = { Text("Encryption Migration Issue") }, @@ -506,8 +509,6 @@ class MainActivity : FragmentActivity() { ) } - val app = remember { AppManager.getInstance() } - val auth = remember { AuthManager.getInstance() } val snackbarHostState = remember { SnackbarHostState() } var startupMode by remember { mutableStateOf(resolveStartupMode(app.needsOnboarding)) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivityAppShell.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivityAppShell.kt index cfe815e7f..b0c0639cd 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivityAppShell.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivityAppShell.kt @@ -44,64 +44,68 @@ internal fun MainActivityAppShell( } CoveTheme(darkTheme = darkTheme) { - CloudBackupPresentationHost( - app = app, - auth = auth, - isCoverPresented = isPrivacyCoverVisible, - presentationPolicy = - if (startupMode == StartupMode.ONBOARDING) { - CloudBackupPresentationPolicy.ONBOARDING - } else { - CloudBackupPresentationPolicy.REQUIRES_UNLOCKED_AUTH - }, - ) { - Scaffold( - containerColor = Color.Transparent, - contentWindowInsets = WindowInsets(0), - snackbarHost = { - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.padding(WindowInsets.navigationBars.asPaddingValues()), - ) - }, - ) { _ -> - Box( - modifier = - Modifier - .fillMaxSize() - .semantics { testTagsAsResourceId = true }, - ) { - LockView { - when (startupMode) { - StartupMode.ONBOARDING -> { - if (onboardingManager != null) { - OnboardingContainer( - manager = onboardingManager, - onComplete = onOnboardingComplete, - ) - } - } - StartupMode.READY -> - SidebarContainer(app = app) { - key(app.selectedNetwork, app.routeId) { - CoveNavDisplay(app = app) + if (auth.wipePresentationState == WipePresentationState.Running) { + SplashLoadingView(showSpinner = true) + } else { + CloudBackupPresentationHost( + app = app, + auth = auth, + isCoverPresented = isPrivacyCoverVisible, + presentationPolicy = + if (startupMode == StartupMode.ONBOARDING) { + CloudBackupPresentationPolicy.ONBOARDING + } else { + CloudBackupPresentationPolicy.REQUIRES_UNLOCKED_AUTH + }, + ) { + Scaffold( + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0), + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(WindowInsets.navigationBars.asPaddingValues()), + ) + }, + ) { _ -> + Box( + modifier = + Modifier + .fillMaxSize() + .semantics { testTagsAsResourceId = true }, + ) { + LockView { + when (startupMode) { + StartupMode.ONBOARDING -> { + if (onboardingManager != null) { + OnboardingContainer( + manager = onboardingManager, + onComplete = onOnboardingComplete, + ) } } + StartupMode.READY -> + SidebarContainer(app = app) { + key(app.selectedNetwork, app.routeId) { + CoveNavDisplay(app = app) + } + } + } + } + + app.sheetState?.let { taggedState -> + SheetContent( + state = taggedState, + app = app, + onDismiss = { app.sheetState = null }, + ) } - } - app.sheetState?.let { taggedState -> - SheetContent( - state = taggedState, + GlobalAlertHandler( app = app, - onDismiss = { app.sheetState = null }, + snackbarHostState = snackbarHostState, ) } - - GlobalAlertHandler( - app = app, - snackbarHostState = snackbarHostState, - ) } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/AndroidPasskeyProvider.kt b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/AndroidPasskeyProvider.kt index d58fb2a8f..7b4aaf380 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/AndroidPasskeyProvider.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/AndroidPasskeyProvider.kt @@ -2,6 +2,7 @@ package org.bitcoinppl.cove.cloudbackup import android.content.Context import android.os.Looper +import androidx.credentials.Credential import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.CreatePublicKeyCredentialResponse import androidx.credentials.CredentialManager @@ -9,13 +10,11 @@ import androidx.credentials.GetCredentialRequest import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.PublicKeyCredential import androidx.credentials.exceptions.CreateCredentialCancellationException -import androidx.credentials.exceptions.CreateCredentialException import androidx.credentials.exceptions.CreateCredentialInterruptedException import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException import androidx.credentials.exceptions.CreateCredentialUnsupportedException import androidx.credentials.exceptions.GetCredentialCancellationException -import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.GetCredentialInterruptedException import androidx.credentials.exceptions.GetCredentialProviderConfigurationException import androidx.credentials.exceptions.GetCredentialUnsupportedException @@ -110,11 +109,10 @@ class AndroidPasskeyProvider( ) val credential = - response.credential as? PublicKeyCredential - ?: throw passkeyRequestFailed( - PasskeyOperation.AUTHENTICATE_ASSERTION, - PasskeyFailureReason.UnexpectedCredentialType, - ) + requirePublicKeyCredential( + response.credential, + PasskeyOperation.AUTHENTICATE_ASSERTION, + ) extractPrfOutput(credential.authenticationResponseJson) } catch (error: Exception) { @@ -144,8 +142,10 @@ class AndroidPasskeyProvider( ) val credential = - response.credential as? PublicKeyCredential - ?: throw PasskeyException.NoCredentialFound() + requirePublicKeyCredential( + response.credential, + PasskeyOperation.DISCOVER_ASSERTION, + ) DiscoveredPasskeyResult( prfOutput = extractPrfOutput(credential.authenticationResponseJson), @@ -337,16 +337,10 @@ internal fun mapPasskeyCreateError(error: Exception): PasskeyException = is CreateCredentialUnsupportedException -> passkeyNotSupported(PasskeyFailureReason.ProviderConfiguration) - is CreateCredentialException -> - passkeyRequestFailed( - PasskeyOperation.REGISTRATION, - passkeyCreateFailureReason(error.passkeyMessage("passkey creation failed")), - ) - else -> passkeyRequestFailed( PasskeyOperation.REGISTRATION, - passkeyCreateFailureReason(error.passkeyMessage("passkey creation failed")), + passkeyUnknownReason("passkey creation failed"), ) } @@ -384,16 +378,10 @@ internal fun mapPasskeyGetError( is GetCredentialUnsupportedException -> passkeyNotSupported(PasskeyFailureReason.ProviderConfiguration) - is GetCredentialException -> - passkeyRequestFailed( - operation, - passkeyUnknownReason(error.passkeyMessage("passkey authentication failed")), - ) - else -> passkeyRequestFailed( operation, - passkeyUnknownReason(error.passkeyMessage("passkey authentication failed")), + passkeyUnknownReason("passkey authentication failed"), ) } @@ -411,9 +399,6 @@ private fun passkeyRequestFailed( private fun passkeyUnknownReason(message: String): PasskeyFailureReason = PasskeyFailureReason.Unknown(diagnosticMessage = message) -private fun passkeyCreateFailureReason(message: String): PasskeyFailureReason = - passkeyUnknownReason(message) - private fun passkeyCreateDomErrorReason( error: CreatePublicKeyCredentialDomException, ): PasskeyFailureReason { @@ -435,9 +420,6 @@ private fun passkeyDomErrorReason(domError: DomError): PasskeyFailureReason = else -> passkeyUnknownReason("passkey DOM error: ${domError.type}") } -private fun Throwable.passkeyMessage(fallback: String): String = - message?.takeIf(String::isNotBlank) ?: fallback - internal fun buildPasskeyCreateRequestJson( rpId: String, challenge: ByteArray, @@ -466,7 +448,7 @@ internal fun buildPasskeyCreateRequestJson( "authenticatorSelection", JSONObject() .put("residentKey", "required") - .put("userVerification", "preferred"), + .put("userVerification", "required"), ).put( "extensions", JSONObject().put("prf", JSONObject()), @@ -482,7 +464,7 @@ internal fun buildPasskeyAssertionRequestJson( JSONObject() .put("challenge", challenge.toBase64Url()) .put("rpId", rpId) - .put("userVerification", "preferred") + .put("userVerification", "required") .put( "extensions", JSONObject().put( @@ -508,6 +490,16 @@ internal fun buildPasskeyAssertionRequestJson( return request.toString() } +internal fun requirePublicKeyCredential( + credential: Credential, + operation: PasskeyOperation, +): PublicKeyCredential = + credential as? PublicKeyCredential + ?: throw passkeyRequestFailed( + operation, + PasskeyFailureReason.UnexpectedCredentialType, + ) + internal fun validatePasskeyRegistrationPrf(responseJson: String) { val prf = JSONObject(responseJson) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/CloudBackupEnableOnboardingContent.kt b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/CloudBackupEnableOnboardingContent.kt index edade2631..b11b97fab 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/CloudBackupEnableOnboardingContent.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/CloudBackupEnableOnboardingContent.kt @@ -335,7 +335,7 @@ internal fun cloudBackupEnableBusyCopy( null, -> CloudBackupEnableBusyCopy( - title = "Creating your encrypted backup...", + title = "Checking for existing backups...", subtitle = "Cloud Backup will continue automatically", ) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/CloudBackupManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/CloudBackupManager.kt index fb5e7ed36..325f88d2d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/CloudBackupManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/cloudbackup/CloudBackupManager.kt @@ -539,8 +539,13 @@ class CloudBackupManager private constructor( private fun apply(message: CloudBackupReconcileMessage) { val wasDisablingCloudBackup = isDisablingCloudBackup when (message) { - is CloudBackupReconcileMessage.Lifecycle -> + is CloudBackupReconcileMessage.Lifecycle -> { + // a completion from an enable that has since been turned off, such as by a full wipe, is stale + if (message.v1 is CloudBackupLifecycle.Disabled) enableCompletion = null + state = state.copy(lifecycle = message.v1, settingsRowStatus = message.v2) + } + is CloudBackupReconcileMessage.EnableCompleted -> enableCompletion = TaggedItem(message.v1) is CloudBackupReconcileMessage.DriveAccountSwitchCommitRequired -> { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsSecuritySection.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsSecuritySection.kt index 50ea633bc..8be4de702 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsSecuritySection.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsSecuritySection.kt @@ -66,27 +66,30 @@ internal fun SecuritySection(app: org.bitcoinppl.cove.AppManager) { } // toggle handlers using Rust validation + // rust only reads unverified wallet ids when enabling the wipe data PIN, so the other + // toggles skip the keychain and database reads behind unverifiedWalletIds() fun onBiometricToggle(enable: Boolean) { val action = SecuritySettingsAction.ToggleBiometric(enable) - val result = auth.validateSecurityAction(action, app.unverifiedWalletIds()) + val result = auth.validateSecurityAction(action, emptyList()) handleSecurityResult(result, action) } fun onPinToggle(enable: Boolean) { val action = SecuritySettingsAction.TogglePin(enable) - val result = auth.validateSecurityAction(action, app.unverifiedWalletIds()) + val result = auth.validateSecurityAction(action, emptyList()) handleSecurityResult(result, action) } fun onWipeDataPinToggle(enable: Boolean) { val action = SecuritySettingsAction.ToggleWipeDataPin(enable) - val result = auth.validateSecurityAction(action, app.unverifiedWalletIds()) + val unverifiedWalletIds = if (enable) app.unverifiedWalletIds() else emptyList() + val result = auth.validateSecurityAction(action, unverifiedWalletIds) handleSecurityResult(result, action) } fun onDecoyPinToggle(enable: Boolean) { val action = SecuritySettingsAction.ToggleDecoyPin(enable) - val result = auth.validateSecurityAction(action, app.unverifiedWalletIds()) + val result = auth.validateSecurityAction(action, emptyList()) handleSecurityResult(result, action) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt index 5f642029e..c0e4bce3b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/LockView.kt @@ -24,7 +24,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material3.Button import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -57,6 +56,8 @@ import kotlinx.coroutines.launch import org.bitcoinppl.cove.Auth import org.bitcoinppl.cove.Log import org.bitcoinppl.cove.UnlockMode +import org.bitcoinppl.cove.WIPE_FAILURE_MESSAGE +import org.bitcoinppl.cove.WIPE_FAILURE_TITLE import org.bitcoinppl.cove.WipePresentationState import org.bitcoinppl.cove.findFragmentActivity import org.bitcoinppl.cove_core.AuthType @@ -211,28 +212,12 @@ fun LockView( } } - if (auth.wipePresentationState == WipePresentationState.Running) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.8f)), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator() - Spacer(modifier = Modifier.height(16.dp)) - Text("Removing local wallet data…", color = Color.White) - } - } - } - when (val wipeState = auth.wipePresentationState) { is WipePresentationState.ShutdownBlocked -> { AlertDialog( onDismissRequest = {}, - title = { Text("Wallet Shutdown Is Blocked") }, - text = { Text("Cove could not stop all wallet work. Retry or cancel the wipe.") }, + title = { Text(WIPE_FAILURE_TITLE) }, + text = { Text(WIPE_FAILURE_MESSAGE) }, confirmButton = { TextButton(onClick = { auth.retryWipe(wipeState.attemptId) }) { Text("Retry") @@ -246,11 +231,11 @@ fun LockView( ) } - is WipePresentationState.Failed -> { + WipePresentationState.Failed -> { AlertDialog( onDismissRequest = auth::clearWipeFailure, - title = { Text("Unable to Remove Local Data") }, - text = { Text(wipeState.message) }, + title = { Text(WIPE_FAILURE_TITLE) }, + text = { Text(WIPE_FAILURE_MESSAGE) }, confirmButton = { TextButton(onClick = auth::clearWipeFailure) { Text("OK") } }, diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index 23bf0826c..d859e2cc1 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1317,8 +1317,6 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_converter_parse_fiat_str( ): Short - external fun uniffi_cove_checksum_method_database_dangerous_reset_all_data( - ): Short external fun uniffi_cove_checksum_method_database_diagnostics_reports( ): Short external fun uniffi_cove_checksum_method_database_global_config( @@ -2214,7 +2212,7 @@ internal object UniffiLib { external fun uniffi_cove_fn_method_ffiapp_cancel_wallet_deletion_attempt(`ptr`: Long,`attemptId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_cove_fn_method_ffiapp_dangerous_wipe_all_data(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ): RustBuffer.ByValue external fun uniffi_cove_fn_method_ffiapp_delete_corrupted_wallet(`ptr`: Long,`id`: RustBufferWalletId.ByValue, ): Long external fun uniffi_cove_fn_method_ffiapp_dispatch(`ptr`: Long,`action`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -2256,7 +2254,7 @@ internal object UniffiLib { external fun uniffi_cove_fn_method_ffiapp_reset_nested_routes_to(`ptr`: Long,`defaultRoute`: RustBuffer.ByValue,`nestedRoutes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_cove_fn_method_ffiapp_retry_dangerous_wipe_all_data(`ptr`: Long,`attemptId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ): RustBuffer.ByValue external fun uniffi_cove_fn_method_ffiapp_retry_delete_corrupted_wallet(`ptr`: Long,`id`: RustBufferWalletId.ByValue,`attemptId`: RustBuffer.ByValue, ): Long external fun uniffi_cove_fn_method_ffiapp_save_tap_signer_backup(`ptr`: Long,`tapSigner`: Long,`backup`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -2373,8 +2371,6 @@ internal object UniffiLib { ): Unit external fun uniffi_cove_fn_constructor_database_new(uniffi_out_err: UniffiRustCallStatus, ): Long - external fun uniffi_cove_fn_method_database_dangerous_reset_all_data(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit external fun uniffi_cove_fn_method_database_diagnostics_reports(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_method_database_global_config(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -4080,7 +4076,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_ffiapp_cancel_wallet_deletion_attempt() != 29231.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_ffiapp_dangerous_wipe_all_data() != 1643.toShort()) { + if (lib.uniffi_cove_checksum_method_ffiapp_dangerous_wipe_all_data() != 55578.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_cove_checksum_method_ffiapp_delete_corrupted_wallet() != 8180.toShort()) { @@ -4143,7 +4139,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_ffiapp_reset_nested_routes_to() != 57261.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_ffiapp_retry_dangerous_wipe_all_data() != 38709.toShort()) { + if (lib.uniffi_cove_checksum_method_ffiapp_retry_dangerous_wipe_all_data() != 62726.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_cove_checksum_method_ffiapp_retry_delete_corrupted_wallet() != 50275.toShort()) { @@ -4239,9 +4235,6 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_converter_parse_fiat_str() != 59628.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_database_dangerous_reset_all_data() != 1221.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } if (lib.uniffi_cove_checksum_method_database_diagnostics_reports() != 32801.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -10744,8 +10737,6 @@ public object FfiConverterTypeConverter: FfiConverter { public interface DatabaseInterface { - fun `dangerousResetAllData`() - fun `diagnosticsReports`(): DiagnosticsReportsTable fun `globalConfig`(): GlobalConfigTable @@ -10870,19 +10861,6 @@ open class Database: Disposable, AutoCloseable, DatabaseInterface } } - - @Throws(DatabaseException::class)override fun `dangerousResetAllData`() - = - callWithHandle { - uniffiRustCallWithError(DatabaseException) { _status -> - UniffiLib.uniffi_cove_fn_method_database_dangerous_reset_all_data( - it, - _status) -} - } - - - override fun `diagnosticsReports`(): DiagnosticsReportsTable { return FfiConverterTypeDiagnosticsReportsTable.lift( callWithHandle { @@ -11747,8 +11725,10 @@ public interface FfiAppInterface { /** * DANGER: This will wipe all wallet data on this device + * + * Returns the committed post-wipe state the frontend applies before releasing authentication */ - fun `dangerousWipeAllData`() + fun `dangerousWipeAllData`(): FullWipeCompletion /** * Delete a wallet with a corrupted database, cleaning up all associated data @@ -11834,7 +11814,7 @@ public interface FfiAppInterface { /** * Retry a full wipe after a typed shutdown block */ - fun `retryDangerousWipeAllData`(`attemptId`: ShutdownAttemptId) + fun `retryDangerousWipeAllData`(`attemptId`: ShutdownAttemptId): FullWipeCompletion /** * Retry a corrupted-wallet deletion after a typed shutdown block @@ -12038,9 +12018,11 @@ open class FfiApp: Disposable, AutoCloseable, FfiAppInterface /** * DANGER: This will wipe all wallet data on this device + * + * Returns the committed post-wipe state the frontend applies before releasing authentication */ - @Throws(AppException::class)override fun `dangerousWipeAllData`() - = + @Throws(AppException::class)override fun `dangerousWipeAllData`(): FullWipeCompletion { + return FfiConverterTypeFullWipeCompletion.lift( callWithHandle { uniffiRustCallWithError(AppException) { _status -> UniffiLib.uniffi_cove_fn_method_ffiapp_dangerous_wipe_all_data( @@ -12048,7 +12030,8 @@ open class FfiApp: Disposable, AutoCloseable, FfiAppInterface _status) } } - + ) + } @@ -12382,8 +12365,8 @@ open class FfiApp: Disposable, AutoCloseable, FfiAppInterface /** * Retry a full wipe after a typed shutdown block */ - @Throws(AppException::class)override fun `retryDangerousWipeAllData`(`attemptId`: ShutdownAttemptId) - = + @Throws(AppException::class)override fun `retryDangerousWipeAllData`(`attemptId`: ShutdownAttemptId): FullWipeCompletion { + return FfiConverterTypeFullWipeCompletion.lift( callWithHandle { uniffiRustCallWithError(AppException) { _status -> UniffiLib.uniffi_cove_fn_method_ffiapp_retry_dangerous_wipe_all_data( @@ -12392,7 +12375,8 @@ open class FfiApp: Disposable, AutoCloseable, FfiAppInterface FfiConverterTypeShutdownAttemptId.lower(`attemptId`),_status) } } - + ) + } @@ -32253,6 +32237,61 @@ public object FfiConverterTypeAppState: FfiConverterRustBuffer { +/** + * Authentication settings a frontend mirrors + */ +data class AuthSettings ( + /** + * How the app is unlocked + */ + var `authType`: AuthType + , + /** + * Whether a wipe data PIN is set + */ + var `isWipeDataPinEnabled`: kotlin.Boolean + , + /** + * Whether a decoy PIN is set + */ + var `isDecoyPinEnabled`: kotlin.Boolean + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeAuthSettings: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): AuthSettings { + return AuthSettings( + FfiConverterTypeAuthType.read(buf), + FfiConverterBoolean.read(buf), + FfiConverterBoolean.read(buf), + ) + } + + override fun allocationSize(value: AuthSettings) = ( + FfiConverterTypeAuthType.allocationSize(value.`authType`) + + FfiConverterBoolean.allocationSize(value.`isWipeDataPinEnabled`) + + FfiConverterBoolean.allocationSize(value.`isDecoyPinEnabled`) + ) + + override fun write(value: AuthSettings, buf: ByteBuffer) { + FfiConverterTypeAuthType.write(value.`authType`, buf) + FfiConverterBoolean.write(value.`isWipeDataPinEnabled`, buf) + FfiConverterBoolean.write(value.`isDecoyPinEnabled`, buf) + } +} + + + /** * Report of what happened during a backup import */ @@ -33830,6 +33869,104 @@ public object FfiConverterTypeFoundAddress: FfiConverterRustBuffer +/** + * Committed app and authentication state after a successful full wipe + * + * Frontends apply this synchronously before releasing authentication, so no frontend has + * to decide on its own what a wiped app looks like + */ +data class FullWipeCompletion ( + /** + * Navigation after the wipe, starting at new-wallet selection with no pushed routes + */ + var `router`: Router + , + /** + * Whether the app must show onboarding, carried over from before the wipe + */ + var `needsOnboarding`: kotlin.Boolean + , + var `selectedNetwork`: Network + , + var `colorScheme`: ColorSchemeSelection + , + var `selectedNode`: Node + , + var `fiatCurrency`: FiatCurrency + , + /** + * Always empty, included so frontends replace their wallet list instead of re-reading it + */ + var `wallets`: List + , + var `auth`: AuthSettings + +): Disposable{ + + + + + + @Suppress("UNNECESSARY_SAFE_CALL") // codegen is much simpler if we unconditionally emit safe calls here + override fun destroy() { + + Disposable.destroy( + this.`router`, + this.`needsOnboarding`, + this.`selectedNetwork`, + this.`colorScheme`, + this.`selectedNode`, + this.`fiatCurrency`, + this.`wallets`, + this.`auth` + ) + } + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeFullWipeCompletion: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): FullWipeCompletion { + return FullWipeCompletion( + FfiConverterTypeRouter.read(buf), + FfiConverterBoolean.read(buf), + FfiConverterTypeNetwork.read(buf), + FfiConverterTypeColorSchemeSelection.read(buf), + FfiConverterTypeNode.read(buf), + FfiConverterTypeFiatCurrency.read(buf), + FfiConverterSequenceTypeWalletMetadata.read(buf), + FfiConverterTypeAuthSettings.read(buf), + ) + } + + override fun allocationSize(value: FullWipeCompletion) = ( + FfiConverterTypeRouter.allocationSize(value.`router`) + + FfiConverterBoolean.allocationSize(value.`needsOnboarding`) + + FfiConverterTypeNetwork.allocationSize(value.`selectedNetwork`) + + FfiConverterTypeColorSchemeSelection.allocationSize(value.`colorScheme`) + + FfiConverterTypeNode.allocationSize(value.`selectedNode`) + + FfiConverterTypeFiatCurrency.allocationSize(value.`fiatCurrency`) + + FfiConverterSequenceTypeWalletMetadata.allocationSize(value.`wallets`) + + FfiConverterTypeAuthSettings.allocationSize(value.`auth`) + ) + + override fun write(value: FullWipeCompletion, buf: ByteBuffer) { + FfiConverterTypeRouter.write(value.`router`, buf) + FfiConverterBoolean.write(value.`needsOnboarding`, buf) + FfiConverterTypeNetwork.write(value.`selectedNetwork`, buf) + FfiConverterTypeColorSchemeSelection.write(value.`colorScheme`, buf) + FfiConverterTypeNode.write(value.`selectedNode`, buf) + FfiConverterTypeFiatCurrency.write(value.`fiatCurrency`, buf) + FfiConverterSequenceTypeWalletMetadata.write(value.`wallets`, buf) + FfiConverterTypeAuthSettings.write(value.`auth`, buf) + } +} + + + data class GroupedWord ( var `number`: kotlin.UByte , @@ -38792,11 +38929,29 @@ sealed class AuthManagerReconcileMessage { companion object } - object WipeDataPinChanged : AuthManagerReconcileMessage() + /** + * Whether a wipe data PIN is now set + */ + data class WipeDataPinChanged( + val v1: kotlin.Boolean) : AuthManagerReconcileMessage() + + { + + + companion object + } + + /** + * Whether a decoy PIN is now set + */ + data class DecoyPinChanged( + val v1: kotlin.Boolean) : AuthManagerReconcileMessage() + { - object DecoyPinChanged : AuthManagerReconcileMessage() + companion object + } @@ -38817,8 +38972,12 @@ public object FfiConverterTypeAuthManagerReconcileMessage : FfiConverterRustBuff 1 -> AuthManagerReconcileMessage.AuthTypeChanged( FfiConverterTypeAuthType.read(buf), ) - 2 -> AuthManagerReconcileMessage.WipeDataPinChanged - 3 -> AuthManagerReconcileMessage.DecoyPinChanged + 2 -> AuthManagerReconcileMessage.WipeDataPinChanged( + FfiConverterBoolean.read(buf), + ) + 3 -> AuthManagerReconcileMessage.DecoyPinChanged( + FfiConverterBoolean.read(buf), + ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -38835,12 +38994,14 @@ public object FfiConverterTypeAuthManagerReconcileMessage : FfiConverterRustBuff // Add the size for the Int that specifies the variant plus the size needed for all fields ( 4UL + + FfiConverterBoolean.allocationSize(value.v1) ) } is AuthManagerReconcileMessage.DecoyPinChanged -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( 4UL + + FfiConverterBoolean.allocationSize(value.v1) ) } } @@ -38854,10 +39015,12 @@ public object FfiConverterTypeAuthManagerReconcileMessage : FfiConverterRustBuff } is AuthManagerReconcileMessage.WipeDataPinChanged -> { buf.putInt(2) + FfiConverterBoolean.write(value.v1, buf) Unit } is AuthManagerReconcileMessage.DecoyPinChanged -> { buf.putInt(3) + FfiConverterBoolean.write(value.v1, buf) Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } @@ -51109,6 +51272,10 @@ enum class LocalDataResetStage { * Remaining Cove wallet keychain entries */ WALLET_KEYCHAIN, + /** + * Persisted KeyTeleport receive session + */ + KEY_TELEPORT, /** * Orphan BDK stores and wallet-data directories */ @@ -62080,6 +62247,14 @@ enum class WalletDeletionStage { * Parent-directory durability synchronization */ DIRECTORY_SYNC, + /** + * Unsigned transaction rows that belong to the wallet + */ + UNSIGNED_TRANSACTIONS, + /** + * Selected-wallet references in global config + */ + WALLET_SELECTION, /** * Exact durable metadata rows */ diff --git a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/AndroidPasskeyProviderTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/AndroidPasskeyProviderTest.kt index f1f9dbae1..ee1dd21c7 100644 --- a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/AndroidPasskeyProviderTest.kt +++ b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/AndroidPasskeyProviderTest.kt @@ -1,5 +1,7 @@ package org.bitcoinppl.cove.cloudbackup +import android.os.Bundle +import androidx.credentials.CustomCredential import androidx.credentials.exceptions.CreateCredentialCancellationException import androidx.credentials.exceptions.CreateCredentialInterruptedException import androidx.credentials.exceptions.CreateCredentialUnsupportedException @@ -26,7 +28,7 @@ import org.junit.Test class AndroidPasskeyProviderTest { @Test - fun createRequestJsonRequestsPrfExtension() { + fun createRequestJsonRequiresUserVerificationAndPreservesFields() { val request = JSONObject( buildPasskeyCreateRequestJson( @@ -41,6 +43,25 @@ class AndroidPasskeyProviderTest { ), ) + assertEquals("BAUG", request.getString("challenge")) + + val rp = request.getJSONObject("rp") + assertEquals("covebitcoinwallet.com", rp.getString("id")) + + val user = request.getJSONObject("user") + assertEquals("AQID", user.getString("id")) + assertEquals("test@example.com", user.getString("name")) + assertEquals("Test User", user.getString("displayName")) + + assertEquals( + "required", + request.getJSONObject("authenticatorSelection").getString("residentKey"), + ) + assertEquals( + "required", + request.getJSONObject("authenticatorSelection").getString("userVerification"), + ) + val prf = request.getJSONObject("extensions").getJSONObject("prf") assertEquals(0, prf.length()) @@ -48,7 +69,7 @@ class AndroidPasskeyProviderTest { } @Test - fun assertionRequestJsonDoesNotBoundInteractiveAuthorization() { + fun assertionRequestJsonRequiresUserVerificationAndPreservesFields() { val request = JSONObject( buildPasskeyAssertionRequestJson( @@ -59,9 +80,33 @@ class AndroidPasskeyProviderTest { ), ) + assertEquals("BwgJ", request.getString("challenge")) + assertEquals("covebitcoinwallet.com", request.getString("rpId")) + assertEquals("required", request.getString("userVerification")) + + val prf = request.getJSONObject("extensions").getJSONObject("prf") + assertEquals("BAUG", prf.getJSONObject("eval").getString("first")) + + val allowCredentials = request.getJSONArray("allowCredentials") + assertEquals(1, allowCredentials.length()) + assertEquals("public-key", allowCredentials.getJSONObject(0).getString("type")) + assertEquals("AQID", allowCredentials.getJSONObject(0).getString("id")) + assertTrue(!request.has("timeout")) } + @Test + fun discoveryRejectsUnexpectedCredentialTypeAsAuthenticationFailure() { + val unexpectedCredential = CustomCredential("unexpected", Bundle()) + val error = + assertThrows(PasskeyException.RequestFailed::class.java) { + requirePublicKeyCredential(unexpectedCredential, PasskeyOperation.DISCOVER_ASSERTION) + } + + assertEquals(PasskeyOperation.DISCOVER_ASSERTION, error.operation) + assertEquals(PasskeyFailureReason.UnexpectedCredentialType, error.reason) + } + @Test fun registrationPrfValidationAcceptsRootExtensionResults() { validatePasskeyRegistrationPrf( @@ -140,6 +185,10 @@ class AndroidPasskeyProviderTest { (localizedDiagnostic as PasskeyException.RequestFailed).reason is PasskeyFailureReason.Unknown, ) + assertEquals( + PasskeyFailureReason.Unknown("passkey creation failed"), + localizedDiagnostic.reason, + ) val createSecurityError = mapPasskeyCreateError(CreatePublicKeyCredentialDomException(SecurityError())) assertTrue(createSecurityError is PasskeyException.RequestFailed) @@ -235,7 +284,7 @@ class AndroidPasskeyProviderTest { (diagnostic as PasskeyException.RequestFailed).operation, ) assertEquals( - PasskeyFailureReason.Unknown("credential provider diagnostic"), + PasskeyFailureReason.Unknown("passkey authentication failed"), diagnostic.reason, ) } diff --git a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/CloudBackupRegressionHelpersTest.kt b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/CloudBackupRegressionHelpersTest.kt index e601f2907..1e12530d9 100644 --- a/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/CloudBackupRegressionHelpersTest.kt +++ b/android/app/src/test/java/org/bitcoinppl/cove/cloudbackup/CloudBackupRegressionHelpersTest.kt @@ -48,14 +48,15 @@ class CloudBackupRegressionHelpersTest { val context = manualEnableContext() val hidden = CloudBackupVerificationPresentation.Hidden(null) val checkingPasskey = "Checking that your passkey is available..." + val checkingExistingBackups = "Checking for existing backups..." val creatingBackup = "Creating your encrypted backup..." val states = listOf>( - CloudBackupEnableFlow.DiscoveringExistingBackup to creatingBackup, - CloudBackupEnableFlow.AwaitingForceNewConfirmation(context, null) to creatingBackup, + CloudBackupEnableFlow.DiscoveringExistingBackup to checkingExistingBackups, + CloudBackupEnableFlow.AwaitingForceNewConfirmation(context, null) to checkingExistingBackups, CloudBackupEnableFlow.AwaitingPasskeyChoice( CloudBackupPasskeyChoiceIntent.Enable(context, null), - ) to creatingBackup, + ) to checkingExistingBackups, CloudBackupEnableFlow.CreatingPasskey to "Creating your passkey...", CloudBackupEnableFlow.AwaitingSavedPasskeyConfirmation( SavedPasskeyConfirmationMode.AUTOMATIC, @@ -67,7 +68,7 @@ class CloudBackupRegressionHelpersTest { CloudBackupEnableFlow.UploadingInitialBackup(null) to creatingBackup, CloudBackupEnableFlow.RetryingUploadWithStagedMaterial(null) to creatingBackup, CloudBackupEnableFlow.WaitingForPasskeyAvailability to checkingPasskey, - null to creatingBackup, + null to checkingExistingBackups, ) states.forEach { (state, expectedTitle) -> diff --git a/docs/icloud_drive.md b/docs/icloud_drive.md index e660d2277..10d83b21d 100644 --- a/docs/icloud_drive.md +++ b/docs/icloud_drive.md @@ -37,6 +37,8 @@ If you need one subdirectory, start with the appropriate iCloud scope and narrow Cove owns one main-actor metadata query for the lifetime of the process. The query searches the ubiquitous data scope, publishes value snapshots to async consumers, and remains running so later `didUpdate` events can satisfy upload-confirmation checks, downloads, listings, and sync-health checks. Do not create short-lived queries for individual operations or call `stop()` during normal app operation. Tearing down a query while CloudDocs is delivering progress notifications can race inside `NSMetadataQuery` cleanup. +Complete listings reuse a metadata generation that has already completed the required quiet interval. A later metadata event invalidates that settled generation, so the next complete listing waits for stability again. + The initial result is authoritative only after `NSMetadataQueryDidFinishGathering`. Updates received while gathering may reveal an item early, but an empty partial snapshot must not be interpreted as proof that an item is absent. A transient `start()` failure is not process-fatal: the shared index surfaces it to the operation that attempted startup and permits a later consumer to retry. ### Cold start timing @@ -163,6 +165,19 @@ Rust owns upload confirmation. Its pending-upload worker later calls marking the blob confirmed. Do not make the upload methods wait for metadata visibility; provider confirmation belongs to that retryable background flow. +### Deleting files + +After a coordinated delete succeeds, the iOS storage helper records the resolved +path in the process-wide metadata index before it returns. The index immediately +removes that path and its descendants from all metadata readers. It also filters +stale provider snapshots for up to 60 seconds. + +A complete snapshot that no longer contains the deleted path releases the +temporary record. A partial update during initial gathering cannot release it. +A successful upload releases only the exact file path after local handoff +validation. This is a process-local consistency bound, not proof that the +provider completed its remote delete. + ### Timeouts and retries in this app These numbers are project heuristics, not Apple guidance: diff --git a/ios/Cove.xcodeproj/project.pbxproj b/ios/Cove.xcodeproj/project.pbxproj index b57f0f604..07e8cb8d2 100644 --- a/ios/Cove.xcodeproj/project.pbxproj +++ b/ios/Cove.xcodeproj/project.pbxproj @@ -526,7 +526,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 125; DEVELOPMENT_TEAM = Q8UP8C53Y8; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -550,7 +550,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 125; DEVELOPMENT_TEAM = Q8UP8C53Y8; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -575,7 +575,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 125; DEVELOPMENT_TEAM = Q8UP8C53Y8; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -600,7 +600,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 125; DEVELOPMENT_TEAM = Q8UP8C53Y8; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -628,7 +628,7 @@ CODE_SIGN_ENTITLEMENTS = Cove/Cove.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 125; DEVELOPMENT_ASSET_PATHS = "\"Cove/Preview Content\""; DEVELOPMENT_TEAM = Q8UP8C53Y8; ENABLE_APP_SANDBOX = YES; @@ -674,7 +674,7 @@ CODE_SIGN_ENTITLEMENTS = Cove/Cove.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 125; DEVELOPMENT_ASSET_PATHS = "\"Cove/Preview Content\""; DEVELOPMENT_TEAM = Q8UP8C53Y8; ENABLE_APP_SANDBOX = YES; @@ -716,7 +716,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 125; DEVELOPMENT_TEAM = Q8UP8C53Y8; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -740,7 +740,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 125; DEVELOPMENT_TEAM = Q8UP8C53Y8; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -893,7 +893,7 @@ CODE_SIGN_ENTITLEMENTS = Cove/Cove.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 125; DEVELOPMENT_ASSET_PATHS = "\"Cove/Preview Content\""; DEVELOPMENT_TEAM = Q8UP8C53Y8; ENABLE_APP_SANDBOX = YES; @@ -943,7 +943,7 @@ CODE_SIGN_ENTITLEMENTS = Cove/Cove.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 125; DEVELOPMENT_ASSET_PATHS = "\"Cove/Preview Content\""; DEVELOPMENT_TEAM = Q8UP8C53Y8; ENABLE_APP_SANDBOX = YES; diff --git a/ios/Cove/AppManager.swift b/ios/Cove/AppManager.swift index ff071c3c1..ecc885bf3 100644 --- a/ios/Cove/AppManager.swift +++ b/ios/Cove/AppManager.swift @@ -244,18 +244,48 @@ struct CorruptedWalletDeletionRetry: Equatable { } /// Reset the manager state + @MainActor public func reset() { + clearSession() + resetProjectionFromCommittedRustState() + } + + @MainActor + private func clearSession() { navigationCoordinator.reset() corruptedWalletDeletionRetry = nil - database = Database() - needsOnboarding = rust.needsOnboarding() clearWalletManager() - managerCache.clearCoinControlManager() clearKeyTeleportManager() - let state = rust.state() - router = state.router + // a mode switch must not leave the previous mode's NFC session running + tapSignerNfc?.cancel() + tapSignerNfc = nil + } + + /// Apply the committed post-wipe state from Rust before authentication is released + /// + /// Rust decides what a wiped app looks like; this only clears iOS-owned presentation state + @MainActor + func applyWipeCompletion(_ completion: FullWipeCompletion) async { + isSidebarVisible = false + isLoading = false + alertState = nil + sheetState = nil + isPastHeader = false + clearSession() + + database = Database() + needsOnboarding = completion.needsOnboarding + selectedNetwork = completion.selectedNetwork + colorSchemeSelection = completion.colorScheme + selectedNode = completion.selectedNode + selectedFiatCurrency = completion.fiatCurrency + wallets = completion.wallets + router = completion.router + routeId = UUID() + + await PopupStack.dismissAllPopups() } func deleteCorruptedWallet(id: WalletId, databaseError: String) { @@ -752,6 +782,21 @@ struct CorruptedWalletDeletionRetry: Equatable { } extension AppManager { + private func resetProjectionFromCommittedRustState() { + database = Database() + needsOnboarding = rust.needsOnboarding() + + let globalConfig = database.globalConfig() + selectedNetwork = globalConfig.selectedNetwork() + colorSchemeSelection = globalConfig.colorScheme() + selectedNode = globalConfig.selectedNode() + selectedFiatCurrency = globalConfig.selectedFiatCurrency() + wallets = (try? database.wallets().all()) ?? [] + + router = rust.state().router + routeId = UUID() + } + @MainActor private func applyConfigurationMessage(_ message: AppStateReconcileMessage) { switch message { diff --git a/ios/Cove/AuthManager.swift b/ios/Cove/AuthManager.swift index 9cbda7e94..b01f093cb 100644 --- a/ios/Cove/AuthManager.swift +++ b/ios/Cove/AuthManager.swift @@ -8,11 +8,14 @@ enum WipePresentationState: Equatable { case idle case running case shutdownBlocked(ShutdownAttemptId) - case failed(String) + case failed + + static let failureTitle = "Unable to Open Cove" + static let failureMessage = "Please try again." } private enum WipeCallResult: Sendable { - case success + case success(FullWipeCompletion) case failure(AppError) case unexpectedFailure(String) } @@ -159,19 +162,27 @@ private enum WipeCallResult: Sendable { case retry(ShutdownAttemptId) } + @MainActor + private func apply(_ settings: AuthSettings) { + type = settings.authType + isWipeDataPinEnabled = settings.isWipeDataPinEnabled + isDecoyPinEnabled = settings.isDecoyPinEnabled + isUsingBiometrics = false + } + @MainActor private func finishWipe(call: WipeCall) async -> UnlockMode { - let app = AppManager.shared.rust + let rustApp = AppManager.shared.rust let result = await Task.detached(priority: .userInitiated) { do { - switch call { + let completion = switch call { case .initial: - try app.dangerousWipeAllData() + try rustApp.dangerousWipeAllData() case let .retry(attemptId): - try app.retryDangerousWipeAllData(attemptId: attemptId) + try rustApp.retryDangerousWipeAllData(attemptId: attemptId) } - return WipeCallResult.success + return WipeCallResult.success(completion) } catch let error as AppError { return WipeCallResult.failure(error) } catch { @@ -180,12 +191,11 @@ private enum WipeCallResult: Sendable { }.value switch result { - case .success: - rust = RustAuthManager() + case let .success(completion): + await AppManager.shared.applyWipeCompletion(completion) + apply(completion.auth) unlock() - type = .none wipePresentationState = .idle - AppManager.shared.reset() return .wipe case let .failure(.WalletLifecycle(.shutdownBlocked(attemptId, _, _))): @@ -194,12 +204,12 @@ private enum WipeCallResult: Sendable { case let .failure(error): logger.error("Failed to wipe all data: \(error)") - wipePresentationState = .failed(error.localizedDescription) + wipePresentationState = .failed return .locked case let .unexpectedFailure(message): logger.error("Failed to wipe all data: \(message)") - wipePresentationState = .failed(message) + wipePresentationState = .failed return .locked } } @@ -243,11 +253,11 @@ private enum WipeCallResult: Sendable { case let .authTypeChanged(authType): type = authType - case .wipeDataPinChanged: - isWipeDataPinEnabled = rust.isWipeDataPinEnabled() + case let .wipeDataPinChanged(enabled): + isWipeDataPinEnabled = enabled - case .decoyPinChanged: - isDecoyPinEnabled = rust.isDecoyPinEnabled() + case let .decoyPinChanged(enabled): + isDecoyPinEnabled = enabled } } } diff --git a/ios/Cove/CloudBackupManager.swift b/ios/Cove/CloudBackupManager.swift index 82aac4761..1847cbfde 100644 --- a/ios/Cove/CloudBackupManager.swift +++ b/ios/Cove/CloudBackupManager.swift @@ -364,6 +364,9 @@ final class CloudBackupManager: ReconcilingManager, CloudBackupManagerReconciler state.lifecycle = lifecycle state.settingsRowStatus = settingsRowStatus + // a completion from an enable that has since been turned off, such as by a full wipe, is stale + if case .disabled = lifecycle { enableCompletion = nil } + case let .enableCompleted(context): enableCompletion = TaggedItem(context) diff --git a/ios/Cove/CloudBackupPresentationCoordinator.swift b/ios/Cove/CloudBackupPresentationCoordinator.swift index 82eb5aa08..9710588e6 100644 --- a/ios/Cove/CloudBackupPresentationCoordinator.swift +++ b/ios/Cove/CloudBackupPresentationCoordinator.swift @@ -176,6 +176,22 @@ final class CloudBackupPresentationCoordinator { presentationTransitions.discard { _ in true } } + @discardableResult + func dismissCurrentPresentationForAction() -> PresentationTransitionRequest? { + guard currentPresentation != nil else { return nil } + + ignoreNextDismissEvent = true + return presentationTransitions.dismissCurrentPresentationForTransition() + } + + func isPendingActionPresentable(_ presentation: CloudBackupRootPresentation) -> Bool { + guard CloudBackupRootPresentation(rootPrompt: rootPrompt()) == presentation else { + return false + } + + return isPromptPresentable(presentation) + } + func consumeDismissEvent() -> Bool { if ignoreNextDismissEvent { ignoreNextDismissEvent = false @@ -212,22 +228,9 @@ final class CloudBackupPresentationCoordinator { return } - if currentPresentation == desiredPresentation { - presentationTransitions.discardQueued { _ in true } - return - } - - if currentPresentation == nil { - if presentationTransitions.isAwaitingPresenterReadiness { - presentationTransitions.queue(desiredPresentation) - } else { - presentationTransitions.present(desiredPresentation) - } - return + presentationTransitions.reconcile(toward: desiredPresentation) { + ignoreNextDismissEvent = true } - - ignoreNextDismissEvent = true - presentationTransitions.transition(to: desiredPresentation) } func presenterDidBecomeReady(_ requestID: UUID) { @@ -293,6 +296,8 @@ struct CloudBackupPresentationHost: View { @State private var manager = CloudBackupManager.shared @State private var coordinator = CloudBackupPresentationCoordinator() + @State private var passkeyActionHandoff = + PresentationActionHandoff() @State private var successFloater: CloudBackupSuccessFloater? @State private var successFloaterDismissTask: Task? @@ -395,25 +400,52 @@ struct CloudBackupPresentationHost: View { } private func handlePasskeyChoice(existing: Bool) { - guard let intent = passkeyChoiceIntent else { return } - coordinator.dismissCurrentPresentation() + guard + let currentPresentation = coordinator.presentationTransitions.currentPresentation, + case let .passkeyChoice(intent) = currentPresentation.item + else { return } + + let presentation = currentPresentation.item + guard let action = passkeyChoiceAction(intent: intent, existing: existing) else { + coordinator.dismissCurrentPresentation() + return + } + + beginPasskeyAction(action, presentation: presentation) + } + private func passkeyChoiceAction( + intent: CloudBackupPasskeyChoiceIntent, + existing: Bool + ) -> CloudBackupManagerAction? { switch (intent, existing) { - case (.enable, true): - manager.dispatch(action: .acceptEnablePrompt(.useExisting)) + case (.enable, true), (.enableExistingPasskeyOnly, true): + .acceptEnablePrompt(.useExisting) case (.enable, false): - manager.dispatch(action: .acceptEnablePrompt(.createNew)) - case (.enableExistingPasskeyOnly, true): - manager.dispatch(action: .acceptEnablePrompt(.useExisting)) + .acceptEnablePrompt(.createNew) case (.enableExistingPasskeyOnly, false): - return + nil case (.repairPasskey, true): - manager.dispatch(action: .repairPasskey) + .repairPasskey case (.repairPasskey, false): - manager.dispatch(action: .repairPasskeyNoDiscovery) + .repairPasskeyNoDiscovery } } + private func beginPasskeyAction( + _ action: CloudBackupManagerAction, + presentation: CloudBackupRootPresentation + ) { + guard passkeyActionHandoff.pendingAction == nil else { return } + guard let transition = coordinator.dismissCurrentPresentationForAction() else { return } + + passkeyActionHandoff.stage( + action: action, + presentation: presentation, + transition: transition + ) + } + private func openCloudBackupScreen() { coordinator.dismissCurrentPresentation() @@ -426,13 +458,20 @@ struct CloudBackupPresentationHost: View { } private func createNewBackup() { - coordinator.dismissCurrentPresentation() - manager.dispatch(action: .acceptEnablePrompt(.createNew)) + acceptExistingBackupPrompt(.createNew) } private func useExistingBackup() { - coordinator.dismissCurrentPresentation() - manager.dispatch(action: .acceptEnablePrompt(.useExisting)) + acceptExistingBackupPrompt(.useExisting) + } + + private func acceptExistingBackupPrompt(_ choice: CloudBackupEnablePromptChoice) { + guard + let presentation = coordinator.currentPresentation, + case .existingBackupFound = presentation + else { return } + + beginPasskeyAction(.acceptEnablePrompt(choice), presentation: presentation) } private func cancelExistingBackupPrompt() { @@ -464,8 +503,12 @@ struct CloudBackupPresentationHost: View { } private func verifyCloudBackup() { - coordinator.dismissCurrentPresentation() - manager.startVerification(source: .rootPrompt) + guard coordinator.currentPresentation == .verificationPrompt else { return } + + beginPasskeyAction( + .startVerification(.rootPrompt), + presentation: .verificationPrompt + ) } private func existingPasskeyButtonTitle(for hint: CloudBackupPasskeyHint?) -> String { @@ -518,6 +561,40 @@ struct CloudBackupPresentationHost: View { } } + private func presenterDidBecomeReady(_ requestID: UUID) { + let currentPresentation = CloudBackupRootPresentation(rootPrompt: manager.rootPrompt) + let isHostAvailable = currentPresentation.map(coordinator.isPendingActionPresentable) == true + + passkeyActionHandoff.presenterDidBecomeReady( + requestID, + currentPresentation: currentPresentation, + isHostAvailable: isHostAvailable, + using: coordinator.presentationTransitions, + withoutPendingAction: coordinator.presenterDidBecomeReady + ) { action in + manager.dispatch(action: action) + } + } + + private func handleRootPromptChange(_ rootPrompt: CloudBackupRootPrompt) { + let currentPresentation = CloudBackupRootPresentation(rootPrompt: rootPrompt) + if let pendingPresentation = passkeyActionHandoff.pendingPresentation, + pendingPresentation != currentPresentation + { + passkeyActionHandoff.cancel() + coordinator.presentationTransitions.discardQueued { queuedPresentation in + queuedPresentation == pendingPresentation + } + } + + coordinator.reconcile() + } + + private func hostDidDisappear() { + passkeyActionHandoff.cancel() + coordinator.hostDidDisappear() + } + var body: some View { content .overlay(alignment: .top) { @@ -531,8 +608,8 @@ struct CloudBackupPresentationHost: View { .environment(coordinator) .presentationTransitionHost( state: coordinator.presentationTransitions.hostState, - presenterDidBecomeReady: coordinator.presenterDidBecomeReady, - hostDidDisappear: coordinator.hostDidDisappear + presenterDidBecomeReady: presenterDidBecomeReady, + hostDidDisappear: hostDidDisappear ) .modifier(CloudBackupObservationModifier( presentationContext: presentationContext, @@ -540,6 +617,7 @@ struct CloudBackupPresentationHost: View { verificationState: manager.verificationState, verificationPresentation: manager.verificationPresentation, updateContext: coordinator.update, + handleRootPromptChange: handleRootPromptChange, reconcile: coordinator.reconcile, handleVerificationPresentation: handleVerificationPresentation, onDisappear: dismissSuccessFloater @@ -605,6 +683,7 @@ private struct CloudBackupObservationModifier: ViewModifier { let verificationState: CloudBackupVerificationState? let verificationPresentation: CloudBackupVerificationPresentation let updateContext: (CloudBackupPresentationContext) -> Void + let handleRootPromptChange: (CloudBackupRootPrompt) -> Void let reconcile: () -> Void let handleVerificationPresentation: (CloudBackupVerificationPresentation) -> Void let onDisappear: (UUID?) -> Void @@ -614,8 +693,8 @@ private struct CloudBackupObservationModifier: ViewModifier { .onChange(of: presentationContext, initial: true) { _, context in updateContext(context) } - .onChange(of: rootPrompt) { _, _ in - reconcile() + .onChange(of: rootPrompt) { _, rootPrompt in + handleRootPromptChange(rootPrompt) } .onChange(of: verificationState) { _, _ in reconcile() diff --git a/ios/Cove/CoveMainView.swift b/ios/Cove/CoveMainView.swift index c0aebfcc3..fc24a5e9b 100644 --- a/ios/Cove/CoveMainView.swift +++ b/ios/Cove/CoveMainView.swift @@ -193,23 +193,28 @@ struct CoveMainView: View { } var body: some View { - CloudBackupPresentationHost(app: app, auth: auth, isCoverPresented: showCover) { - CoveMainPresentedContent( - app: app, - auth: auth, - showCover: $showCover, - scannedCode: $scannedCode, - id: id, - phase: phase, - presentationContext: presentationContext, - navigate: navigate, - resetIdentity: resetViewIdentity, - onChangeRoute: onChangeRoute, - onChangeQr: onChangeQr, - onChangeNfc: onChangeNfc, - onChangePhase: handleScenePhaseChange - ) + Group { + if auth.wipePresentationState == .running { + CoverView() + } else { + CloudBackupPresentationHost(app: app, auth: auth, isCoverPresented: showCover) { + CoveMainPresentedContent( + app: app, + auth: auth, + showCover: $showCover, + scannedCode: $scannedCode, + id: id, + presentationContext: presentationContext, + navigate: navigate, + resetIdentity: resetViewIdentity, + onChangeRoute: onChangeRoute, + onChangeQr: onChangeQr, + onChangeNfc: onChangeNfc + ) + } + } } + .onChange(of: phase, initial: true, handleScenePhaseChange) } } @@ -219,14 +224,12 @@ private struct CoveMainPresentedContent: View { @Binding var showCover: Bool @Binding var scannedCode: TaggedItem? let id: UUID - let phase: ScenePhase let presentationContext: CoveMainPresentationContext let navigate: (Route) -> Void let resetIdentity: () -> Void let onChangeRoute: ([Route], [Route]) -> Void let onChangeQr: (TaggedItem?, TaggedItem?) -> Void let onChangeNfc: (NfcMessage?, NfcMessage?) -> Void - let onChangePhase: (ScenePhase, ScenePhase) -> Void var body: some View { CoveLockedContent(app: app, auth: auth, showCover: $showCover) @@ -241,7 +244,6 @@ private struct CoveMainPresentedContent: View { .presentingAlert($app.alertState, context: presentationContext) .presentingSheet($app.sheetState, context: presentationContext) .onOpenURL(perform: ScanManager.shared.handleFileOpen) - .onChange(of: phase, initial: true, onChangePhase) } } @@ -290,18 +292,8 @@ private struct CoveWipePresentationModifier: ViewModifier { func body(content: Content) -> some View { content - .overlay { - if auth.wipePresentationState == .running { - ZStack { - Color.black.opacity(0.8).ignoresSafeArea() - ProgressView("Removing local wallet data…") - .tint(.white) - .foregroundStyle(.white) - } - } - } .alert( - "Wallet Shutdown Is Blocked", + WipePresentationState.failureTitle, isPresented: Binding( get: { blockedAttempt != nil }, set: { _ in } @@ -316,13 +308,13 @@ private struct CoveWipePresentationModifier: ViewModifier { } } } message: { - Text("Cove could not stop all wallet work. Retry or cancel the wipe.") + Text(WipePresentationState.failureMessage) } .alert( - "Unable to Remove Local Data", + WipePresentationState.failureTitle, isPresented: Binding( get: { - if case .failed = auth.wipePresentationState { true } else { false } + auth.wipePresentationState == .failed }, set: { presented in if !presented { auth.clearWipeFailure() } @@ -331,9 +323,7 @@ private struct CoveWipePresentationModifier: ViewModifier { ) { Button("OK", role: .cancel) { auth.clearWipeFailure() } } message: { - if case let .failed(message) = auth.wipePresentationState { - Text(message) - } + Text(WipePresentationState.failureMessage) } } diff --git a/ios/Cove/FFI/CloudStorageAccessImpl.swift b/ios/Cove/FFI/CloudStorageAccessImpl.swift index 5603af146..c68288b44 100644 --- a/ios/Cove/FFI/CloudStorageAccessImpl.swift +++ b/ios/Cove/FFI/CloudStorageAccessImpl.swift @@ -286,10 +286,13 @@ final class CloudStorageAccessImpl: CloudStorageAccess, @unchecked Sendable { data: Data, policy _: CloudAccessPolicy ) async throws { - try await run { + let url = try await run { let url = try self.helper.backupFileURL(namespace: namespace, location: location) try self.helper.writeForUpload(data: data, to: url) + return url } + + await helper.clearMetadataDeletion(of: url) } func uploadWalletBackup( @@ -299,10 +302,13 @@ final class CloudStorageAccessImpl: CloudStorageAccess, @unchecked Sendable { data: Data, policy _: CloudAccessPolicy ) async throws { - try await run { + let url = try await run { let url = try self.helper.backupFileURL(namespace: namespace, location: location) try self.helper.writeForUpload(data: data, to: url) + return url } + + await helper.clearMetadataDeletion(of: url) } // MARK: - Download @@ -351,28 +357,7 @@ final class CloudStorageAccessImpl: CloudStorageAccess, @unchecked Sendable { } func deleteNamespace(namespace: String, policy _: CloudAccessPolicy) async throws { - let url = try await run { - try self.helper.namespaceDirectoryReadURL(namespace: namespace) - } - let isLocallyVisible = await run { - FileManager.default.fileExists(atPath: url.path) - } - if isLocallyVisible { - try await run { - try self.helper.coordinatedDelete(at: url, missingItemID: namespace) - } - return - } - - let resolvedURL = try await helper.metadataItemIfPresent( - named: url.lastPathComponent, - parentDirectoryURL: url.deletingLastPathComponent() - )?.url - guard let resolvedURL else { throw CloudStorageError.NotFound(namespace) } - - try await run { - try self.helper.coordinatedDelete(at: resolvedURL, missingItemID: namespace) - } + try await helper.deleteNamespaceDirectory(namespace: namespace) } // MARK: - Discovery diff --git a/ios/Cove/FFI/ICloudDriveHelper+Metadata.swift b/ios/Cove/FFI/ICloudDriveHelper+Metadata.swift index 750dd901a..1741378a0 100644 --- a/ios/Cove/FFI/ICloudDriveHelper+Metadata.swift +++ b/ios/Cove/FFI/ICloudDriveHelper+Metadata.swift @@ -163,6 +163,8 @@ enum ICloudMetadataIndexError: Error, Equatable { case timedOut } +typealias ICloudMetadataSettleSleep = @MainActor @Sendable (TimeInterval) async throws -> Void + @MainActor final class ICloudMetadataIndex { static let shared = ICloudMetadataIndex(source: FoundationICloudMetadataQuerySource()) @@ -185,15 +187,46 @@ final class ICloudMetadataIndex { } private let source: ICloudMetadataQuerySource + private let settleSleep: ICloudMetadataSettleSleep + private let now: @MainActor @Sendable () -> Date + private let deletionTombstoneMaxAge: TimeInterval private var phase = Phase.idle private var records: [ICloudMetadataRecord] = [] + private var deletionTombstones: [String: Date] = [:] private var generation: UInt64 = 0 + // quiet interval observed for the current generation; `apply` resets it when records change + private var settledInterval: TimeInterval? private var snapshotWaiters: [UUID: SnapshotWaiter] = [:] private var itemWaiters: [UUID: ItemWaiter] = [:] private var observers: [UUID: @MainActor @Sendable () -> Void] = [:] - init(source: ICloudMetadataQuerySource) { + init( + source: ICloudMetadataQuerySource, + settleSleep: @escaping ICloudMetadataSettleSleep = { duration in + try await Task.sleep(for: .seconds(duration)) + }, + now: @escaping @MainActor @Sendable () -> Date = { Date() }, + deletionTombstoneMaxAge: TimeInterval = 60 + ) { self.source = source + self.settleSleep = settleSleep + self.now = now + self.deletionTombstoneMaxAge = deletionTombstoneMaxAge + } + + func markDeleted(resolvedPaths: [String]) { + let recordedAt = now() + for path in resolvedPaths { + deletionTombstones[path] = recordedAt + } + + records.removeAll { record in + resolvedPaths.contains { path in Self.covers(path: path, record: record) } + } + } + + func clearDeletion(resolvedPath: String) { + deletionTombstones.removeValue(forKey: resolvedPath) } func currentOrInitialRecords(timeout: TimeInterval) async throws -> [ICloudMetadataRecord] { @@ -215,15 +248,27 @@ final class ICloudMetadataIndex { let deadline = Date().addingTimeInterval(timeout) _ = try await currentOrInitialRecords(timeout: timeout) + try Task.checkCancellation() + guard deadline.timeIntervalSinceNow > 0 else { + throw ICloudMetadataIndexError.timedOut + } + + if let settledInterval, settledInterval >= settleInterval { + return records + } + while true { try Task.checkCancellation() let observedGeneration = generation let remaining = deadline.timeIntervalSinceNow guard remaining > 0 else { throw ICloudMetadataIndexError.timedOut } - try await Task.sleep(for: .seconds(min(settleInterval, remaining))) + let quietInterval = min(settleInterval, remaining) + try await settleSleep(quietInterval) + try Task.checkCancellation() guard generation == observedGeneration else { continue } + settledInterval = max(settledInterval ?? 0, quietInterval) return records } } @@ -333,14 +378,15 @@ final class ICloudMetadataIndex { private func apply(_ event: ICloudMetadataQueryEvent) { switch event { case let .finishedGathering(records): - self.records = records phase = .live + self.records = reconcileTombstones(with: records) resumeSnapshotWaiters() case let .updated(records): - self.records = records + self.records = reconcileTombstones(with: records) } generation &+= 1 + settledInterval = nil resumeMatchingItemWaiters() for observer in observers.values { @@ -348,6 +394,33 @@ final class ICloudMetadataIndex { } } + private func reconcileTombstones( + with records: [ICloudMetadataRecord] + ) -> [ICloudMetadataRecord] { + guard !deletionTombstones.isEmpty else { return records } + + let currentTime = now() + deletionTombstones = deletionTombstones.filter { _, recordedAt in + currentTime.timeIntervalSince(recordedAt) < deletionTombstoneMaxAge + } + + if case .live = phase { + deletionTombstones = deletionTombstones.filter { path, _ in + records.contains { record in Self.covers(path: path, record: record) } + } + } + + return records.filter { record in + !deletionTombstones.keys.contains { path in + Self.covers(path: path, record: record) + } + } + } + + private static func covers(path: String, record: ICloudMetadataRecord) -> Bool { + record.resolvedPath == path || record.resolvedPath.hasPrefix(path + "/") + } + private func waitForInitialSnapshot(timeout: TimeInterval) async throws -> [ICloudMetadataRecord] { let id = UUID() return try await withTaskCancellationHandler { @@ -476,6 +549,19 @@ final class SyncHealthObserver: @unchecked Sendable { } extension ICloudDriveHelper { + /// Records successful deletes in the index; the main-actor hop is not a cancellation point, + /// so a cancelled delete task still records its partial successes + func recordMetadataDeletions(of urls: [URL]) async { + let resolvedPaths = urls.map { Self.resolvedPath($0.path) } + guard !resolvedPaths.isEmpty else { return } + + await metadataIndexProvider().markDeleted(resolvedPaths: resolvedPaths) + } + + func clearMetadataDeletion(of url: URL) async { + await metadataIndexProvider().clearDeletion(resolvedPath: Self.resolvedPath(url.path)) + } + func makeSyncHealthObserver( onChange: @escaping @Sendable () -> Void ) -> SyncHealthObserver { diff --git a/ios/Cove/FFI/ICloudDriveHelper.swift b/ios/Cove/FFI/ICloudDriveHelper.swift index eabf66028..ccd53238b 100644 --- a/ios/Cove/FFI/ICloudDriveHelper.swift +++ b/ios/Cove/FFI/ICloudDriveHelper.swift @@ -67,6 +67,7 @@ final class ICloudDriveHelper: @unchecked Sendable { private let namespacesSubdirectory = csppNamespacesSubdirectory() private let walletsSubdirectory = csppWalletsDirectory() private let containerURLProvider: @Sendable () -> URL? + private let coordinatedDeleter: @Sendable (URL, String) throws -> URL let metadataIndexProvider: @MainActor @Sendable () -> ICloudMetadataIndex let defaultTimeout: TimeInterval let metadataListingTimeout: TimeInterval @@ -90,12 +91,16 @@ final class ICloudDriveHelper: @unchecked Sendable { metadataIndexProvider: @escaping @MainActor @Sendable () -> ICloudMetadataIndex = { ICloudMetadataIndex.shared }, + coordinatedDeleter: @escaping @Sendable (URL, String) throws -> URL = { url, missingItemID in + try ICloudDriveHelper.coordinatedDelete(at: url, missingItemID: missingItemID) + }, defaultTimeout: TimeInterval = 60, metadataListingTimeout: TimeInterval = 15, readAttemptTimeout: TimeInterval = 5 ) { self.containerURLProvider = containerURLProvider self.metadataIndexProvider = metadataIndexProvider + self.coordinatedDeleter = coordinatedDeleter self.defaultTimeout = defaultTimeout self.metadataListingTimeout = metadataListingTimeout self.readAttemptTimeout = readAttemptTimeout @@ -447,9 +452,10 @@ extension ICloudDriveHelper { Log.info("writeForUpload: validated local iCloud handoff for \(url.lastPathComponent)") } - func coordinatedDelete(at url: URL, missingItemID: String) throws { + static func coordinatedDelete(at url: URL, missingItemID: String) throws -> URL { var coordinatorError: NSError? var deleteError: Error? + var deletedURL: URL? let coordinator = NSFileCoordinator() coordinator.coordinate( @@ -457,6 +463,7 @@ extension ICloudDriveHelper { ) { newURL in do { try FileManager.default.removeItem(at: newURL) + deletedURL = newURL } catch { deleteError = error } @@ -468,6 +475,16 @@ extension ICloudDriveHelper { } throw Self.uploadError("delete failed", error: error) } + + guard let deletedURL else { + throw CloudStorageError.UploadFailed("coordinated delete produced no result") + } + + return deletedURL + } + + private func performCoordinatedDelete(at url: URL, missingItemID: String) throws -> URL { + try coordinatedDeleter(url, missingItemID) } private static func coordinatedRead( @@ -565,6 +582,16 @@ extension ICloudDriveHelper { } } + private func runFileOperation( + _ operation: @escaping @Sendable () throws -> Value + ) async throws -> Value { + try await withCheckedThrowingContinuation { continuation in + fileReadQueue.async { + continuation.resume(with: Result { try operation() }) + } + } + } + /// Reads a local backup directly or materializes a provider item with bounded coordination func downloadFile(target: ICloudBackupReadTarget, recordId: String) async throws -> Data { try await downloadData(target: target, recordId: recordId) @@ -1073,15 +1100,16 @@ extension ICloudDriveHelper { let urlsToDelete = (localURLs + metadataURLs).filter { url in seenPaths.insert(url.standardizedFileURL.path).inserted } - var deletedAny = false + var deletedURLs: [URL] = [] var lastError: Error? for url in urlsToDelete { - try Task.checkCancellation() + guard !Task.isCancelled else { break } do { - try coordinatedDelete(at: url, missingItemID: recordId) - deletedAny = true + let deletedURL = try performCoordinatedDelete(at: url, missingItemID: recordId) + deletedURLs.append(deletedURL) + deletedURLs.append(url) } catch CloudStorageError.NotFound { continue } catch { @@ -1089,10 +1117,39 @@ extension ICloudDriveHelper { } } + await recordMetadataDeletions(of: deletedURLs) + + try Task.checkCancellation() if let lastError { throw lastError } - guard deletedAny else { throw CloudStorageError.NotFound(recordId) } + guard !deletedURLs.isEmpty else { throw CloudStorageError.NotFound(recordId) } + } + + func deleteNamespaceDirectory(namespace: String) async throws { + let (requestedURL, isLocallyVisible) = try await runFileOperation { + let requestedURL = try self.namespaceDirectoryReadURL(namespace: namespace) + let isLocallyVisible = FileManager.default.fileExists(atPath: requestedURL.path) + return (requestedURL, isLocallyVisible) + } + let deleteURL: URL + + if isLocallyVisible { + deleteURL = requestedURL + } else { + let metadataItem = try await metadataItemIfPresent( + named: requestedURL.lastPathComponent, + parentDirectoryURL: requestedURL.deletingLastPathComponent() + ) + guard let metadataItem else { throw CloudStorageError.NotFound(namespace) } + + deleteURL = metadataItem.url + } + + let deletedURL = try await runFileOperation { + try self.performCoordinatedDelete(at: deleteURL, missingItemID: namespace) + } + await recordMetadataDeletions(of: [requestedURL, deleteURL, deletedURL]) } private func allBackupFiles(in namespaceDirectory: URL) -> [URL] { diff --git a/ios/Cove/FFI/PasskeyProviderImpl.swift b/ios/Cove/FFI/PasskeyProviderImpl.swift index 224a8b371..1fcd9e63b 100644 --- a/ios/Cove/FFI/PasskeyProviderImpl.swift +++ b/ios/Cove/FFI/PasskeyProviderImpl.swift @@ -1,5 +1,4 @@ import AuthenticationServices -import CryptoKit @_exported import CoveCore import Foundation @@ -30,6 +29,169 @@ enum PasskeyOperationContext: Equatable { .authenticateAssertion } } + + var requestMode: PasskeyRequestMode { + switch self { + case .registration: + .registration + case .discoverAssertion: + .discovery + case .authenticateAssertion: + .targeted + } + } +} + +enum PasskeyRequestMode: String, Equatable { + case registration + case discovery + case targeted + case presence +} + +final class PasskeyRequestDiagnostics: @unchecked Sendable { + let requestID = UUID() + let rpId: String + let requestMode: PasskeyRequestMode + + private let lock = NSLock() + private var nativeSubmissionTime: ContinuousClock.Instant? + private var presentationAnchorTime: ContinuousClock.Instant? + private var presentationAnchorAvailable: Bool? + private var presentationSceneActivation: String? + private var completionTime: ContinuousClock.Instant? + + init(rpId: String, requestMode: PasskeyRequestMode) { + self.rpId = rpId + self.requestMode = requestMode + } + + var presentationAnchorRequested: Bool { + lock.withLock { presentationAnchorTime != nil } + } + + func markNativeSubmission(at time: ContinuousClock.Instant = ContinuousClock.now) { + lock.withLock { + nativeSubmissionTime = time + } + } + + func logNativeSubmission() { + markNativeSubmission() + Log.info("[PASSKEY] native request submitted \(logFields())") + } + + func markPresentationAnchorRequest( + at time: ContinuousClock.Instant = ContinuousClock.now, + isAvailable: Bool? = nil, + sceneActivation: String? = nil + ) { + lock.withLock { + if presentationAnchorTime == nil { + presentationAnchorTime = time + presentationAnchorAvailable = isAvailable + presentationSceneActivation = sceneActivation + } + } + } + + func markCompletion(at time: ContinuousClock.Instant = ContinuousClock.now) { + lock.withLock { + if completionTime == nil { + completionTime = time + } + } + } + + func logFields() -> String { + lock.withLock { + let submissionToAnchor = durationMilliseconds( + from: nativeSubmissionTime, + to: presentationAnchorTime + ) + let submissionToCompletion = durationMilliseconds( + from: nativeSubmissionTime, + to: completionTime + ) + let anchorAvailable = presentationAnchorAvailable.map { String($0) } ?? "na" + let sceneActivation = presentationSceneActivation ?? "na" + + return "request_id=\(requestID.uuidString) " + + "rpId=\(rpId) " + + "operation=\(requestMode.rawValue) " + + "request_mode=\(requestMode.rawValue) " + + "submission_to_anchor_ms=\(submissionToAnchor) " + + "submission_to_completion_ms=\(submissionToCompletion) " + + "presentation_anchor_requested=\(presentationAnchorTime != nil) " + + "presentation_anchor_available=\(anchorAvailable) " + + "presentation_scene_activation=\(sceneActivation)" + } + } +} + +private func durationMilliseconds( + from start: ContinuousClock.Instant?, + to end: ContinuousClock.Instant? +) -> String { + guard let start, let end else { return "na" } + + let components = start.duration(to: end).components + let milliseconds = components.seconds * 1000 + + components.attoseconds / 1_000_000_000_000_000 + return String(milliseconds) +} + +/// Domain and code for the error and every underlying error, without localized text +/// +/// AuthenticationServices wraps the credential provider's failure in `NSUnderlyingErrorKey`, +/// and that inner code is what separates a user cancel from a system-side failure +func passkeyNSErrorMetadata(_ error: Error) -> String { + let nsError = error as NSError + var metadata = "error_domain=\(nsError.domain) error_code=\(nsError.code)" + + let underlying = passkeyUnderlyingErrors(of: nsError) + if !underlying.isEmpty { + let chain = underlying.map { "\($0.domain):\($0.code)" }.joined(separator: ",") + metadata += " underlying=\(chain)" + } + + return metadata +} + +private func passkeyUnderlyingErrors(of error: NSError) -> [NSError] { + var chain: [NSError] = [] + var visited = Set() + var pending = [error] + + while let current = pending.popLast(), chain.count < 8 { + guard visited.insert(ObjectIdentifier(current)).inserted else { continue } + + var next: [NSError] = [] + if let single = current.userInfo[NSUnderlyingErrorKey] as? NSError { + next.append(single) + } + if let multiple = current.userInfo[NSMultipleUnderlyingErrorsKey] as? [NSError] { + next.append(contentsOf: multiple) + } + + chain.append(contentsOf: next) + pending.append(contentsOf: next.reversed()) + } + + return chain +} + +/// Drop the last references to a finished request on the main thread +/// +/// The blocking caller otherwise releases the controller off the main thread while +/// AuthenticationServices is still unwinding its delegate callback for that controller +private func releasePasskeyRequestOnMain( + _ controller: ASAuthorizationController, + _ delegate: AnyObject +) { + DispatchQueue.main.async { + withExtendedLifetime((controller, delegate)) {} + } } func passkeyUnexpectedCredentialError( @@ -61,14 +223,6 @@ final class PasskeyProviderImpl: PasskeyProvider, @unchecked Sendable { } } - private func credentialSummary(_ credentialId: Data) -> String { - let fingerprint = SHA256.hash(data: credentialId) - .prefix(6) - .map { String(format: "%02x", $0) } - .joined() - return "len=\(credentialId.count) fingerprint=\(fingerprint)" - } - /// PRF is guaranteed on iOS 18.4+ (our minimum deployment target) func isPrfSupported() -> Bool { true @@ -126,10 +280,7 @@ final class PasskeyProviderImpl: PasskeyProvider, @unchecked Sendable { ) // passkey authorization requests can present iOS UI, so do not use this for background polling - let credentialSummary = credentialSummary(credentialId) - Log.info("[PASSKEY] presence check start rpId=\(rpId) credential=\(credentialSummary)") - - let delegate = PasskeyExistenceDelegate() + let delegate = PasskeyExistenceDelegate(rpId: rpId) let controller: ASAuthorizationController controller = DispatchQueue.main.sync { @@ -150,9 +301,11 @@ final class PasskeyProviderImpl: PasskeyProvider, @unchecked Sendable { let ctrl = ASAuthorizationController(authorizationRequests: [request]) ctrl.delegate = delegate ctrl.presentationContextProvider = delegate + delegate.diagnostics.logNativeSubmission() ctrl.performRequests(options: .preferImmediatelyAvailableCredentials) return ctrl } + defer { releasePasskeyRequestOnMain(controller, delegate) } // .notInteractive returns almost instantly when no credential exists. // if iOS doesn't respond quickly enough to prove presence or absence, @@ -160,15 +313,16 @@ final class PasskeyProviderImpl: PasskeyProvider, @unchecked Sendable { let gotResult = delegate.semaphore.wait(timeout: .now() + 1.0) if gotResult == .timedOut { + delegate.diagnostics.markCompletion() Log.warn( - "[PASSKEY] presence check timed out after 1s" + "[PASSKEY] \(delegate.diagnostics.logFields()) timed_out_after_s=1" ) DispatchQueue.main.async { controller.cancel() } return .indeterminate } Log.info( - "[PASSKEY] presence check resolved rpId=\(rpId) credential=\(credentialSummary) presence=\(delegate.presence)" + "[PASSKEY] \(delegate.diagnostics.logFields()) presence=\(delegate.presence)" ) return delegate.presence } @@ -199,11 +353,10 @@ final class PasskeyProviderImpl: PasskeyProvider, @unchecked Sendable { challenge: Data, user: PasskeyRegistrationUser ) throws -> ASAuthorizationPlatformPublicKeyCredentialRegistration { - let delegate = PasskeyDelegate(context: .registration) + let delegate = PasskeyDelegate(context: .registration, rpId: rpId) let controller: ASAuthorizationController controller = DispatchQueue.main.sync { - Log.info("[PASSKEY] registration request start rpId=\(rpId)") let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: rpId ) @@ -222,9 +375,11 @@ final class PasskeyProviderImpl: PasskeyProvider, @unchecked Sendable { let ctrl = ASAuthorizationController(authorizationRequests: [request]) ctrl.delegate = delegate ctrl.presentationContextProvider = delegate + delegate.diagnostics.logNativeSubmission() ctrl.performRequests() return ctrl } + defer { releasePasskeyRequestOnMain(controller, delegate) } let credential = try delegate.waitForResult { controller.cancel() @@ -237,7 +392,9 @@ final class PasskeyProviderImpl: PasskeyProvider, @unchecked Sendable { throw passkeyUnexpectedCredentialError(operation: .registration) } - Log.info("[PASSKEY] registration request succeeded credential_len=\(registration.credentialID.count)") + Log.info( + "[PASSKEY] registration request succeeded \(delegate.diagnostics.logFields())" + ) return registration } @@ -294,13 +451,10 @@ final class PasskeyProviderImpl: PasskeyProvider, @unchecked Sendable { challenge: Data, context: PasskeyOperationContext ) throws -> ASAuthorizationPlatformPublicKeyCredentialAssertion { - let delegate = PasskeyDelegate(context: context) + let delegate = PasskeyDelegate(context: context, rpId: rpId) let controller: ASAuthorizationController controller = DispatchQueue.main.sync { - Log.info( - "[PASSKEY] \(context.logDescription) request start rpId=\(rpId) targeted=\(credentialId != nil)" - ) let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: rpId ) @@ -326,9 +480,11 @@ final class PasskeyProviderImpl: PasskeyProvider, @unchecked Sendable { let ctrl = ASAuthorizationController(authorizationRequests: [request]) ctrl.delegate = delegate ctrl.presentationContextProvider = delegate + delegate.diagnostics.logNativeSubmission() ctrl.performRequests() return ctrl } + defer { releasePasskeyRequestOnMain(controller, delegate) } let credential = try delegate.waitForResult { controller.cancel() @@ -341,7 +497,9 @@ final class PasskeyProviderImpl: PasskeyProvider, @unchecked Sendable { throw passkeyUnexpectedCredentialError(operation: context.operation) } - Log.info("[PASSKEY] \(context.logDescription) request succeeded credential_len=\(assertion.credentialID.count)") + Log.info( + "[PASSKEY] \(context.logDescription) request succeeded \(delegate.diagnostics.logFields())" + ) return assertion } @@ -369,35 +527,90 @@ final class PasskeyProviderImpl: PasskeyProvider, @unchecked Sendable { // MARK: - PasskeyDelegate -private func passkeyPresentationAnchor() -> ASPresentationAnchor { +private struct PasskeyPresentationAnchorResolution { + let anchor: ASPresentationAnchor + let isAvailable: Bool + let sceneActivation: String +} + +private func passkeySceneActivationName(_ state: UIScene.ActivationState?) -> String { + guard let state else { return "none" } + + switch state { + case .foregroundActive: + return "foregroundActive" + case .foregroundInactive: + return "foregroundInactive" + case .background: + return "background" + case .unattached: + return "unattached" + @unknown default: + return "unknown" + } +} + +private func passkeyPresentationAnchor() -> PasskeyPresentationAnchorResolution { let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } let activeScene = scenes.first { $0.activationState == .foregroundActive } let foregroundScene = activeScene ?? scenes.first { $0.activationState == .foregroundInactive } + func resolution(for window: UIWindow?, scene: UIWindowScene?) -> PasskeyPresentationAnchorResolution { + guard let window else { + return PasskeyPresentationAnchorResolution( + anchor: ASPresentationAnchor(), + isAvailable: false, + sceneActivation: passkeySceneActivationName( + scene?.activationState ?? foregroundScene?.activationState + ) + ) + } + + return PasskeyPresentationAnchorResolution( + anchor: window, + isAvailable: true, + sceneActivation: passkeySceneActivationName( + window.windowScene?.activationState ?? scene?.activationState + ) + ) + } + if let window = foregroundScene?.windows.first(where: \.isKeyWindow) { - return window + return resolution(for: window, scene: foregroundScene) } if let window = foregroundScene?.windows.first(where: { !$0.isHidden && $0.windowLevel == .normal }) { - return window + return resolution(for: window, scene: foregroundScene) } for scene in scenes { if let window = scene.windows.first(where: \.isKeyWindow) { - return window + return resolution(for: window, scene: scene) } if let window = scene.windows.first(where: { !$0.isHidden && $0.windowLevel == .normal }) { - return window + return resolution(for: window, scene: scene) } } - Log.warn("[PASSKEY] no foreground presentation anchor found") - return ASPresentationAnchor() + return resolution(for: nil, scene: foregroundScene) +} + +private func passkeyPresentationAnchor( + recordingIn diagnostics: PasskeyRequestDiagnostics +) -> ASPresentationAnchor { + let resolution = passkeyPresentationAnchor() + diagnostics.markPresentationAnchorRequest( + isAvailable: resolution.isAvailable, + sceneActivation: resolution.sceneActivation + ) + Log.info("[PASSKEY] \(diagnostics.logFields())") + + return resolution.anchor } final class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, @@ -410,14 +623,16 @@ final class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, private let timeout: TimeInterval private var result: Result? private let context: PasskeyOperationContext - private var didRequestPresentationAnchor = false + let diagnostics: PasskeyRequestDiagnostics init( context: PasskeyOperationContext, + rpId: String = "unknown", timeout: TimeInterval = PasskeyDelegate.interactiveRequestTimeout ) { self.context = context self.timeout = timeout + diagnostics = PasskeyRequestDiagnostics(rpId: rpId, requestMode: context.requestMode) } func waitForResult( @@ -432,8 +647,9 @@ final class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, ) if complete(with: .failure(timeoutError)) { + diagnostics.markCompletion() Log.warn( - "[PASSKEY] \(context.logDescription) timed out after \(timeout)s" + "[PASSKEY] \(diagnostics.logFields()) timed_out_after_s=\(timeout)" ) DispatchQueue.main.async(execute: cancelController) } @@ -449,43 +665,45 @@ final class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, } func presentationAnchor(for _: ASAuthorizationController) -> ASPresentationAnchor { - lock.withLock { - didRequestPresentationAnchor = true - } - - return passkeyPresentationAnchor() + passkeyPresentationAnchor(recordingIn: diagnostics) } func authorizationController( controller _: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization ) { - if complete(with: .success(authorization.credential)) { - Log.info("[PASSKEY] \(context.logDescription) completed credential_type=\(type(of: authorization.credential))") + diagnostics.markCompletion() + guard complete(with: .success(authorization.credential)) else { + Log.info("[PASSKEY] \(diagnostics.logFields()) late_callback") + return } + + Log.info( + "[PASSKEY] \(diagnostics.logFields()) completed" + ) } func authorizationController( controller _: ASAuthorizationController, didCompleteWithError error: Error ) { - let didRequestPresentationAnchor = lock.withLock { - self.didRequestPresentationAnchor - } + diagnostics.markCompletion() + let presentationAnchorRequested = diagnostics.presentationAnchorRequested let result: Result let logMessage: String let shouldWarn: Bool + let errorMetadata = passkeyNSErrorMetadata(error) switch error as? ASAuthorizationError { case let authError?: switch passkeyAuthorizationFailure( for: authError.code, - didRequestPresentationAnchor: didRequestPresentationAnchor, - diagnosticMessage: error.localizedDescription + didRequestPresentationAnchor: presentationAnchorRequested, + diagnosticMessage: errorMetadata ) { case .userCancelled: result = .failure(PasskeyError.UserCancelled) - logMessage = "[PASSKEY] \(context.logDescription) cancelled code=\(authError.code.rawValue) description=\(error.localizedDescription)" + logMessage = "[PASSKEY] \(diagnostics.logFields()) cancelled \(errorMetadata)" shouldWarn = false case let .requestFailed(reason): result = .failure( @@ -494,21 +712,24 @@ final class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate, reason: reason ) ) - logMessage = "[PASSKEY] \(context.logDescription) failed code=\(authError.code.rawValue) requested_ui=\(didRequestPresentationAnchor) description=\(error.localizedDescription)" + logMessage = "[PASSKEY] \(diagnostics.logFields()) failed \(errorMetadata)" shouldWarn = true } case nil: result = .failure( PasskeyError.RequestFailed( operation: context.operation, - reason: .unknown(diagnosticMessage: error.localizedDescription) + reason: .unknown(diagnosticMessage: errorMetadata) ) ) - logMessage = "[PASSKEY] \(context.logDescription) failed with non-auth error: \(error.localizedDescription)" + logMessage = "[PASSKEY] \(diagnostics.logFields()) failed_non_auth \(errorMetadata)" shouldWarn = true } - guard complete(with: result) else { return } + guard complete(with: result) else { + Log.info("[PASSKEY] \(diagnostics.logFields()) late_callback \(errorMetadata)") + return + } if shouldWarn { Log.warn(logMessage) @@ -591,19 +812,23 @@ private class PasskeyExistenceDelegate: NSObject, ASAuthorizationControllerDeleg { let semaphore = DispatchSemaphore(value: 0) var presence: PasskeyCredentialPresence = .indeterminate - private var didRequestPresentationAnchor = false + let diagnostics: PasskeyRequestDiagnostics + + init(rpId: String) { + diagnostics = PasskeyRequestDiagnostics(rpId: rpId, requestMode: .presence) + } func presentationAnchor(for _: ASAuthorizationController) -> ASPresentationAnchor { - didRequestPresentationAnchor = true - return passkeyPresentationAnchor() + passkeyPresentationAnchor(recordingIn: diagnostics) } func authorizationController( controller _: ASAuthorizationController, didCompleteWithAuthorization _: ASAuthorization ) { + diagnostics.markCompletion() presence = .present - Log.info("[PASSKEY] presence check authorization succeeded") + Log.info("[PASSKEY] \(diagnostics.logFields()) presence=\(presence)") semaphore.signal() } @@ -611,24 +836,30 @@ private class PasskeyExistenceDelegate: NSObject, ASAuthorizationControllerDeleg controller _: ASAuthorizationController, didCompleteWithError error: Error ) { + diagnostics.markCompletion() + let presentationAnchorRequested = diagnostics.presentationAnchorRequested + let errorMetadata = passkeyNSErrorMetadata(error) + if let authError = error as? ASAuthorizationError { if authError.code == .notInteractive { presence = .missing Log.info( - "[PASSKEY] presence check classified missing code=\(authError.code.rawValue) requested_ui=\(didRequestPresentationAnchor) description=\(error.localizedDescription)" + "[PASSKEY] \(diagnostics.logFields()) classified=missing \(errorMetadata)" ) - } else if authError.code == .canceled, !didRequestPresentationAnchor { + } else if authError.code == .canceled, !presentationAnchorRequested { presence = .missing Log.info( - "[PASSKEY] presence check classified missing after silent cancellation code=\(authError.code.rawValue) requested_ui=\(didRequestPresentationAnchor) description=\(error.localizedDescription)" + "[PASSKEY] \(diagnostics.logFields()) classified=missing_after_silent_cancellation \(errorMetadata)" ) } else { Log.warn( - "[PASSKEY] presence check failed with auth error code=\(authError.code.rawValue) requested_ui=\(didRequestPresentationAnchor) description=\(error.localizedDescription)" + "[PASSKEY] \(diagnostics.logFields()) failed \(errorMetadata)" ) } } else { - Log.warn("[PASSKEY] presence check failed with non-auth error: \(error.localizedDescription)") + Log.warn( + "[PASSKEY] \(diagnostics.logFields()) failed_non_auth \(errorMetadata)" + ) } semaphore.signal() } diff --git a/ios/Cove/Flows/NewWalletFlow/PendingWalletViewModel.swift b/ios/Cove/Flows/NewWalletFlow/PendingWalletViewModel.swift index 8f9b4236b..25669cc41 100644 --- a/ios/Cove/Flows/NewWalletFlow/PendingWalletViewModel.swift +++ b/ios/Cove/Flows/NewWalletFlow/PendingWalletViewModel.swift @@ -7,7 +7,11 @@ import SwiftUI -@Observable final class PendingWalletManager: PendingWalletManagerReconciler { +extension WeakReconciler: PendingWalletManagerReconciler where Reconciler == PendingWalletManager {} + +@Observable final class PendingWalletManager: ReconcilingManager, PendingWalletManagerReconciler { + typealias Message = PendingWalletManagerReconcileMessage + private let logger = Log(id: "PendingWalletManager") var rust: RustPendingWalletManager var numberOfWords: NumberOfBip39Words @@ -19,22 +23,26 @@ import SwiftUI self.numberOfWords = numberOfWords bip39Words = rust.bip39Words() - self.rust.listenForUpdates(reconciler: self) + // a strong reconciler would keep every discarded manager and its mnemonic alive + self.rust.listenForUpdates(reconciler: WeakReconciler(self)) } - func reconcile(message: PendingWalletManagerReconcileMessage) { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - logger.debug("Reconcile: \(message)") - - switch message { - case let .words(numberOfBip39Words): - numberOfWords = numberOfBip39Words - bip39Words = rust.bip39Words() - } + func apply(_ message: Message) { + switch message { + case let .words(numberOfBip39Words): + numberOfWords = numberOfBip39Words + bip39Words = rust.bip39Words() } } + func logReconcile(message: Message) { + logger.debug("Reconcile: \(message)") + } + + func logReconcileMany(messages: [Message]) { + messages.forEach(logReconcile) + } + public func dispatch(action: PendingWalletManagerAction) { rust.dispatch(action: action) } diff --git a/ios/Cove/Flows/SelectedWalletFlow/ChooseWalletTypeView.swift b/ios/Cove/Flows/SelectedWalletFlow/ChooseWalletTypeView.swift index 3ea1b532a..07c834cba 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/ChooseWalletTypeView.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/ChooseWalletTypeView.swift @@ -10,8 +10,8 @@ import SwiftUI public struct ChooseWalletTypeView: View { @Environment(\.dismiss) private var dismiss - @State var manager: WalletManager - @State var foundAddresses: [FoundAddress] + let manager: WalletManager + let foundAddresses: [FoundAddress] /// private /// first native segwit address diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletAlerts.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletAlerts.swift index 4d6165487..498b1d345 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletAlerts.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletAlerts.swift @@ -3,7 +3,7 @@ import SwiftUI struct SelectedWalletPresentationContext { let app: AppManager let manager: WalletManager - let presentationState: Binding?> + let presentReceive: () -> Void let walletErrorAlert: Binding?> let scannedLabels: Binding?> @@ -38,7 +38,7 @@ extension WalletErrorAlert: TaggedAlertPresentable { actions: { Button("Receive Funds") { context.dismissWalletError() - context.presentationState.wrappedValue = TaggedItem(.receive) + context.presentReceive() } Button("Cancel", role: .cancel) { diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift index 96a99112d..d1536bfde 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift @@ -17,6 +17,8 @@ enum SelectedWalletPresentationState: Equatable { case labelsQrExport case exportXpubConfirmation case xpubQrExport + case labelsShare + case xpubShare } struct SelectedWalletScreen: View { @@ -36,7 +38,9 @@ struct SelectedWalletScreen: View { /// public var manager: WalletManager - @State private var presentationState: TaggedItem? = nil + /// sheets, dialogs, and share handoffs must wait for the previous prompt to dismiss + @State private var presentationCoordinator = + PresentationTransitionCoordinator() @State private var shouldShowNavBar = false @State private var cloudBackupManager = CloudBackupManager.shared @@ -123,7 +127,7 @@ struct SelectedWalletScreen: View { SelectedWalletPresentationContext( app: app, manager: manager, - presentationState: $presentationState, + presentReceive: showReceiveSheet, walletErrorAlert: Binding( get: { manager.errorAlert }, set: { manager.errorAlert = $0 } @@ -133,44 +137,19 @@ struct SelectedWalletScreen: View { } private var sheetPresentationState: Binding?> { - Binding( - get: { - guard let presentationState, presentationState.item.isSheet else { return nil } - return presentationState - }, - set: { newValue in - if let newValue { - presentationState = newValue - } else if presentationState?.item.isSheet == true { - presentationState = nil - } - } - ) + presentationCoordinator.presentedItem(where: \.isSheet) } private var labelsFileImportIsPresented: Binding { - isPresenting(.labelsFileImport) + presentationCoordinator.isPresented { $0 == .labelsFileImport } } private var exportLabelsConfirmationIsPresented: Binding { - isPresenting(.exportLabelsConfirmation) + presentationCoordinator.isPresented { $0 == .exportLabelsConfirmation } } private var exportXpubConfirmationIsPresented: Binding { - isPresenting(.exportXpubConfirmation) - } - - private func isPresenting(_ state: SelectedWalletPresentationState) -> Binding { - Binding( - get: { presentationState?.item == state }, - set: { isPresented in - if isPresented { - presentationState = TaggedItem(state) - } else if presentationState?.item == state { - presentationState = nil - } - } - ) + presentationCoordinator.isPresented { $0 == .exportXpubConfirmation } } private func setSheetState(_ discoveryState: DiscoveryState) { @@ -178,25 +157,41 @@ struct SelectedWalletScreen: View { switch discoveryState { case let .foundAddressesFromMnemonic(foundAddresses): - presentationState = TaggedItem(.chooseAddressType(foundAddresses)) + presentationCoordinator.present(.chooseAddressType(foundAddresses)) case let .foundAddressesFromXprv(foundAddresses): - presentationState = TaggedItem(.chooseAddressType(foundAddresses)) + presentationCoordinator.present(.chooseAddressType(foundAddresses)) case let .foundAddressesFromJson(foundAddress, _): - presentationState = TaggedItem(.chooseAddressType(foundAddress)) + presentationCoordinator.present(.chooseAddressType(foundAddress)) default: () } } func showReceiveSheet() { - presentationState = TaggedItem(.receive) + presentationCoordinator.present(.receive) + } + + private func present(_ state: SelectedWalletPresentationState) { + presentationCoordinator.present(state) } - func showQrExport() { - presentationState = TaggedItem(.labelsQrExport) + /// Dialog buttons run while the dialog is still dismissing, so the next + /// presentation is queued until the presenter is free again + private func transition(to state: SelectedWalletPresentationState) { + presentationCoordinator.transition(to: state) } - func presentXpubQrExport() { - presentationState = TaggedItem(.xpubQrExport) + private func performCurrentPresentationAction() { + guard let presentation = presentationCoordinator.currentPresentation?.item else { return } + + switch presentation { + case .receive, .chooseAddressType, .qrLabelsImport, .labelsFileImport, + .exportLabelsConfirmation, .labelsQrExport, .exportXpubConfirmation, .xpubQrExport: + return + case .labelsShare: + shareLabelsFile() + case .xpubShare: + shareXpubFile() + } } private func showRenameFromTitleMenu() { @@ -214,8 +209,10 @@ struct SelectedWalletScreen: View { } } - func shareXpubFile() { + private func shareXpubFile() { Task { + defer { presentationCoordinator.discard { $0 == .xpubShare } } + do { let result = try await manager.exportXpubForShare() ShareSheet.present(data: result.content, filename: result.filename) { success in @@ -235,8 +232,10 @@ struct SelectedWalletScreen: View { } } - func shareLabelsFile() { + private func shareLabelsFile() { Task { + defer { presentationCoordinator.discard { $0 == .labelsShare } } + do { let result = try await manager.exportLabelsForShare() ShareSheet.present(data: result.content, filename: result.filename) { success in @@ -336,7 +335,7 @@ struct SelectedWalletScreen: View { shouldShowNavBar: shouldShowNavBar, reduceTransparency: reduceTransparency, toolbarTextColor: toolbarTextColor, - presentationState: $presentationState, + present: present, sheetPresentationState: sheetPresentationState, labelsFileImportIsPresented: labelsFileImportIsPresented, exportLabelsConfirmationIsPresented: exportLabelsConfirmationIsPresented, @@ -349,10 +348,10 @@ struct SelectedWalletScreen: View { changeName: showRenameFromTitleMenu, importLabelsFile: importLabelsFile, scannedLabelsChanged: onChangeOfScannedLabels, - showLabelsQrExport: showQrExport, - shareLabelsFile: shareLabelsFile, - showXpubQrExport: presentXpubQrExport, - shareXpubFile: shareXpubFile + showLabelsQrExport: { transition(to: .labelsQrExport) }, + shareLabelsFile: { transition(to: .labelsShare) }, + showXpubQrExport: { transition(to: .xpubQrExport) }, + shareXpubFile: { transition(to: .xpubShare) } ), refresh: beginRefresh, performPostRefresh: performPostRefresh, @@ -361,6 +360,10 @@ struct SelectedWalletScreen: View { scrollToTransaction: handleScrollToTransaction ) .modifier(OuterBackgroundModifier(iOS26OrLater: iOS26OrLater)) + .presentationTransitionHost(presentationCoordinator) + .onChange(of: presentationCoordinator.currentPresentation?.id, initial: true) { _, _ in + performCurrentPresentationAction() + } .onChange(of: manager.walletMetadata.discoveryState, discoveryStateChanged) .onAppear(perform: initializePresentation) .onAppear(perform: ensureWalletIsSelected) @@ -470,7 +473,7 @@ private struct SelectedWalletPresentedContent: View { let shouldShowNavBar: Bool let reduceTransparency: Bool let toolbarTextColor: Color - @Binding var presentationState: TaggedItem? + let present: (SelectedWalletPresentationState) -> Void let sheetPresentationState: Binding?> let labelsFileImportIsPresented: Binding let exportLabelsConfirmationIsPresented: Binding @@ -504,7 +507,7 @@ private struct SelectedWalletPresentedContent: View { SelectedWalletToolbar( manager: manager, shouldShowNavBar: shouldShowNavBar, - presentationState: $presentationState, + present: present, exportLabelsConfirmationIsPresented: exportLabelsConfirmationIsPresented, exportXpubConfirmationIsPresented: exportXpubConfirmationIsPresented, showLabelsQrExport: showLabelsQrExport, @@ -539,7 +542,7 @@ private struct SelectedWalletToolbar: ToolbarContent { let manager: WalletManager let shouldShowNavBar: Bool - @Binding var presentationState: TaggedItem? + let present: (SelectedWalletPresentationState) -> Void let exportLabelsConfirmationIsPresented: Binding let exportXpubConfirmationIsPresented: Binding let showLabelsQrExport: () -> Void @@ -594,15 +597,15 @@ private struct SelectedWalletToolbar: ToolbarContent { } private func showLabelsFileImport() { - presentationState = TaggedItem(.labelsFileImport) + present(.labelsFileImport) } private func showLabelsExportConfirmation() { - presentationState = TaggedItem(.exportLabelsConfirmation) + present(.exportLabelsConfirmation) } private func showXpubExportConfirmation() { - presentationState = TaggedItem(.exportXpubConfirmation) + present(.exportXpubConfirmation) } } @@ -702,7 +705,8 @@ extension SelectedWalletPresentationState { switch self { case .receive, .chooseAddressType, .qrLabelsImport, .labelsQrExport, .xpubQrExport: true - case .labelsFileImport, .exportLabelsConfirmation, .exportXpubConfirmation: + case .labelsFileImport, .exportLabelsConfirmation, .exportXpubConfirmation, + .labelsShare, .xpubShare: false } } diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletSheets.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletSheets.swift index 31bc11fb2..847bb45e9 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletSheets.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletSheets.swift @@ -27,7 +27,8 @@ private struct SelectedWalletSheetContent: View { case .xpubQrExport: XpubQrExportSheet(manager: context.manager) - case .labelsFileImport, .exportLabelsConfirmation, .exportXpubConfirmation: + case .labelsFileImport, .exportLabelsConfirmation, .exportXpubConfirmation, + .labelsShare, .xpubShare: EmptyView() } } diff --git a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailCloudOnlySection.swift b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailCloudOnlySection.swift index 4e867ae8c..584d00a52 100644 --- a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailCloudOnlySection.swift +++ b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailCloudOnlySection.swift @@ -76,7 +76,7 @@ func cloudBackupRestoreAllPresentation( struct CloudOnlySection: View { let wallets: [CloudBackupWalletItem] let manager: CloudBackupManager - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter private var isOperating: Bool { manager.cloudOnlyOperation.operatingRecordId != nil @@ -87,7 +87,7 @@ struct CloudOnlySection: View { wallets: wallets, manager: manager, isOperating: isOperating, - presentationCoordinator: presentationCoordinator + presenter: presenter ) } } @@ -96,7 +96,7 @@ private struct CloudOnlyFormSection: View { let wallets: [CloudBackupWalletItem] let manager: CloudBackupManager let isOperating: Bool - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter var body: some View { Section(header: Text("Not on This Device")) { @@ -104,7 +104,7 @@ private struct CloudOnlyFormSection: View { wallets: wallets, manager: manager, isOperating: isOperating, - presentationCoordinator: presentationCoordinator + presenter: presenter ) } } @@ -114,7 +114,7 @@ private struct CloudOnlySectionContent: View { let wallets: [CloudBackupWalletItem] let manager: CloudBackupManager let isOperating: Bool - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter var body: some View { CloudOnlyRestoreAllControl(manager: manager) @@ -125,7 +125,7 @@ private struct CloudOnlySectionContent: View { operatingRecordId: manager.cloudOnlyOperation.operatingRecordId, isOperating: isOperating || manager.restoreAllState.isRunning || !manager.isDetailInventoryReady, - presentationCoordinator: presentationCoordinator, + presenter: presenter, onRetryWallet: { item in manager.dispatch(action: .restoreCloudWallet(item.recordId)) } @@ -285,7 +285,7 @@ private struct CloudOnlyWalletRows: View { let wallets: [CloudBackupWalletItem] let operatingRecordId: String? let isOperating: Bool - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter let onRetryWallet: (CloudBackupWalletItem) -> Void var body: some View { @@ -293,9 +293,10 @@ private struct CloudOnlyWalletRows: View { VStack(alignment: .leading, spacing: 8) { CloudOnlyWalletActionButton( item: item, + manager: manager, isOperating: isOperating, isCurrentOperation: operatingRecordId == item.recordId, - presentationCoordinator: presentationCoordinator + presenter: presenter ) if item.restoreFailure != nil { @@ -316,13 +317,14 @@ private struct CloudOnlyWalletRows: View { private struct CloudOnlyWalletActionButton: View { let item: CloudBackupWalletItem + let manager: CloudBackupManager let isOperating: Bool let isCurrentOperation: Bool - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter var body: some View { Button { - presentationCoordinator.present(.dialog(.cloudOnlyWalletActions(item))) + presenter.transitions.present(.cloudOnlyWalletDialog(item)) } label: { CloudOnlyWalletActionLabel( item: item, @@ -332,6 +334,11 @@ private struct CloudOnlyWalletActionButton: View { .buttonStyle(.plain) .foregroundStyle(.primary) .disabled(isOperating) + .cloudOnlyWalletActionDialog( + wallet: item, + manager: manager, + presenter: presenter + ) } } diff --git a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailContent.swift b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailContent.swift index 11004e7c1..7eb96097a 100644 --- a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailContent.swift +++ b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailContent.swift @@ -7,7 +7,7 @@ struct CloudBackupDetailForm: View { let isUnsupportedPasskeyProvider: Bool let shouldShowLoadingState: Bool let progressPresentation: CloudBackupDetailProgressPresentation - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter let recreateConfirmationIsPresented: Binding let reinitializeConfirmationIsPresented: Binding @@ -20,7 +20,7 @@ struct CloudBackupDetailForm: View { isUnsupportedPasskeyProvider: isUnsupportedPasskeyProvider, shouldShowLoadingState: shouldShowLoadingState, progressPresentation: progressPresentation, - presentationCoordinator: presentationCoordinator, + presenter: presenter, recreateConfirmationIsPresented: recreateConfirmationIsPresented, reinitializeConfirmationIsPresented: reinitializeConfirmationIsPresented ) @@ -35,7 +35,7 @@ private struct CloudBackupDetailFormContent: View { let isUnsupportedPasskeyProvider: Bool let shouldShowLoadingState: Bool let progressPresentation: CloudBackupDetailProgressPresentation - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter let recreateConfirmationIsPresented: Binding let reinitializeConfirmationIsPresented: Binding @@ -49,7 +49,7 @@ private struct CloudBackupDetailFormContent: View { DisableCloudBackupSection( manager: manager, detail: manager.detail, - presentationCoordinator: presentationCoordinator + presenter: presenter ) } } else { @@ -60,11 +60,11 @@ private struct CloudBackupDetailFormContent: View { isCancelled: isCancelled, shouldShowLoadingState: shouldShowLoadingState, progressPresentation: progressPresentation, - presentationCoordinator: presentationCoordinator + presenter: presenter ) VerificationSection( manager: manager, - presentationCoordinator: presentationCoordinator, + presenter: presenter, recreateConfirmationIsPresented: recreateConfirmationIsPresented, reinitializeConfirmationIsPresented: reinitializeConfirmationIsPresented ) @@ -72,7 +72,7 @@ private struct CloudBackupDetailFormContent: View { DisableCloudBackupSection( manager: manager, detail: manager.detail, - presentationCoordinator: presentationCoordinator + presenter: presenter ) } } @@ -84,7 +84,7 @@ private struct CloudBackupStatusSection: View { let isCancelled: Bool let shouldShowLoadingState: Bool let progressPresentation: CloudBackupDetailProgressPresentation - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter @AccessibilityFocusState private var inventoryErrorFocused: Bool @@ -111,7 +111,7 @@ private struct CloudBackupStatusSection: View { isCancelled: isCancelled, shouldShowLoadingState: shouldShowLoadingState, progressPresentation: progressPresentation, - presentationCoordinator: presentationCoordinator + presenter: presenter ) } .onChange(of: manager.detailError, initial: true) { _, error in @@ -169,7 +169,7 @@ private struct CloudBackupDetailStatusContent: View { let isCancelled: Bool let shouldShowLoadingState: Bool let progressPresentation: CloudBackupDetailProgressPresentation - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter var body: some View { if progressPresentation == .verificationCard { @@ -179,7 +179,7 @@ private struct CloudBackupDetailStatusContent: View { detail: detail, syncHealth: manager.syncHealth, manager: manager, - presentationCoordinator: presentationCoordinator + presenter: presenter ) } else if shouldShowLoadingState, manager.detailError == nil { CloudBackupLoadingSection() diff --git a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailOtherBackupsSection.swift b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailOtherBackupsSection.swift index 8b099e442..9e22cc34e 100644 --- a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailOtherBackupsSection.swift +++ b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailOtherBackupsSection.swift @@ -3,7 +3,7 @@ import SwiftUI struct OtherBackupsSection: View { let summary: CloudBackupOtherBackupsSummary let manager: CloudBackupManager - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter private var isRecovering: Bool { if case .recovering = manager.otherBackupsOperation { return true } @@ -61,13 +61,13 @@ struct OtherBackupsSection: View { private func requestRecovery() { guard manager.isOtherBackupsInventoryReady else { return } - presentationCoordinator.present(.dialog(.recoverOtherBackups)) + presenter.transitions.present(.dialog(.recoverOtherBackups)) } private func requestDeletion() { guard manager.isOtherBackupsInventoryReady else { return } - presentationCoordinator.present(.alert(.otherBackupsDeleteConfirmation)) + presenter.transitions.present(.alert(.otherBackupsDeleteConfirmation)) } } @@ -132,7 +132,7 @@ private struct OtherBackupsOperationLabel: View { } } -struct OtherBackupsRecoveryResult { +struct OtherBackupsRecoveryResult: Equatable { let walletsRestored: UInt32 let walletsFailed: UInt32 let failedWalletErrors: [String] diff --git a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailPresentation.swift b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailPresentation.swift index 1902778e3..556a458f1 100644 --- a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailPresentation.swift +++ b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailPresentation.swift @@ -5,14 +5,13 @@ enum CloudBackupDestructiveConfirmation: Equatable { case reinitialize } -enum CloudBackupDetailDialog { +enum CloudBackupDetailDialog: Equatable { case destructive(CloudBackupDestructiveConfirmation) - case cloudOnlyWalletActions(CloudBackupWalletItem) case disableCloudBackup case recoverOtherBackups } -enum CloudBackupDetailAlert { +enum CloudBackupDetailAlert: Equatable { case cloudOnlyDeleteWallet(CloudBackupWalletItem) case cloudOnlyUnsupportedRestore(CloudBackupWalletItem) case undecryptableWalletDeletion(UInt32) @@ -23,26 +22,171 @@ enum CloudBackupDetailAlert { case otherBackupsFinalDeleteConfirmation } -enum CloudBackupDetailPresentation { +enum CloudBackupDetailPresentation: Equatable { case dialog(CloudBackupDetailDialog) + case cloudOnlyWalletDialog(CloudBackupWalletItem) case alert(CloudBackupDetailAlert) } +/// Work a dialog or alert button requests that must wait for that prompt to finish dismissing +/// +/// Most of these reach a passkey prompt; requesting it during the dismissal animation cancels +/// the assertion silently and misreports the passkey as missing +enum CloudBackupDetailDeferredAction: Equatable { + case dispatch(CloudBackupManagerAction) + case startVerification + + func perform(on manager: CloudBackupManager) { + switch self { + case let .dispatch(action): + manager.dispatch(action: action) + case .startVerification: + manager.startVerification(source: .cloudBackupDetail) + } + } +} + +/// Owns the detail screen's prompt transitions and the action staged behind a dismissal +@MainActor +@Observable +final class CloudBackupDetailPresenter { + let transitions = PresentationTransitionCoordinator() + + @ObservationIgnored + private let handoff = + PresentationActionHandoff() + @ObservationIgnored + private let dispatch: (CloudBackupDetailDeferredAction) -> Void + + init(dispatch: @escaping (CloudBackupDetailDeferredAction) -> Void) { + self.dispatch = dispatch + } + + /// Dismiss `presentation` and run `action` once UIKit reports the presenter free again + func dismiss( + _ presentation: CloudBackupDetailPresentation, + then action: CloudBackupDetailDeferredAction + ) { + guard handoff.pendingAction == nil else { return } + + // SwiftUI may clear the prompt binding before the button action runs, so reuse that dismissal + let transition = transitions.dismissCurrentPresentationForTransition() + ?? transitions.transitionRequest + + guard let transition else { + dispatch(action) + return + } + + handoff.stage(action: action, presentation: presentation, transition: transition) + } + + func presenterDidBecomeReady(_ requestID: UUID) { + handoff.presenterDidBecomeReady( + requestID, + currentPresentation: handoff.pendingPresentation, + isHostAvailable: true, + using: transitions, + dispatch: dispatch + ) + } + + func hostDidDisappear() { + handoff.hostDidDisappear(using: transitions) + } +} + extension View { func cloudBackupDetailPresentations( manager: CloudBackupManager, - coordinator: PresentationTransitionCoordinator + presenter: CloudBackupDetailPresenter ) -> some View { modifier(CloudBackupDetailPresentationModifier( manager: manager, - coordinator: coordinator + presenter: presenter + )) + } + + func cloudOnlyWalletActionDialog( + wallet: CloudBackupWalletItem, + manager: CloudBackupManager, + presenter: CloudBackupDetailPresenter + ) -> some View { + modifier(CloudOnlyWalletActionDialogModifier( + wallet: wallet, + manager: manager, + presenter: presenter )) } } +private struct CloudOnlyWalletActionDialogModifier: ViewModifier { + let wallet: CloudBackupWalletItem + let manager: CloudBackupManager + let presenter: CloudBackupDetailPresenter + + private var coordinator: PresentationTransitionCoordinator { + presenter.transitions + } + + private var isPresented: Binding { + coordinator.isPresented { presentation in + guard case let .cloudOnlyWalletDialog(currentWallet) = presentation else { + return false + } + + return currentWallet.recordId == wallet.recordId + } + } + + func body(content: Content) -> some View { + content.confirmationDialog( + wallet.name, + isPresented: isPresented, + titleVisibility: .visible + ) { + Button("Restore to This Device") { + restoreWallet() + } + .disabled(!manager.isDetailInventoryReady) + + Button("Delete from iCloud", role: .destructive) { + requestDeletion() + } + .disabled(!manager.isDetailInventoryReady) + + Button("Cancel", role: .cancel) {} + } + } + + private func restoreWallet() { + guard manager.isDetailInventoryReady else { return } + + if wallet.syncStatus == .unsupportedVersion { + coordinator.transition(to: .alert(.cloudOnlyUnsupportedRestore(wallet))) + return + } + + presenter.dismiss( + .cloudOnlyWalletDialog(wallet), + then: .dispatch(.restoreCloudWallet(wallet.recordId)) + ) + } + + private func requestDeletion() { + guard manager.isDetailInventoryReady else { return } + + coordinator.transition(to: .alert(.cloudOnlyDeleteWallet(wallet))) + } +} + private struct CloudBackupDetailPresentationModifier: ViewModifier { let manager: CloudBackupManager - let coordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter + + private var coordinator: PresentationTransitionCoordinator { + presenter.transitions + } private var dialog: CloudBackupDetailDialog? { guard case let .dialog(dialog) = coordinator.currentPresentation?.item else { @@ -106,8 +250,6 @@ private struct CloudBackupDetailPresentationModifier: ViewModifier { case .recreate: "Recreate Backup Index" case .reinitialize: "Reinitialize Cloud Backup" } - case let .cloudOnlyWalletActions(wallet): - wallet.name case .disableCloudBackup: "Disable Cloud Backup?" case .recoverOtherBackups: @@ -126,19 +268,6 @@ private struct CloudBackupDetailPresentationModifier: ViewModifier { Button("Cancel", role: .cancel) {} - case let .cloudOnlyWalletActions(wallet): - Button("Restore to This Device") { - restoreCloudOnlyWallet(wallet) - } - .disabled(!manager.isDetailInventoryReady) - - Button("Delete from iCloud", role: .destructive) { - requestCloudOnlyWalletDeletion(wallet) - } - .disabled(!manager.isDetailInventoryReady) - - Button("Cancel", role: .cancel) {} - case .disableCloudBackup: Button("Continue", role: .destructive, action: presentFinalDisableConfirmation) .disabled(!manager.isDetailInventoryComplete) @@ -167,8 +296,6 @@ private struct CloudBackupDetailPresentationModifier: ViewModifier { "This will replace your entire cloud backup. Wallets that only exist in the current cloud backup will be lost." ) } - case .cloudOnlyWalletActions: - EmptyView() case .disableCloudBackup: Text( "Disabling Cloud Backup will permanently delete your current Cove cloud backups from cloud storage." @@ -216,11 +343,9 @@ private struct CloudBackupDetailPresentationModifier: ViewModifier { Button("OK", role: .cancel) {} case .undecryptableWalletDeletion: - Button( - "Delete Backups", - role: .destructive, - action: deleteUndecryptableWalletBackups - ) + Button("Delete Backups", role: .destructive) { + deleteUndecryptableWalletBackups(after: alert) + } .disabled(undecryptableWalletCount == 0 || manager.isPerformingDestructiveAction) Button("Cancel", role: .cancel) {} @@ -232,7 +357,9 @@ private struct CloudBackupDetailPresentationModifier: ViewModifier { Button("Cancel", role: .cancel) {} case .otherBackupsRecoveryResult: - Button("Verify Current Passkey", action: verifyCurrentPasskey) + Button("Verify Current Passkey") { + presenter.dismiss(.alert(alert), then: .startVerification) + } Button("Done", role: .cancel) {} case .otherBackupsDeleteConfirmation: @@ -289,32 +416,12 @@ private struct CloudBackupDetailPresentationModifier: ViewModifier { private func performDestructiveAction(_ confirmation: CloudBackupDestructiveConfirmation) { guard manager.isDetailInventoryComplete else { return } - coordinator.dismissCurrentPresentation() - - switch confirmation { - case .recreate: - manager.dispatch(action: .recreateManifest) - case .reinitialize: - manager.dispatch(action: .reinitializeBackup) - } - } - - private func restoreCloudOnlyWallet(_ wallet: CloudBackupWalletItem) { - guard manager.isDetailInventoryReady else { return } - - if wallet.syncStatus == .unsupportedVersion { - coordinator.transition(to: .alert(.cloudOnlyUnsupportedRestore(wallet))) - return + let action: CloudBackupManagerAction = switch confirmation { + case .recreate: .recreateManifest + case .reinitialize: .reinitializeBackup } - coordinator.dismissCurrentPresentation() - manager.dispatch(action: .restoreCloudWallet(wallet.recordId)) - } - - private func requestCloudOnlyWalletDeletion(_ wallet: CloudBackupWalletItem) { - guard manager.isDetailInventoryReady else { return } - - coordinator.transition(to: .alert(.cloudOnlyDeleteWallet(wallet))) + presenter.dismiss(.dialog(.destructive(confirmation)), then: .dispatch(action)) } private func presentFinalDisableConfirmation() { @@ -326,27 +433,22 @@ private struct CloudBackupDetailPresentationModifier: ViewModifier { private func recoverOtherBackups() { guard manager.isOtherBackupsInventoryReady else { return } - coordinator.dismissCurrentPresentation() - manager.dispatch(action: .recoverOtherBackups) + presenter.dismiss(.dialog(.recoverOtherBackups), then: .dispatch(.recoverOtherBackups)) } private func deleteCloudOnlyWallet(_ wallet: CloudBackupWalletItem) { guard manager.isDetailInventoryReady else { return } - coordinator.dismissCurrentPresentation() - manager.dispatch(action: .deleteCloudWallet(wallet.recordId)) + presenter.dismiss( + .alert(.cloudOnlyDeleteWallet(wallet)), + then: .dispatch(.deleteCloudWallet(wallet.recordId)) + ) } private func disableCloudBackup() { guard manager.isDetailInventoryComplete else { return } - coordinator.dismissCurrentPresentation() - manager.dispatch(action: .disableCloudBackup) - } - - private func verifyCurrentPasskey() { - coordinator.dismissCurrentPresentation() - manager.startVerification(source: .cloudBackupDetail) + presenter.dismiss(.alert(.disableFinalConfirmation), then: .dispatch(.disableCloudBackup)) } private func presentFinalOtherBackupsDeleteConfirmation() { @@ -358,14 +460,15 @@ private struct CloudBackupDetailPresentationModifier: ViewModifier { private func deleteOtherBackups() { guard manager.isOtherBackupsInventoryReady else { return } - coordinator.dismissCurrentPresentation() - manager.dispatch(action: .deleteOtherBackups) + presenter.dismiss( + .alert(.otherBackupsFinalDeleteConfirmation), + then: .dispatch(.deleteOtherBackups) + ) } - private func deleteUndecryptableWalletBackups() { + private func deleteUndecryptableWalletBackups(after alert: CloudBackupDetailAlert) { guard undecryptableWalletCount > 0, !manager.isPerformingDestructiveAction else { return } - coordinator.dismissCurrentPresentation() - manager.dispatch(action: .deleteUndecryptableWalletBackups) + presenter.dismiss(.alert(alert), then: .dispatch(.deleteUndecryptableWalletBackups)) } } diff --git a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailScreen.swift b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailScreen.swift index cc44c107b..1c8e64aed 100644 --- a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailScreen.swift +++ b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailScreen.swift @@ -43,8 +43,13 @@ struct CloudBackupDetailScreen: View { @Environment(CloudBackupPresentationCoordinator.self) private var cloudBackupPresentationCoordinator @State private var manager = CloudBackupManager.shared - @State private var presentationCoordinator = - PresentationTransitionCoordinator() + @State private var presenter = CloudBackupDetailPresenter { action in + action.perform(on: CloudBackupManager.shared) + } + + private var presentationCoordinator: PresentationTransitionCoordinator { + presenter.transitions + } private var hasVerificationResult: Bool { switch manager.verificationState { @@ -96,17 +101,21 @@ struct CloudBackupDetailScreen: View { isUnsupportedPasskeyProvider: isUnsupportedPasskeyProvider, shouldShowLoadingState: shouldShowLoadingState, progressPresentation: progressPresentation, - presentationCoordinator: presentationCoordinator, + presenter: presenter, recreateConfirmationIsPresented: confirmationBinding(for: .recreate), reinitializeConfirmationIsPresented: confirmationBinding(for: .reinitialize) ) .cloudBackupDetailPresentations( manager: manager, - coordinator: presentationCoordinator + presenter: presenter ) .navigationTitle("Cloud Backup") .navigationBarTitleDisplayMode(.inline) - .presentationTransitionHost(presentationCoordinator) + .presentationTransitionHost( + state: presentationCoordinator.hostState, + presenterDidBecomeReady: presenter.presenterDidBecomeReady, + hostDidDisappear: presenter.hostDidDisappear + ) .task(enterDetail) .onDisappear(perform: clearDetailPresentation) .onChange(of: hasCloudBackupPresentationBlocker, initial: true) { _, active in @@ -171,7 +180,7 @@ struct CloudBackupDetailScreen: View { } private func clearDetailPresentation() { - presentationCoordinator.discardAll() + presenter.hostDidDisappear() cloudBackupPresentationCoordinator.setBlocker(.cloudBackupDetailDialog, active: false) } } diff --git a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailSections.swift b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailSections.swift index 77db9c1b8..9ca814852 100644 --- a/ios/Cove/Flows/SettingsFlow/CloudBackupDetailSections.swift +++ b/ios/Cove/Flows/SettingsFlow/CloudBackupDetailSections.swift @@ -44,7 +44,7 @@ struct DetailFormContent: View { let detail: CloudBackupDetail let syncHealth: CloudSyncHealth let manager: CloudBackupManager - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter var body: some View { HeaderSection(lastSync: detail.lastSync, syncHealth: syncHealth) @@ -55,14 +55,14 @@ struct DetailFormContent: View { CloudOnlySection( wallets: cloudOnlyWallets, manager: manager, - presentationCoordinator: presentationCoordinator + presenter: presenter ) } if let summary = cloudBackupVisibleOtherBackupsSummary(manager.otherBackupsState) { OtherBackupsSection( summary: summary, manager: manager, - presentationCoordinator: presentationCoordinator + presenter: presenter ) } @@ -177,7 +177,7 @@ struct MissingPasskeyContent: View { struct DisableCloudBackupSection: View { let manager: CloudBackupManager let detail: CloudBackupDetail? - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter private var unavailableMessage: String? { if manager.isDisablingCloudBackup { @@ -218,7 +218,7 @@ struct DisableCloudBackupSection: View { DisableCloudBackupControls( manager: manager, unavailableMessage: unavailableMessage, - presentationCoordinator: presentationCoordinator + presenter: presenter ) } } @@ -226,7 +226,7 @@ struct DisableCloudBackupSection: View { private struct DisableCloudBackupControls: View { let manager: CloudBackupManager let unavailableMessage: String? - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter var body: some View { Section { @@ -245,7 +245,7 @@ private struct DisableCloudBackupControls: View { DisableCloudBackupRequestButton( manager: manager, unavailableMessage: unavailableMessage, - presentationCoordinator: presentationCoordinator + presenter: presenter ) } } @@ -254,7 +254,7 @@ private struct DisableCloudBackupControls: View { private struct DisableCloudBackupRequestButton: View { let manager: CloudBackupManager let unavailableMessage: String? - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter var body: some View { Button(role: .destructive, action: requestDisable) { @@ -269,9 +269,9 @@ private struct DisableCloudBackupRequestButton: View { guard manager.isDetailInventoryComplete else { return } if let unavailableMessage { - presentationCoordinator.present(.alert(.disableUnavailable(unavailableMessage))) + presenter.transitions.present(.alert(.disableUnavailable(unavailableMessage))) } else { - presentationCoordinator.present(.dialog(.disableCloudBackup)) + presenter.transitions.present(.dialog(.disableCloudBackup)) } } } diff --git a/ios/Cove/Flows/SettingsFlow/CloudBackupEnableOnboardingSections.swift b/ios/Cove/Flows/SettingsFlow/CloudBackupEnableOnboardingSections.swift index 57b094201..baf76b4a7 100644 --- a/ios/Cove/Flows/SettingsFlow/CloudBackupEnableOnboardingSections.swift +++ b/ios/Cove/Flows/SettingsFlow/CloudBackupEnableOnboardingSections.swift @@ -366,7 +366,7 @@ func cloudBackupEnableBusyCopy( ) case nil, .discoveringExistingBackup, .awaitingForceNewConfirmation, .awaitingPasskeyChoice: CloudBackupEnableBusyCopy( - title: "Creating your encrypted backup...", + title: "Checking for existing backups...", subtitle: "Cloud Backup will continue automatically", progress: nil ) diff --git a/ios/Cove/Flows/SettingsFlow/CloudBackupVerificationSection.swift b/ios/Cove/Flows/SettingsFlow/CloudBackupVerificationSection.swift index 7f0e780a0..5c35de0e4 100644 --- a/ios/Cove/Flows/SettingsFlow/CloudBackupVerificationSection.swift +++ b/ios/Cove/Flows/SettingsFlow/CloudBackupVerificationSection.swift @@ -25,7 +25,7 @@ private extension CloudBackupPasskeyRepairState? { struct VerificationSection: View { let manager: CloudBackupManager - let presentationCoordinator: PresentationTransitionCoordinator + let presenter: CloudBackupDetailPresenter let recreateConfirmationIsPresented: Binding let reinitializeConfirmationIsPresented: Binding @@ -131,7 +131,7 @@ struct VerificationSection: View { private func requestUndecryptableWalletDeletion() { guard undecryptableWalletCount > 0 else { return } - presentationCoordinator.present( + presenter.transitions.present( .alert(.undecryptableWalletDeletion(undecryptableWalletCount)) ) } diff --git a/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift b/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift index 4b6050736..219981b20 100644 --- a/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift +++ b/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift @@ -93,10 +93,8 @@ struct MainSettingsScreen: View { set: { enable in // enable if enable { - if !app.rust.unverifiedWalletIds().isEmpty { - alertState = .init( - .unverifiedWallets(app.rust.unverifiedWalletIds().first!) - ) + if let walletId = app.rust.unverifiedWalletIds().first { + alertState = .init(.unverifiedWallets(walletId)) return } diff --git a/ios/Cove/Flows/SettingsFlow/SettingsCloudBackupEnableSheet.swift b/ios/Cove/Flows/SettingsFlow/SettingsCloudBackupEnableSheet.swift index 7052abf9a..cbcf55730 100644 --- a/ios/Cove/Flows/SettingsFlow/SettingsCloudBackupEnableSheet.swift +++ b/ios/Cove/Flows/SettingsFlow/SettingsCloudBackupEnableSheet.swift @@ -27,6 +27,10 @@ func cloudBackupPendingEnableSupportEmailURL( struct SettingsCloudBackupEnableSheet: View { @State private var manager = CloudBackupManager.shared + @State private var promptTransitions = PresentationTransitionCoordinator() + @State private var promptActionHandoff = + PresentationActionHandoff() + @State private var isHostAvailable = false @State private var ignoreNextPromptDismiss = false let onComplete: () -> Void @@ -54,7 +58,7 @@ struct SettingsCloudBackupEnableSheet: View { private var showingPasskeyChoice: Binding { Binding( - get: { isEnablePasskeyChoice(manager.rootPrompt) }, + get: { passkeyChoiceIntent != nil }, set: { isPresented in guard !isPresented else { return } handlePromptDismiss() @@ -65,7 +69,10 @@ struct SettingsCloudBackupEnableSheet: View { private var showingExistingBackupPrompt: Binding { Binding( get: { - if case .existingBackupFound = manager.rootPrompt { return true } + if case .existingBackupFound = promptTransitions.currentPresentation?.item { + return true + } + return false }, set: { isPresented in @@ -76,7 +83,9 @@ struct SettingsCloudBackupEnableSheet: View { } private var passkeyChoiceIntent: CloudBackupPasskeyChoiceIntent? { - guard case let .passkeyChoice(intent) = manager.rootPrompt else { return nil } + guard case let .passkeyChoice(intent) = promptTransitions.currentPresentation?.item else { + return nil + } return intent } @@ -88,7 +97,9 @@ struct SettingsCloudBackupEnableSheet: View { } private var existingBackupPasskeyHint: CloudBackupPasskeyHint? { - guard case let .existingBackupFound(_, passkeyHint) = manager.rootPrompt else { + guard + case let .existingBackupFound(_, passkeyHint) = promptTransitions.currentPresentation?.item + else { return nil } @@ -120,8 +131,27 @@ struct SettingsCloudBackupEnableSheet: View { } private func dispatchPromptAction(_ action: CloudBackupManagerAction) { + guard case .acceptEnablePrompt = action else { + ignoreNextPromptDismiss = true + manager.dispatch(action: action) + return + } + + guard + promptActionHandoff.pendingAction == nil, + let currentPrompt = promptTransitions.currentPresentation?.item + else { return } + ignoreNextPromptDismiss = true - manager.dispatch(action: action) + guard let transition = promptTransitions.dismissCurrentPresentationForTransition() else { + return + } + + promptActionHandoff.stage( + action: action, + presentation: currentPrompt, + transition: transition + ) } private func handlePromptDismiss() { @@ -160,6 +190,48 @@ struct SettingsCloudBackupEnableSheet: View { return "Creating a new Cloud Backup will not include wallets from your previous backup. If you still have access to the passkey named Cove Cloud Backup (\(existingBackupPasskeyHint.nameSuffix)), use that passkey instead." } + private func reconcilePrompt(_ rootPrompt: CloudBackupRootPrompt) { + let desiredPrompt = isAwaitingEnablePrompt(rootPrompt) ? rootPrompt : nil + + if let pendingPrompt = promptActionHandoff.pendingPresentation, + pendingPrompt != desiredPrompt + { + promptActionHandoff.cancel() + promptTransitions.discardQueued { $0 == pendingPrompt } + } + + guard let desiredPrompt else { + promptTransitions.discardQueued { _ in true } + if promptTransitions.currentPresentation != nil { + ignoreNextPromptDismiss = true + promptTransitions.dismissCurrentPresentation() + } + return + } + + promptTransitions.reconcile(toward: desiredPrompt) { + ignoreNextPromptDismiss = true + } + } + + private func presenterDidBecomeReady(_ requestID: UUID) { + let currentPrompt = isAwaitingEnablePrompt(manager.rootPrompt) ? manager.rootPrompt : nil + + promptActionHandoff.presenterDidBecomeReady( + requestID, + currentPresentation: currentPrompt, + isHostAvailable: isHostAvailable, + using: promptTransitions + ) { action in + manager.dispatch(action: action) + } + } + + private func hostDidDisappear() { + isHostAvailable = false + promptActionHandoff.hostDidDisappear(using: promptTransitions) + } + var body: some View { CloudBackupExistingBackupAlertHost( isPresented: showingExistingBackupPrompt, @@ -194,10 +266,22 @@ struct SettingsCloudBackupEnableSheet: View { ) } } + .presentationTransitionHost( + state: promptTransitions.hostState, + presenterDidBecomeReady: presenterDidBecomeReady, + hostDidDisappear: hostDidDisappear + ) + .onAppear { + isHostAvailable = true + } + .onDisappear { + hostDidDisappear() + } .onChange(of: manager.enableCompletion, initial: true) { _, completion in completeIfReady(completion) } .onChange(of: manager.rootPrompt, initial: true) { _, rootPrompt in + reconcilePrompt(rootPrompt) if !isAwaitingEnablePrompt(rootPrompt) { ignoreNextPromptDismiss = false } diff --git a/ios/Cove/Info.plist b/ios/Cove/Info.plist index f1fb629c1..1de82a336 100644 --- a/ios/Cove/Info.plist +++ b/ios/Cove/Info.plist @@ -2,6 +2,8 @@ + ITSAppUsesNonExemptEncryption + NSAppTransportSecurity NSAllowsLocalNetworking diff --git a/ios/Cove/ManagerCache.swift b/ios/Cove/ManagerCache.swift index 00d734a35..aa34f3300 100644 --- a/ios/Cove/ManagerCache.swift +++ b/ios/Cove/ManagerCache.swift @@ -317,6 +317,7 @@ struct WalletManagerCacheState: Equatable { let hadWalletManager = walletManager != nil clearSendFlowManager() + clearCoinControlManager() backgroundScanTaskHandler.endInitialScanBackgroundTask() walletManager?.setInitialScanLifecycleChanged(nil) let walletManager = self.walletManager @@ -332,6 +333,7 @@ struct WalletManagerCacheState: Equatable { guard let id else { return } walletManagerCacheState.invalidate(.wallet(id)) clearSendFlowManager(id: id) + clearCoinControlManager(id: id) if walletManager?.id == id { backgroundScanTaskHandler.endInitialScanBackgroundTask() @@ -376,8 +378,8 @@ struct WalletManagerCacheState: Equatable { } } - func clearCoinControlManager() { - guard let coinControlManager else { return } + func clearCoinControlManager(id: WalletId? = nil) { + guard let coinControlManager, id == nil || coinControlManager.id == id else { return } self.coinControlManager = nil coinControlManager.close() diff --git a/ios/Cove/PresentationTransitionCoordinator.swift b/ios/Cove/PresentationTransitionCoordinator.swift index 113128321..50923419a 100644 --- a/ios/Cove/PresentationTransitionCoordinator.swift +++ b/ios/Cove/PresentationTransitionCoordinator.swift @@ -7,6 +7,16 @@ enum PresentationTransitionHostState: Equatable { case awaitingPresenterReadiness(UUID) } +struct PresentationTransitionRequest: Equatable { + let readinessRequestID: UUID +} + +struct PendingPresentationAction { + let presentation: Presentation + let transition: PresentationTransitionRequest + let action: Action +} + @MainActor @Observable final class PresentationTransitionCoordinator { @@ -14,6 +24,10 @@ final class PresentationTransitionCoordinator { private(set) var queuedPresentation: Presentation? private(set) var readinessRequestID: UUID? + var transitionRequest: PresentationTransitionRequest? { + readinessRequestID.map(PresentationTransitionRequest.init) + } + var hostState: PresentationTransitionHostState { if let readinessRequestID { return .awaitingPresenterReadiness(readinessRequestID) @@ -47,6 +61,7 @@ final class PresentationTransitionCoordinator { queuedPresentation = nil currentPresentation = TaggedItem(presentation) + readinessRequestID = nil } func queue(_ presentation: Presentation) { @@ -84,10 +99,15 @@ final class PresentationTransitionCoordinator { } func dismissCurrentPresentation() { - guard currentPresentation != nil else { return } + _ = dismissCurrentPresentationForTransition() + } + + @discardableResult + func dismissCurrentPresentationForTransition() -> PresentationTransitionRequest? { + guard currentPresentation != nil else { return nil } currentPresentation = nil - beginWaitingForPresenterReadiness() + return beginWaitingForPresenterReadiness() } func discard(where shouldDiscard: (Presentation) -> Bool) { @@ -114,14 +134,21 @@ final class PresentationTransitionCoordinator { _ requestID: UUID, presentQueuedPresentation: Bool = true ) { - guard readinessRequestID == requestID else { return } + guard consumePresenterReadiness(requestID) else { return } - readinessRequestID = nil if presentQueuedPresentation { self.presentQueuedPresentation() } } + @discardableResult + func consumePresenterReadiness(_ requestID: UUID) -> Bool { + guard readinessRequestID == requestID else { return false } + + readinessRequestID = nil + return true + } + func hostDidDisappear() { discardAll() } @@ -192,8 +219,11 @@ final class PresentationTransitionCoordinator { ) } - private func beginWaitingForPresenterReadiness() { - readinessRequestID = UUID() + @discardableResult + private func beginWaitingForPresenterReadiness() -> PresentationTransitionRequest { + let requestID = UUID() + readinessRequestID = requestID + return PresentationTransitionRequest(readinessRequestID: requestID) } private func presentQueuedPresentation() { @@ -201,6 +231,113 @@ final class PresentationTransitionCoordinator { currentPresentation = TaggedItem(queuedPresentation) self.queuedPresentation = nil + readinessRequestID = nil + } +} + +extension PresentationTransitionCoordinator where Presentation: Equatable { + /// Move toward a presentable `desired` presentation + /// + /// `beforeReplacingCurrent` runs only when a different visible presentation is about to be + /// dismissed, so hosts can ignore the dismissal their binding reports for it + func reconcile( + toward desired: Presentation, + beforeReplacingCurrent: () -> Void + ) { + if currentPresentation?.item == desired { + discardQueued { _ in true } + return + } + + // present queues behind a pending readiness request on its own + guard currentPresentation != nil else { + present(desired) + return + } + + beforeReplacingCurrent() + transition(to: desired) + } +} + +@MainActor +final class PresentationActionHandoff { + private(set) var pendingAction: PendingPresentationAction? + + var pendingPresentation: Presentation? { + pendingAction?.presentation + } + + func stage( + action: Action, + presentation: Presentation, + transition: PresentationTransitionRequest + ) { + guard pendingAction == nil else { return } + + pendingAction = PendingPresentationAction( + presentation: presentation, + transition: transition, + action: action + ) + } + + func cancel() { + pendingAction = nil + } + + /// Drop any staged action along with the host's presentation state + func hostDidDisappear(using coordinator: PresentationTransitionCoordinator) { + cancel() + coordinator.hostDidDisappear() + } + + /// Route presenter readiness to the staged action, or to the coordinator when none is staged + /// + /// `withoutPendingAction` replaces the plain coordinator advance for hosts that apply their + /// own presentability checks before presenting queued work + func presenterDidBecomeReady( + _ requestID: UUID, + currentPresentation: Presentation?, + isHostAvailable: Bool, + using coordinator: PresentationTransitionCoordinator, + withoutPendingAction fallback: ((UUID) -> Void)? = nil, + dispatch: (Action) -> Void + ) { + guard let pendingAction else { + if let fallback { + fallback(requestID) + } else { + coordinator.presenterDidBecomeReady(requestID) + } + return + } + + // an unrelated readiness signal must leave the staged action waiting for its own request + guard pendingAction.transition.readinessRequestID == requestID else { return } + + let canDispatch = pendingAction.presentation == currentPresentation && + pendingAction.transition == coordinator.transitionRequest && + isHostAvailable && + (coordinator.queuedPresentation == nil || + coordinator.queuedPresentation == pendingAction.presentation) + + guard canDispatch else { + self.pendingAction = nil + coordinator.discardQueued { $0 == pendingAction.presentation } + coordinator.presenterDidBecomeReady(requestID) + return + } + + coordinator.discardQueued { $0 == pendingAction.presentation } + + guard coordinator.consumePresenterReadiness(requestID) else { + self.pendingAction = nil + return + } + + self.pendingAction = nil + dispatch(pendingAction.action) } } diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 70eea32c0..b0568da2e 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -3072,8 +3072,6 @@ public func FfiConverterTypeConverter_lower(_ value: Converter) -> UInt64 { public protocol DatabaseProtocol: AnyObject, Sendable { - func dangerousResetAllData() throws - func diagnosticsReports() -> DiagnosticsReportsTable func globalConfig() -> GlobalConfigTable @@ -3148,14 +3146,6 @@ public convenience init() { -open func dangerousResetAllData()throws {try rustCallWithError(FfiConverterTypeDatabaseError_lift) { - uniffiCallStatus in - uniffi_cove_fn_method_database_dangerous_reset_all_data( - self.uniffiCloneHandle(),uniffiCallStatus - ) -} -} - open func diagnosticsReports() -> DiagnosticsReportsTable { return try! FfiConverterTypeDiagnosticsReportsTable_lift(try! rustCall() { uniffiCallStatus in @@ -3605,8 +3595,10 @@ public protocol FfiAppProtocol: AnyObject, Sendable { /** * DANGER: This will wipe all wallet data on this device + * + * Returns the committed post-wipe state the frontend applies before releasing authentication */ - func dangerousWipeAllData() throws + func dangerousWipeAllData() throws -> FullWipeCompletion /** * Delete a wallet with a corrupted database, cleaning up all associated data @@ -3692,7 +3684,7 @@ public protocol FfiAppProtocol: AnyObject, Sendable { /** * Retry a full wipe after a typed shutdown block */ - func retryDangerousWipeAllData(attemptId: ShutdownAttemptId) throws + func retryDangerousWipeAllData(attemptId: ShutdownAttemptId) throws -> FullWipeCompletion /** * Retry a corrupted-wallet deletion after a typed shutdown block @@ -3829,13 +3821,16 @@ open func cancelWalletDeletionAttempt(attemptId: ShutdownAttemptId) {try! rustC /** * DANGER: This will wipe all wallet data on this device + * + * Returns the committed post-wipe state the frontend applies before releasing authentication */ -open func dangerousWipeAllData()throws {try rustCallWithError(FfiConverterTypeAppError_lift) { +open func dangerousWipeAllData()throws -> FullWipeCompletion { + return try FfiConverterTypeFullWipeCompletion_lift(try rustCallWithError(FfiConverterTypeAppError_lift) { uniffiCallStatus in uniffi_cove_fn_method_ffiapp_dangerous_wipe_all_data( self.uniffiCloneHandle(),uniffiCallStatus ) -} +}) } /** @@ -4083,13 +4078,14 @@ open func resetNestedRoutesTo(defaultRoute: Route, nestedRoutes: [Route]) {try! /** * Retry a full wipe after a typed shutdown block */ -open func retryDangerousWipeAllData(attemptId: ShutdownAttemptId)throws {try rustCallWithError(FfiConverterTypeAppError_lift) { +open func retryDangerousWipeAllData(attemptId: ShutdownAttemptId)throws -> FullWipeCompletion { + return try FfiConverterTypeFullWipeCompletion_lift(try rustCallWithError(FfiConverterTypeAppError_lift) { uniffiCallStatus in uniffi_cove_fn_method_ffiapp_retry_dangerous_wipe_all_data( self.uniffiCloneHandle(), FfiConverterTypeShutdownAttemptId_lower(attemptId),uniffiCallStatus ) -} +}) } /** @@ -15268,6 +15264,85 @@ public func FfiConverterTypeAppState_lower(_ value: AppState) -> RustBuffer { } +/** + * Authentication settings a frontend mirrors + */ +public struct AuthSettings: Equatable, Hashable { + /** + * How the app is unlocked + */ + public var authType: AuthType + /** + * Whether a wipe data PIN is set + */ + public var isWipeDataPinEnabled: Bool + /** + * Whether a decoy PIN is set + */ + public var isDecoyPinEnabled: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * How the app is unlocked + */authType: AuthType, + /** + * Whether a wipe data PIN is set + */isWipeDataPinEnabled: Bool, + /** + * Whether a decoy PIN is set + */isDecoyPinEnabled: Bool) { + self.authType = authType + self.isWipeDataPinEnabled = isWipeDataPinEnabled + self.isDecoyPinEnabled = isDecoyPinEnabled + } + + + + +} + +#if compiler(>=6) +extension AuthSettings: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeAuthSettings: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthSettings { + return + try AuthSettings( + authType: FfiConverterTypeAuthType.read(from: &buf), + isWipeDataPinEnabled: FfiConverterBool.read(from: &buf), + isDecoyPinEnabled: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: AuthSettings, into buf: inout [UInt8]) { + FfiConverterTypeAuthType.write(value.authType, into: &buf) + FfiConverterBool.write(value.isWipeDataPinEnabled, into: &buf) + FfiConverterBool.write(value.isDecoyPinEnabled, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeAuthSettings_lift(_ buf: RustBuffer) throws -> AuthSettings { + return try FfiConverterTypeAuthSettings.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeAuthSettings_lower(_ value: AuthSettings) -> RustBuffer { + return FfiConverterTypeAuthSettings.lower(value) +} + + /** * Report of what happened during a backup import */ @@ -17291,6 +17366,108 @@ public func FfiConverterTypeFoundAddress_lower(_ value: FoundAddress) -> RustBuf } +/** + * Committed app and authentication state after a successful full wipe + * + * Frontends apply this synchronously before releasing authentication, so no frontend has + * to decide on its own what a wiped app looks like + */ +public struct FullWipeCompletion { + /** + * Navigation after the wipe, starting at new-wallet selection with no pushed routes + */ + public var router: Router + /** + * Whether the app must show onboarding, carried over from before the wipe + */ + public var needsOnboarding: Bool + public var selectedNetwork: Network + public var colorScheme: ColorSchemeSelection + public var selectedNode: Node + public var fiatCurrency: FiatCurrency + /** + * Always empty, included so frontends replace their wallet list instead of re-reading it + */ + public var wallets: [WalletMetadata] + public var auth: AuthSettings + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Navigation after the wipe, starting at new-wallet selection with no pushed routes + */router: Router, + /** + * Whether the app must show onboarding, carried over from before the wipe + */needsOnboarding: Bool, selectedNetwork: Network, colorScheme: ColorSchemeSelection, selectedNode: Node, fiatCurrency: FiatCurrency, + /** + * Always empty, included so frontends replace their wallet list instead of re-reading it + */wallets: [WalletMetadata], auth: AuthSettings) { + self.router = router + self.needsOnboarding = needsOnboarding + self.selectedNetwork = selectedNetwork + self.colorScheme = colorScheme + self.selectedNode = selectedNode + self.fiatCurrency = fiatCurrency + self.wallets = wallets + self.auth = auth + } + + + + +} + +#if compiler(>=6) +extension FullWipeCompletion: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeFullWipeCompletion: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FullWipeCompletion { + return + try FullWipeCompletion( + router: FfiConverterTypeRouter.read(from: &buf), + needsOnboarding: FfiConverterBool.read(from: &buf), + selectedNetwork: FfiConverterTypeNetwork.read(from: &buf), + colorScheme: FfiConverterTypeColorSchemeSelection.read(from: &buf), + selectedNode: FfiConverterTypeNode.read(from: &buf), + fiatCurrency: FfiConverterTypeFiatCurrency.read(from: &buf), + wallets: FfiConverterSequenceTypeWalletMetadata.read(from: &buf), + auth: FfiConverterTypeAuthSettings.read(from: &buf) + ) + } + + public static func write(_ value: FullWipeCompletion, into buf: inout [UInt8]) { + FfiConverterTypeRouter.write(value.router, into: &buf) + FfiConverterBool.write(value.needsOnboarding, into: &buf) + FfiConverterTypeNetwork.write(value.selectedNetwork, into: &buf) + FfiConverterTypeColorSchemeSelection.write(value.colorScheme, into: &buf) + FfiConverterTypeNode.write(value.selectedNode, into: &buf) + FfiConverterTypeFiatCurrency.write(value.fiatCurrency, into: &buf) + FfiConverterSequenceTypeWalletMetadata.write(value.wallets, into: &buf) + FfiConverterTypeAuthSettings.write(value.auth, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeFullWipeCompletion_lift(_ buf: RustBuffer) throws -> FullWipeCompletion { + return try FfiConverterTypeFullWipeCompletion.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeFullWipeCompletion_lower(_ value: FullWipeCompletion) -> RustBuffer { + return FfiConverterTypeFullWipeCompletion.lower(value) +} + + public struct GroupedWord: Equatable, Hashable { public var number: UInt8 public var word: String @@ -21717,8 +21894,16 @@ public enum AuthManagerReconcileMessage: Equatable, Hashable { case authTypeChanged(AuthType ) - case wipeDataPinChanged - case decoyPinChanged + /** + * Whether a wipe data PIN is now set + */ + case wipeDataPinChanged(Bool + ) + /** + * Whether a decoy PIN is now set + */ + case decoyPinChanged(Bool + ) @@ -21743,9 +21928,11 @@ public struct FfiConverterTypeAuthManagerReconcileMessage: FfiConverterRustBuffe case 1: return .authTypeChanged(try FfiConverterTypeAuthType.read(from: &buf) ) - case 2: return .wipeDataPinChanged + case 2: return .wipeDataPinChanged(try FfiConverterBool.read(from: &buf) + ) - case 3: return .decoyPinChanged + case 3: return .decoyPinChanged(try FfiConverterBool.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } @@ -21760,12 +21947,14 @@ public struct FfiConverterTypeAuthManagerReconcileMessage: FfiConverterRustBuffe FfiConverterTypeAuthType.write(v1, into: &buf) - case .wipeDataPinChanged: + case let .wipeDataPinChanged(v1): writeInt(&buf, Int32(2)) + FfiConverterBool.write(v1, into: &buf) - case .decoyPinChanged: + case let .decoyPinChanged(v1): writeInt(&buf, Int32(3)) + FfiConverterBool.write(v1, into: &buf) } } @@ -31719,6 +31908,10 @@ public enum LocalDataResetStage: Equatable, Hashable { * Remaining Cove wallet keychain entries */ case walletKeychain + /** + * Persisted KeyTeleport receive session + */ + case keyTeleport /** * Orphan BDK stores and wallet-data directories */ @@ -31766,17 +31959,19 @@ public struct FfiConverterTypeLocalDataResetStage: FfiConverterRustBuffer { case 1: return .walletKeychain - case 2: return .walletArtifacts + case 2: return .keyTeleport + + case 3: return .walletArtifacts - case 3: return .cloudBackup + case 4: return .cloudBackup - case 4: return .restoreState + case 5: return .restoreState - case 5: return .rootDirectorySync + case 6: return .rootDirectorySync - case 6: return .diagnostics + case 7: return .diagnostics - case 7: return .database + case 8: return .database default: throw UniffiInternalError.unexpectedEnumCase } @@ -31790,29 +31985,33 @@ public struct FfiConverterTypeLocalDataResetStage: FfiConverterRustBuffer { writeInt(&buf, Int32(1)) - case .walletArtifacts: + case .keyTeleport: writeInt(&buf, Int32(2)) - case .cloudBackup: + case .walletArtifacts: writeInt(&buf, Int32(3)) - case .restoreState: + case .cloudBackup: writeInt(&buf, Int32(4)) - case .rootDirectorySync: + case .restoreState: writeInt(&buf, Int32(5)) - case .diagnostics: + case .rootDirectorySync: writeInt(&buf, Int32(6)) - case .database: + case .diagnostics: writeInt(&buf, Int32(7)) + + case .database: + writeInt(&buf, Int32(8)) + } } } @@ -40227,6 +40426,14 @@ public enum WalletDeletionStage: Equatable, Hashable { * Parent-directory durability synchronization */ case directorySync + /** + * Unsigned transaction rows that belong to the wallet + */ + case unsignedTransactions + /** + * Selected-wallet references in global config + */ + case walletSelection /** * Exact durable metadata rows */ @@ -40262,7 +40469,11 @@ public struct FfiConverterTypeWalletDeletionStage: FfiConverterRustBuffer { case 5: return .directorySync - case 6: return .metadata + case 6: return .unsignedTransactions + + case 7: return .walletSelection + + case 8: return .metadata default: throw UniffiInternalError.unexpectedEnumCase } @@ -40292,9 +40503,17 @@ public struct FfiConverterTypeWalletDeletionStage: FfiConverterRustBuffer { writeInt(&buf, Int32(5)) - case .metadata: + case .unsignedTransactions: writeInt(&buf, Int32(6)) + + case .walletSelection: + writeInt(&buf, Int32(7)) + + + case .metadata: + writeInt(&buf, Int32(8)) + } } } @@ -47095,7 +47314,7 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_ffiapp_cancel_wallet_deletion_attempt() != 29231) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_ffiapp_dangerous_wipe_all_data() != 1643) { + if (uniffi_cove_checksum_method_ffiapp_dangerous_wipe_all_data() != 55578) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_ffiapp_delete_corrupted_wallet() != 8180) { @@ -47158,7 +47377,7 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_ffiapp_reset_nested_routes_to() != 57261) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_ffiapp_retry_dangerous_wipe_all_data() != 38709) { + if (uniffi_cove_checksum_method_ffiapp_retry_dangerous_wipe_all_data() != 62726) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_ffiapp_retry_delete_corrupted_wallet() != 50275) { @@ -47254,9 +47473,6 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_converter_parse_fiat_str() != 59628) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_database_dangerous_reset_all_data() != 1221) { - return InitializationResult.apiChecksumMismatch - } if (uniffi_cove_checksum_method_database_diagnostics_reports() != 32801) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/CoveTests/CloudBackupIOSSafetyHelpersTests.swift b/ios/CoveTests/CloudBackupIOSSafetyHelpersTests.swift index 5bf45da33..9773a1ba4 100644 --- a/ios/CoveTests/CloudBackupIOSSafetyHelpersTests.swift +++ b/ios/CoveTests/CloudBackupIOSSafetyHelpersTests.swift @@ -408,6 +408,163 @@ final class CloudBackupIOSSafetyHelpersTests: XCTestCase { XCTAssertEqual(secondRecords, [record]) XCTAssertEqual(source.startCount, 1) } +} + +extension CloudBackupIOSSafetyHelpersTests { + @MainActor + func testMetadataIndexDoesNotSettleBeforeInitialGatheringFinishes() async throws { + let source = MetadataQuerySourceSpy() + let settleSleep = MetadataSettleSleepSpy(blockedCalls: [1]) + let index = ICloudMetadataIndex( + source: source, + settleSleep: { duration in try await settleSleep.sleep(for: duration) } + ) + let record = metadataRecord(name: "master-key.json", parentPath: "/cloud/namespace") + let request = Task { + try await index.settledRecords(timeout: 1, settleInterval: 0.5) + } + + await source.waitUntilStarted() + XCTAssertEqual(settleSleep.durations, []) + + source.send(.finishedGathering([record])) + await settleSleep.waitUntilCalled(count: 1) + settleSleep.resume(call: 1) + + let records = try await request.value + XCTAssertEqual(records, [record]) + XCTAssertEqual(settleSleep.durations, [0.5]) + } + + @MainActor + func testMetadataIndexReusesStrongestSettledGeneration() async throws { + let source = MetadataQuerySourceSpy() + let settleSleep = MetadataSettleSleepSpy(blockedCalls: [1]) + let index = ICloudMetadataIndex( + source: source, + settleSleep: { duration in try await settleSleep.sleep(for: duration) } + ) + let record = metadataRecord(name: "master-key.json", parentPath: "/cloud/namespace") + let initial = Task { + try await index.settledRecords(timeout: 1, settleInterval: 0.5) + } + + await source.waitUntilStarted() + source.send(.finishedGathering([record])) + await settleSleep.waitUntilCalled(count: 1) + settleSleep.resume(call: 1) + _ = try await initial.value + + let weakerRecords = try await index.settledRecords(timeout: 1, settleInterval: 0.25) + let equalRecords = try await index.settledRecords(timeout: 1, settleInterval: 0.5) + + XCTAssertEqual(weakerRecords, [record]) + XCTAssertEqual(equalRecords, [record]) + XCTAssertEqual(settleSleep.durations, [0.5]) + + let strongerRecords = try await index.settledRecords(timeout: 1, settleInterval: 0.75) + + XCTAssertEqual(strongerRecords, [record]) + XCTAssertEqual(settleSleep.durations, [0.5, 0.75]) + } + + @MainActor + func testMetadataIndexDoesNotCacheTruncatedWaitAsFullInterval() async throws { + let source = MetadataQuerySourceSpy() + let settleSleep = MetadataSettleSleepSpy(blockedCalls: []) + let index = ICloudMetadataIndex( + source: source, + settleSleep: { duration in try await settleSleep.sleep(for: duration) } + ) + let record = metadataRecord(name: "master-key.json", parentPath: "/cloud/namespace") + let initial = Task { + try await index.settledRecords(timeout: 5, settleInterval: 10) + } + + await source.waitUntilStarted() + source.send(.finishedGathering([record])) + _ = try await initial.value + + XCTAssertEqual(settleSleep.durations.count, 1) + XCTAssertLessThan(try XCTUnwrap(settleSleep.durations.first), 10) + + let fullySettled = try await index.settledRecords(timeout: 20, settleInterval: 10) + + XCTAssertEqual(fullySettled, [record]) + XCTAssertEqual(settleSleep.durations.count, 2) + XCTAssertEqual(settleSleep.durations.last, 10) + } + + @MainActor + func testMetadataIndexDoesNotCacheCancelledSettleWait() async throws { + let source = MetadataQuerySourceSpy() + let settleSleep = MetadataSettleSleepSpy(blockedCalls: [1]) + let index = ICloudMetadataIndex( + source: source, + settleSleep: { duration in try await settleSleep.sleep(for: duration) } + ) + let record = metadataRecord(name: "master-key.json", parentPath: "/cloud/namespace") + let cancelled = Task { + try await index.settledRecords(timeout: 1, settleInterval: 0.5) + } + + await source.waitUntilStarted() + source.send(.finishedGathering([record])) + await settleSleep.waitUntilCalled(count: 1) + cancelled.cancel() + + do { + _ = try await cancelled.value + XCTFail("expected cancellation") + } catch is CancellationError { + } catch { + XCTFail("expected CancellationError, got \(error)") + } + + let settled = try await index.settledRecords(timeout: 1, settleInterval: 0.5) + + XCTAssertEqual(settled, [record]) + XCTAssertEqual(settleSleep.durations, [0.5, 0.5]) + } + + @MainActor + func testMetadataIndexInvalidatesSettledGenerationOnUpdates() async throws { + let source = MetadataQuerySourceSpy() + let settleSleep = MetadataSettleSleepSpy(blockedCalls: [1, 2]) + let index = ICloudMetadataIndex( + source: source, + settleSleep: { duration in try await settleSleep.sleep(for: duration) } + ) + let initialRecord = metadataRecord( + name: "master-key.json", + parentPath: "/cloud/namespace" + ) + let firstUpdate = metadataRecord(name: "wallet-1.json", parentPath: "/cloud/namespace") + let secondUpdate = metadataRecord(name: "wallet-2.json", parentPath: "/cloud/namespace") + let initial = Task { + try await index.settledRecords(timeout: 1, settleInterval: 0.5) + } + + await source.waitUntilStarted() + source.send(.finishedGathering([initialRecord])) + await settleSleep.waitUntilCalled(count: 1) + settleSleep.resume(call: 1) + _ = try await initial.value + + source.send(.updated([firstUpdate])) + let refreshed = Task { + try await index.settledRecords(timeout: 1, settleInterval: 0.5) + } + await settleSleep.waitUntilCalled(count: 2) + + source.send(.updated([secondUpdate])) + settleSleep.resume(call: 2) + await settleSleep.waitUntilCalled(count: 3) + + let records = try await refreshed.value + XCTAssertEqual(records, [secondUpdate]) + XCTAssertEqual(settleSleep.durations, [0.5, 0.5, 0.5]) + } @MainActor func testMetadataIndexWaitsForAnItemPublishedByLaterUpdate() async throws { @@ -1038,6 +1195,14 @@ extension CloudBackupIOSSafetyHelpersTests { @MainActor private func makeICloudMetadataFixture( startResults: [Bool] = [true], + settleSleep: @escaping ICloudMetadataSettleSleep = { duration in + try await Task.sleep(for: .seconds(duration)) + }, + now: @escaping @MainActor @Sendable () -> Date = { Date() }, + deletionTombstoneMaxAge: TimeInterval = 60, + coordinatedDeleter: @escaping @Sendable (URL, String) throws -> URL = { url, missingItemID in + try ICloudDriveHelper.coordinatedDelete(at: url, missingItemID: missingItemID) + }, defaultTimeout: TimeInterval = 1, metadataListingTimeout: TimeInterval = 5 ) -> ICloudMetadataFixture { @@ -1046,16 +1211,23 @@ extension CloudBackupIOSSafetyHelpersTests { isDirectory: true ) let source = MetadataQuerySourceSpy(startResults: startResults) - let index = ICloudMetadataIndex(source: source) + let index = ICloudMetadataIndex( + source: source, + settleSleep: settleSleep, + now: now, + deletionTombstoneMaxAge: deletionTombstoneMaxAge + ) let helper = ICloudDriveHelper( containerURLProvider: { containerURL }, metadataIndexProvider: { index }, + coordinatedDeleter: coordinatedDeleter, defaultTimeout: defaultTimeout, metadataListingTimeout: metadataListingTimeout ) return ICloudMetadataFixture( containerURL: containerURL, source: source, + index: index, helper: helper ) } @@ -1069,10 +1241,646 @@ extension CloudBackupIOSSafetyHelpersTests { } } +extension CloudBackupIOSSafetyHelpersTests { + @MainActor + func testMetadataDeletionImmediatelyFiltersReadersAndStaleUpdates() async throws { + let settleSleep = MetadataSettleSleepSpy(blockedCalls: []) + let fixture = makeICloudMetadataFixture( + settleSleep: { duration in try await settleSleep.sleep(for: duration) } + ) + defer { fixture.removeContainer() } + + let deleted = metadataRecord(name: "wallet.json", parentPath: "/cloud/namespace") + let unrelated = metadataRecord(name: "other.json", parentPath: "/cloud/namespace") + let initial = Task { + try await fixture.index.settledRecords(timeout: 1, settleInterval: 0.5) + } + + await fixture.source.waitUntilStarted() + fixture.source.send(.finishedGathering([deleted, unrelated])) + let initialRecords = try await initial.value + XCTAssertEqual(initialRecords, [deleted, unrelated]) + + var observerCount = 0 + let observerID = fixture.index.addObserver { observerCount += 1 } + fixture.index.markDeleted(resolvedPaths: [deleted.resolvedPath]) + + let currentRecords = try await fixture.index.currentOrInitialRecords(timeout: 1) + let deletedItem = try await fixture.index.itemIfPresent( + named: deleted.name, + parentPath: "/cloud/namespace", + timeout: 1 + ) + XCTAssertEqual(currentRecords, [unrelated]) + XCTAssertNil(deletedItem) + XCTAssertEqual( + fixture.index.visibleItems(matching: [ + ICloudMetadataCandidate(name: unrelated.name, parentPath: "/cloud/namespace"), + ]), + [unrelated] + ) + let settledRecords = try await fixture.index.settledRecords( + timeout: 1, + settleInterval: 0.5 + ) + XCTAssertEqual(settledRecords, [unrelated]) + XCTAssertEqual(settleSleep.durations, [0.5]) + XCTAssertEqual(observerCount, 0) + + fixture.source.send(.updated([deleted, unrelated])) + fixture.source.send(.updated([deleted, unrelated])) + + let staleUpdateRecords = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(staleUpdateRecords, [unrelated]) + XCTAssertEqual( + fixture.index.visibleItems(matching: [ + ICloudMetadataCandidate(name: deleted.name, parentPath: "/cloud/namespace"), + ]), + [] + ) + XCTAssertEqual(observerCount, 2) + XCTAssertEqual(fixture.source.startCount, 1) + + let refreshedRecords = try await fixture.index.settledRecords( + timeout: 1, + settleInterval: 0.5 + ) + XCTAssertEqual(refreshedRecords, [unrelated]) + XCTAssertEqual(settleSleep.durations, [0.5, 0.5]) + fixture.index.removeObserver(observerID) + } + + @MainActor + func testMetadataDeletionReleasesAfterCompleteAbsence() async throws { + let record = metadataRecord(name: "wallet.json", parentPath: "/cloud/namespace") + + let initialFixture = makeICloudMetadataFixture() + initialFixture.index.markDeleted(resolvedPaths: [record.resolvedPath]) + let initial = Task { try await initialFixture.index.currentOrInitialRecords(timeout: 1) } + await initialFixture.source.waitUntilStarted() + initialFixture.source.send(.finishedGathering([])) + let absentInitialRecords = try await initial.value + XCTAssertEqual(absentInitialRecords, []) + initialFixture.source.send(.updated([record])) + let returnedInitialRecord = try await initialFixture.index.currentOrInitialRecords( + timeout: 1 + ) + XCTAssertEqual(returnedInitialRecord, [record]) + + let liveFixture = makeICloudMetadataFixture() + let live = Task { try await liveFixture.index.currentOrInitialRecords(timeout: 1) } + await liveFixture.source.waitUntilStarted() + liveFixture.source.send(.finishedGathering([record])) + _ = try await live.value + liveFixture.index.markDeleted(resolvedPaths: [record.resolvedPath]) + liveFixture.source.send(.updated([])) + liveFixture.source.send(.updated([record])) + let returnedLiveRecord = try await liveFixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(returnedLiveRecord, [record]) + } + + @MainActor + func testMetadataDeletionSurvivesPartialGatheringAndBlocksWaiter() async throws { + let fixture = makeICloudMetadataFixture() + let record = metadataRecord(name: "wallet.json", parentPath: "/cloud/namespace") + fixture.index.markDeleted(resolvedPaths: [record.resolvedPath]) + XCTAssertEqual(fixture.source.startCount, 0) + + let waiter = Task { + try await fixture.index.waitForItem( + named: record.name, + parentPath: "/cloud/namespace", + timeout: 0.02 + ) + } + await fixture.source.waitUntilStarted() + fixture.source.send(.updated([])) + fixture.source.send(.updated([record])) + fixture.source.send(.finishedGathering([record])) + + do { + _ = try await waiter.value + XCTFail("expected tombstoned item waiter to time out") + } catch let error as ICloudMetadataIndexError { + XCTAssertEqual(error, .timedOut) + } + + let records = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(records, []) + } + + @MainActor + func testMetadataDeletionBlocksLiveWaiterThroughStaleUpdate() async throws { + let fixture = makeICloudMetadataFixture() + let record = metadataRecord(name: "wallet.json", parentPath: "/cloud/namespace") + let initial = Task { try await fixture.index.currentOrInitialRecords(timeout: 1) } + await fixture.source.waitUntilStarted() + fixture.source.send(.finishedGathering([record])) + _ = try await initial.value + fixture.index.markDeleted(resolvedPaths: [record.resolvedPath]) + + let waiter = Task { + try await fixture.index.waitForItem( + named: record.name, + parentPath: "/cloud/namespace", + timeout: 0.02 + ) + } + fixture.source.send(.updated([record])) + + do { + _ = try await waiter.value + XCTFail("expected tombstoned live item waiter to time out") + } catch let error as ICloudMetadataIndexError { + XCTAssertEqual(error, .timedOut) + } + } + + @MainActor + func testMetadataDeletionCoversDescendantsWithoutMatchingTextPrefix() async throws { + let fixture = makeICloudMetadataFixture() + let directoryPath = "/cloud/namespace" + let child = metadataRecord(name: "wallet.json", parentPath: directoryPath) + let nested = metadataRecord(name: "key.json", parentPath: directoryPath + "/nested") + let sibling = metadataRecord(name: "other.json", parentPath: directoryPath + "-other") + let initial = Task { try await fixture.index.currentOrInitialRecords(timeout: 1) } + await fixture.source.waitUntilStarted() + fixture.source.send(.finishedGathering([child, nested, sibling])) + _ = try await initial.value + + fixture.index.markDeleted(resolvedPaths: [directoryPath, directoryPath]) + let records = try await fixture.index.currentOrInitialRecords(timeout: 1) + + XCTAssertEqual(records, [sibling]) + XCTAssertEqual( + ICloudMetadataProjection.subdirectoryNames(in: records, parentPath: "/cloud"), + ["namespace-other"] + ) + XCTAssertEqual( + ICloudMetadataProjection.fileNames( + in: records, + parentPath: directoryPath, + prefix: "wallet" + ), + [] + ) + } + + @MainActor + func testMetadataDeletionExpiresOnlyDuringReconciliationAndCanBeRenewed() async throws { + let clock = MetadataTestClock(date: Date(timeIntervalSince1970: 1000)) + let fixture = makeICloudMetadataFixture( + now: { clock.date }, + deletionTombstoneMaxAge: 60 + ) + let record = metadataRecord(name: "wallet.json", parentPath: "/cloud/namespace") + let initial = Task { try await fixture.index.currentOrInitialRecords(timeout: 1) } + await fixture.source.waitUntilStarted() + fixture.source.send(.finishedGathering([record])) + _ = try await initial.value + + fixture.index.markDeleted(resolvedPaths: [record.resolvedPath]) + clock.advance(by: 59) + fixture.source.send(.updated([record])) + var records = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(records, []) + + clock.advance(by: 1) + records = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(records, []) + fixture.source.send(.updated([record])) + records = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(records, [record]) + + fixture.index.markDeleted(resolvedPaths: [record.resolvedPath]) + clock.advance(by: 59) + fixture.index.markDeleted(resolvedPaths: [record.resolvedPath]) + clock.advance(by: 2) + fixture.source.send(.updated([record])) + records = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(records, []) + } + + @MainActor + func testMetadataDeletionClearIsExactAndDoesNotRestoreCachedRecords() async throws { + let fixture = makeICloudMetadataFixture() + let parentPath = "/cloud/namespace" + let first = metadataRecord(name: "first.json", parentPath: parentPath) + let second = metadataRecord(name: "second.json", parentPath: parentPath) + let initial = Task { try await fixture.index.currentOrInitialRecords(timeout: 1) } + await fixture.source.waitUntilStarted() + fixture.source.send(.finishedGathering([first, second])) + _ = try await initial.value + + fixture.index.markDeleted(resolvedPaths: [first.resolvedPath]) + fixture.index.clearDeletion(resolvedPath: first.resolvedPath) + var records = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(records, [second]) + fixture.source.send(.updated([first, second])) + records = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(records, [first, second]) + + fixture.index.markDeleted(resolvedPaths: [parentPath]) + fixture.index.clearDeletion(resolvedPath: first.resolvedPath) + fixture.source.send(.updated([first, second])) + records = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(records, []) + } + + @MainActor + func testSuccessfulBackupDeleteHidesCurrentAndLegacyMetadataImmediately() async throws { + for location in backupLocations() { + let fixture = makeICloudMetadataFixture(settleSleep: { _ in }) + defer { fixture.removeContainer() } + + let url = try fixture.helper.backupFileReadURL( + namespace: testNamespace, + location: location + ) + try writeTestBackup(at: url) + let record = metadataRecord( + name: url.lastPathComponent, + parentPath: url.deletingLastPathComponent().path + ) + let initial = Task { try await fixture.index.currentOrInitialRecords(timeout: 1) } + await fixture.source.waitUntilStarted() + fixture.source.send(.finishedGathering([record])) + _ = try await initial.value + + try await fixture.helper.deleteExistingBackupFile( + namespace: testNamespace, + recordId: "wallet-record", + locations: [location] + ) + + let fileNames = try await fixture.helper.metadataFileNames( + namespacePath: url.deletingLastPathComponent().path, + prefix: csppWalletFilePrefix() + ) + XCTAssertEqual(fileNames, []) + fixture.source.send(.updated([record])) + let staleRecords = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(staleRecords, []) + + do { + _ = try await fixture.helper.existingBackupFileReadTarget( + namespace: testNamespace, + recordId: "wallet-record", + locations: [location], + lookupMode: .currentSnapshot + ) + XCTFail("expected deleted metadata item to be absent") + } catch CloudStorageError.NotFound {} + } + } + + @MainActor + func testBackupDeleteRecordsCoordinatorResolvedURL() async throws { + let location = try XCTUnwrap(backupLocations().first) + let redirectedURL = URL(fileURLWithPath: "/provider/\(testNamespace)/wallet-record.json") + let deleteStub = CoordinatedDeleteStub(redirectTo: redirectedURL) + let fixture = makeICloudMetadataFixture( + coordinatedDeleter: { url, id in + try deleteStub.delete(url: url, missingItemID: id) + } + ) + defer { fixture.removeContainer() } + + let requestedURL = try fixture.helper.backupFileReadURL( + namespace: testNamespace, + location: location + ) + try writeTestBackup(at: requestedURL) + let providerRecord = ICloudMetadataRecord( + name: requestedURL.lastPathComponent, + url: redirectedURL, + resolvedPath: redirectedURL.resolvingSymlinksInPath().path + ) + let initial = Task { try await fixture.index.currentOrInitialRecords(timeout: 1) } + await fixture.source.waitUntilStarted() + fixture.source.send(.finishedGathering([providerRecord])) + _ = try await initial.value + + try await fixture.helper.deleteExistingBackupFile( + namespace: testNamespace, + recordId: "wallet-record", + locations: [location] + ) + + XCTAssertEqual(deleteStub.deletedURLs, [requestedURL]) + var visibleRecords = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(visibleRecords, []) + fixture.source.send(.updated([providerRecord])) + visibleRecords = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(visibleRecords, []) + } + + @MainActor + func testBackupDeleteDoesNotRecordNoSuccessNotFound() async throws { + let notFoundStub = CoordinatedDeleteStub(notFoundOnCalls: [1]) + let notFoundFixture = makeICloudMetadataFixture( + coordinatedDeleter: { url, id in + try notFoundStub.delete(url: url, missingItemID: id) + } + ) + defer { notFoundFixture.removeContainer() } + let notFoundLocation = try XCTUnwrap(backupLocations().first) + let notFoundURL = try notFoundFixture.helper.backupFileReadURL( + namespace: testNamespace, + location: notFoundLocation + ) + try writeTestBackup(at: notFoundURL) + let notFoundRecord = metadataRecord( + name: notFoundURL.lastPathComponent, + parentPath: notFoundURL.deletingLastPathComponent().path + ) + let notFoundInitial = Task { + try await notFoundFixture.index.currentOrInitialRecords(timeout: 1) + } + await notFoundFixture.source.waitUntilStarted() + notFoundFixture.source.send(.finishedGathering([notFoundRecord])) + _ = try await notFoundInitial.value + + do { + try await notFoundFixture.helper.deleteExistingBackupFile( + namespace: testNamespace, + recordId: "wallet-record", + locations: [notFoundLocation] + ) + XCTFail("expected no-success NotFound") + } catch CloudStorageError.NotFound {} + + let notFoundRecords = try await notFoundFixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(notFoundRecords, [notFoundRecord]) + } + + @MainActor + func testBackupDeleteRecordsPartialSuccessBeforeFailure() async throws { + let deleteStub = CoordinatedDeleteStub(failOnCall: 2) + let fixture = makeICloudMetadataFixture( + coordinatedDeleter: { url, id in + try deleteStub.delete(url: url, missingItemID: id) + } + ) + defer { fixture.removeContainer() } + let locations = backupLocations() + let urls = try locations.map { + try fixture.helper.backupFileReadURL(namespace: testNamespace, location: $0) + } + for url in urls { + try writeTestBackup(at: url) + } + let records = urls.map { + metadataRecord(name: $0.lastPathComponent, parentPath: $0.deletingLastPathComponent().path) + } + let initial = Task { try await fixture.index.currentOrInitialRecords(timeout: 1) } + await fixture.source.waitUntilStarted() + fixture.source.send(.finishedGathering(records)) + _ = try await initial.value + + do { + try await fixture.helper.deleteExistingBackupFile( + namespace: testNamespace, + recordId: "wallet-record", + locations: locations + ) + XCTFail("expected injected delete failure") + } catch CloudStorageError.UploadFailed {} + + let remainingRecords = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(remainingRecords, [records[1]]) + } + + @MainActor + func testBackupDeleteCancelledBeforeWorkLeavesMetadataVisible() async throws { + let cancelledStub = CoordinatedDeleteStub() + let cancelledFixture = makeICloudMetadataFixture( + coordinatedDeleter: { url, id in + try cancelledStub.delete(url: url, missingItemID: id) + } + ) + defer { cancelledFixture.removeContainer() } + let locations = backupLocations() + let cancelledURL = try cancelledFixture.helper.backupFileReadURL( + namespace: testNamespace, + location: locations[0] + ) + try writeTestBackup(at: cancelledURL) + let cancelledRecord = metadataRecord( + name: cancelledURL.lastPathComponent, + parentPath: cancelledURL.deletingLastPathComponent().path + ) + let cancelledInitial = Task { + try await cancelledFixture.index.currentOrInitialRecords(timeout: 1) + } + await cancelledFixture.source.waitUntilStarted() + cancelledFixture.source.send(.finishedGathering([cancelledRecord])) + _ = try await cancelledInitial.value + + let cancelled = Task { + withUnsafeCurrentTask { $0?.cancel() } + try await cancelledFixture.helper.deleteExistingBackupFile( + namespace: self.testNamespace, + recordId: "wallet-record", + locations: [locations[0]] + ) + } + + do { + try await cancelled.value + XCTFail("expected cancellation") + } catch is CancellationError {} + + XCTAssertEqual(cancelledStub.callCount, 0) + let cancelledRecords = try await cancelledFixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(cancelledRecords, [cancelledRecord]) + } + + @MainActor + func testBackupDeleteRecordsSuccessBeforeCancellationTakesPrecedence() async throws { + let precedenceStub = CoordinatedDeleteStub(failOnCall: 1, blockOnCall: 2) + let precedenceFixture = makeICloudMetadataFixture( + coordinatedDeleter: { url, id in + try precedenceStub.delete(url: url, missingItemID: id) + } + ) + defer { precedenceFixture.removeContainer() } + let locations = backupLocations() + let precedenceURLs = try locations.map { + try precedenceFixture.helper.backupFileReadURL( + namespace: testNamespace, + location: $0 + ) + } + for url in precedenceURLs { + try writeTestBackup(at: url) + } + let precedenceRecords = precedenceURLs.map { + metadataRecord(name: $0.lastPathComponent, parentPath: $0.deletingLastPathComponent().path) + } + let precedenceInitial = Task { + try await precedenceFixture.index.currentOrInitialRecords(timeout: 1) + } + await precedenceFixture.source.waitUntilStarted() + precedenceFixture.source.send(.finishedGathering(precedenceRecords)) + _ = try await precedenceInitial.value + + let precedenceDelete = Task { + try await precedenceFixture.helper.deleteExistingBackupFile( + namespace: self.testNamespace, + recordId: "wallet-record", + locations: locations + ) + } + await precedenceStub.waitUntilBlocked() + precedenceDelete.cancel() + precedenceStub.releaseBlockedDelete() + + do { + try await precedenceDelete.value + XCTFail("expected cancellation to take precedence over the earlier delete error") + } catch is CancellationError {} + + XCTAssertEqual(precedenceStub.deletedURLs, [precedenceURLs[1]]) + let precedenceVisible = try await precedenceFixture.index.currentOrInitialRecords( + timeout: 1 + ) + XCTAssertEqual(precedenceVisible, [precedenceRecords[0]]) + } + + @MainActor + func testNamespaceDeleteHidesLocalDescendants() async throws { + let localFixture = makeICloudMetadataFixture() + defer { localFixture.removeContainer() } + let localURL = try localFixture.helper.namespaceDirectoryReadURL(namespace: testNamespace) + let localChild = localURL.appendingPathComponent("wallet.json") + try writeTestBackup(at: localChild) + let localRecords = [ + metadataRecord( + name: localURL.lastPathComponent, + parentPath: localURL.deletingLastPathComponent().path + ), + metadataRecord(name: localChild.lastPathComponent, parentPath: localURL.path), + ] + let localInitial = Task { try await localFixture.index.currentOrInitialRecords(timeout: 1) } + await localFixture.source.waitUntilStarted() + localFixture.source.send(.finishedGathering(localRecords)) + _ = try await localInitial.value + + try await localFixture.helper.deleteNamespaceDirectory(namespace: testNamespace) + var visibleRecords = try await localFixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(visibleRecords, []) + localFixture.source.send(.updated(localRecords)) + visibleRecords = try await localFixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(visibleRecords, []) + } + + @MainActor + func testNamespaceDeleteHidesMetadataFallbackDescendants() async throws { + let fallbackStub = CoordinatedDeleteStub(removeFiles: false) + let fallbackFixture = makeICloudMetadataFixture( + coordinatedDeleter: { url, id in + try fallbackStub.delete(url: url, missingItemID: id) + } + ) + defer { fallbackFixture.removeContainer() } + let requestedURL = try fallbackFixture.helper.namespaceDirectoryReadURL( + namespace: testNamespace + ) + let providerURL = URL(fileURLWithPath: "/provider/\(testNamespace)") + let namespaceRecord = ICloudMetadataRecord( + name: testNamespace, + url: providerURL, + resolvedPath: requestedURL.resolvingSymlinksInPath().path + ) + let childRecord = metadataRecord(name: "wallet.json", parentPath: requestedURL.path) + let fallbackInitial = Task { + try await fallbackFixture.index.currentOrInitialRecords(timeout: 1) + } + await fallbackFixture.source.waitUntilStarted() + fallbackFixture.source.send(.finishedGathering([namespaceRecord, childRecord])) + _ = try await fallbackInitial.value + + try await fallbackFixture.helper.deleteNamespaceDirectory(namespace: testNamespace) + + XCTAssertEqual(fallbackStub.deletedURLs, [providerURL]) + var visibleRecords = try await fallbackFixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(visibleRecords, []) + fallbackFixture.source.send(.updated([namespaceRecord, childRecord])) + visibleRecords = try await fallbackFixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(visibleRecords, []) + } + + @MainActor + func testFailedNamespaceDeleteLeavesMetadataVisible() async throws { + let failureStub = CoordinatedDeleteStub(failOnCall: 1, removeFiles: false) + let failureFixture = makeICloudMetadataFixture( + coordinatedDeleter: { url, id in + try failureStub.delete(url: url, missingItemID: id) + } + ) + defer { failureFixture.removeContainer() } + let failureURL = try failureFixture.helper.namespaceDirectoryReadURL( + namespace: testNamespace + ) + let failureChildURL = failureURL.appendingPathComponent("wallet.json") + try writeTestBackup(at: failureChildURL) + let failureRecord = metadataRecord( + name: failureURL.lastPathComponent, + parentPath: failureURL.deletingLastPathComponent().path + ) + let failureInitial = Task { + try await failureFixture.index.currentOrInitialRecords(timeout: 1) + } + await failureFixture.source.waitUntilStarted() + failureFixture.source.send(.finishedGathering([failureRecord])) + _ = try await failureInitial.value + + do { + try await failureFixture.helper.deleteNamespaceDirectory(namespace: testNamespace) + XCTFail("expected namespace delete failure") + } catch CloudStorageError.UploadFailed {} + + let failureRecords = try await failureFixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(failureRecords, [failureRecord]) + } + + @MainActor + func testNotFoundNamespaceDeleteLeavesMetadataVisible() async throws { + let notFoundStub = CoordinatedDeleteStub(notFoundOnCalls: [1], removeFiles: false) + let fixture = makeICloudMetadataFixture( + coordinatedDeleter: { url, id in + try notFoundStub.delete(url: url, missingItemID: id) + } + ) + defer { fixture.removeContainer() } + let requestedURL = try fixture.helper.namespaceDirectoryReadURL(namespace: testNamespace) + let providerURL = URL(fileURLWithPath: "/provider/\(testNamespace)") + let record = ICloudMetadataRecord( + name: testNamespace, + url: providerURL, + resolvedPath: requestedURL.resolvingSymlinksInPath().path + ) + let initial = Task { try await fixture.index.currentOrInitialRecords(timeout: 1) } + await fixture.source.waitUntilStarted() + fixture.source.send(.finishedGathering([record])) + _ = try await initial.value + + do { + try await fixture.helper.deleteNamespaceDirectory(namespace: testNamespace) + XCTFail("expected namespace NotFound") + } catch CloudStorageError.NotFound {} + + let records = try await fixture.index.currentOrInitialRecords(timeout: 1) + XCTAssertEqual(records, [record]) + } +} + @MainActor private struct ICloudMetadataFixture { let containerURL: URL let source: MetadataQuerySourceSpy + let index: ICloudMetadataIndex let helper: ICloudDriveHelper func removeContainer() { @@ -1082,6 +1890,93 @@ private struct ICloudMetadataFixture { } } +@MainActor +private final class MetadataTestClock { + private(set) var date: Date + + init(date: Date) { + self.date = date + } + + func advance(by duration: TimeInterval) { + date = date.addingTimeInterval(duration) + } +} + +private final class CoordinatedDeleteStub: @unchecked Sendable { + private let lock = NSLock() + private let failOnCall: Int? + private let blockOnCall: Int? + private let notFoundOnCalls: Set + private let removeFiles: Bool + private let redirectedURL: URL? + private let blocked = DispatchSemaphore(value: 0) + private let releaseBlock = DispatchSemaphore(value: 0) + private var calls = 0 + private var urls: [URL] = [] + + init( + failOnCall: Int? = nil, + blockOnCall: Int? = nil, + notFoundOnCalls: Set = [], + removeFiles: Bool = true, + redirectTo redirectedURL: URL? = nil + ) { + self.failOnCall = failOnCall + self.blockOnCall = blockOnCall + self.notFoundOnCalls = notFoundOnCalls + self.removeFiles = removeFiles + self.redirectedURL = redirectedURL + } + + var callCount: Int { + lock.withLock { calls } + } + + var deletedURLs: [URL] { + lock.withLock { urls } + } + + func delete(url: URL, missingItemID _: String) throws -> URL { + let call = lock.withLock { + calls += 1 + return calls + } + if call == blockOnCall { + blocked.signal() + releaseBlock.wait() + } + if notFoundOnCalls.contains(call) { + throw CloudStorageError.NotFound(url.lastPathComponent) + } + if call == failOnCall { + throw CloudStorageError.UploadFailed("injected delete failure") + } + + if removeFiles { + try FileManager.default.removeItem(at: url) + } + lock.withLock { + urls.append(url) + } + + return redirectedURL ?? url + } + + func waitUntilBlocked() async { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + self.blocked.wait() + continuation.resume() + } + } + } + + func releaseBlockedDelete() { + releaseBlock.signal() + } +} + @MainActor private final class MetadataQuerySourceSpy: ICloudMetadataQuerySource { private let startResults: [Bool] @@ -1132,6 +2027,64 @@ private final class MetadataQuerySourceSpy: ICloudMetadataQuerySource { private var startWaiters: [(count: Int, expectation: XCTestExpectation)] = [] } +@MainActor +private final class MetadataSettleSleepSpy { + private let blockedCalls: Set + private var continuations: [Int: CheckedContinuation] = [:] + private var callWaiters: [(count: Int, expectation: XCTestExpectation)] = [] + private(set) var durations: [TimeInterval] = [] + + init(blockedCalls: Set) { + self.blockedCalls = blockedCalls + } + + func sleep(for duration: TimeInterval) async throws { + try Task.checkCancellation() + durations.append(duration) + let call = durations.count + callWaiters + .filter { $0.count <= call } + .forEach { $0.expectation.fulfill() } + callWaiters.removeAll { $0.count <= call } + + guard blockedCalls.contains(call) else { return } + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + continuations[call] = continuation + } + } onCancel: { + Task { @MainActor [weak self] in + self?.cancel(call: call) + } + } + } + + func waitUntilCalled(count: Int, timeout: TimeInterval = 1) async { + guard durations.count < count else { return } + + let expectation = XCTestExpectation(description: "metadata settle sleep call \(count)") + callWaiters.append((count, expectation)) + + let result = await XCTWaiter.fulfillment(of: [expectation], timeout: timeout) + callWaiters.removeAll { $0.expectation === expectation } + + XCTAssertEqual( + result, + .completed, + "metadata settle sleep reached \(durations.count) of \(count) expected call(s)" + ) + } + + func resume(call: Int) { + continuations.removeValue(forKey: call)?.resume() + } + + private func cancel(call: Int) { + continuations.removeValue(forKey: call)?.resume(throwing: CancellationError()) + } +} + private final class SilentNamespaceProbeTestState: @unchecked Sendable { private let lock = NSLock() private var currentTime: TimeInterval = 0 diff --git a/ios/CoveTests/CloudBackupPresentationCoordinatorTests.swift b/ios/CoveTests/CloudBackupPresentationCoordinatorTests.swift index 07e4a3b97..c68e3e970 100644 --- a/ios/CoveTests/CloudBackupPresentationCoordinatorTests.swift +++ b/ios/CoveTests/CloudBackupPresentationCoordinatorTests.swift @@ -4,6 +4,80 @@ import SwiftUI import XCTest final class CloudBackupPresentationCoordinatorTests: XCTestCase { + @MainActor + func testVerificationActionWaitsForMatchingPresenterReadiness() throws { + let transitions = PresentationTransitionCoordinator() + let handoff = + PresentationActionHandoff() + transitions.present(.verificationPrompt) + let transition = try XCTUnwrap( + transitions.dismissCurrentPresentationForTransition() + ) + handoff.stage( + action: .startVerification(.rootPrompt), + presentation: .verificationPrompt, + transition: transition + ) + var dispatchedAction: CloudBackupManagerAction? + + XCTAssertNil(dispatchedAction) + + handoff.presenterDidBecomeReady( + transition.readinessRequestID, + currentPresentation: .verificationPrompt, + isHostAvailable: true, + using: transitions + ) { dispatchedAction = $0 } + + guard case .startVerification(.rootPrompt) = dispatchedAction else { + return XCTFail("Expected root-prompt verification after presenter readiness") + } + } + + @MainActor + func testDetailPromptActionWaitsForPromptDismissal() throws { + var dispatched: [CloudBackupDetailDeferredAction] = [] + let presenter = CloudBackupDetailPresenter { dispatched.append($0) } + presenter.transitions.present(.alert(.disableFinalConfirmation)) + + presenter.dismiss(.alert(.disableFinalConfirmation), then: .dispatch(.disableCloudBackup)) + + XCTAssertNil(presenter.transitions.currentPresentation) + XCTAssertTrue(dispatched.isEmpty) + let requestID = try XCTUnwrap(presenter.transitions.readinessRequestID) + + presenter.presenterDidBecomeReady(UUID()) + XCTAssertTrue(dispatched.isEmpty, "a stale readiness signal must not run the action") + + presenter.presenterDidBecomeReady(requestID) + XCTAssertEqual(dispatched, [.dispatch(.disableCloudBackup)]) + XCTAssertNil(presenter.transitions.readinessRequestID) + } + + @MainActor + func testDetailPromptActionRunsAtOnceWithoutAPrompt() { + var dispatched: [CloudBackupDetailDeferredAction] = [] + let presenter = CloudBackupDetailPresenter { dispatched.append($0) } + + presenter.dismiss(.alert(.disableFinalConfirmation), then: .startVerification) + + XCTAssertEqual(dispatched, [.startVerification]) + } + + @MainActor + func testDetailHostDisappearanceDropsStagedAction() throws { + var dispatched: [CloudBackupDetailDeferredAction] = [] + let presenter = CloudBackupDetailPresenter { dispatched.append($0) } + presenter.transitions.present(.dialog(.recoverOtherBackups)) + presenter.dismiss(.dialog(.recoverOtherBackups), then: .dispatch(.recoverOtherBackups)) + let requestID = try XCTUnwrap(presenter.transitions.readinessRequestID) + + presenter.hostDidDisappear() + presenter.presenterDidBecomeReady(requestID) + + XCTAssertTrue(dispatched.isEmpty) + } + func testOnboardingPolicySuppressesVerificationPrompt() { let context = presentableContext(presentationPolicy: .onboarding) diff --git a/ios/CoveTests/OnboardingBackupViewsTests.swift b/ios/CoveTests/OnboardingBackupViewsTests.swift index 46110aad9..069842ce9 100644 --- a/ios/CoveTests/OnboardingBackupViewsTests.swift +++ b/ios/CoveTests/OnboardingBackupViewsTests.swift @@ -55,6 +55,8 @@ final class OnboardingBackupViewsTests: XCTestCase { .uploadingInitialBackup(progress: progress), verificationPresentation: hidden ) + XCTAssertEqual(defaultCopy.title, "Checking for existing backups...") + XCTAssertEqual(uploadCopy.title, "Creating your encrypted backup...") let promptOrDiscoveryStates: [CloudBackupEnableFlow?] = [ nil, diff --git a/ios/CoveTests/PasskeyProviderImplTests.swift b/ios/CoveTests/PasskeyProviderImplTests.swift index acc2c4fb8..893b6ea25 100644 --- a/ios/CoveTests/PasskeyProviderImplTests.swift +++ b/ios/CoveTests/PasskeyProviderImplTests.swift @@ -4,6 +4,103 @@ import CoveCore import XCTest final class PasskeyProviderImplTests: XCTestCase { + func testDiagnosticsMeasureMonotonicRequestDurationsAndAnchorState() { + let diagnostics = PasskeyRequestDiagnostics(rpId: "example.com", requestMode: .targeted) + let submission = ContinuousClock.Instant.now + let anchor = submission.advanced(by: .milliseconds(12)) + let completion = submission.advanced(by: .milliseconds(34)) + + diagnostics.markNativeSubmission(at: submission) + diagnostics.markPresentationAnchorRequest( + at: anchor, + isAvailable: true, + sceneActivation: "foregroundActive" + ) + diagnostics.markCompletion(at: completion) + + let fields = diagnostics.logFields() + XCTAssertTrue(fields.contains("request_id=")) + XCTAssertEqual(fields.components(separatedBy: "rpId=").count, 2) + XCTAssertTrue(fields.contains("rpId=example.com")) + XCTAssertTrue(fields.contains("operation=targeted")) + XCTAssertTrue(fields.contains("request_mode=targeted")) + XCTAssertTrue(fields.contains("submission_to_anchor_ms=12")) + XCTAssertTrue(fields.contains("submission_to_completion_ms=34")) + XCTAssertTrue(fields.contains("presentation_anchor_requested=true")) + XCTAssertTrue(fields.contains("presentation_anchor_available=true")) + XCTAssertTrue(fields.contains("presentation_scene_activation=foregroundActive")) + } + + func testNSErrorMetadataContainsOnlyDomainAndCode() { + let error = NSError( + domain: "com.example.passkey", + code: 42, + userInfo: [ + NSLocalizedDescriptionKey: "private localized description", + "private": "private user info", + ] + ) + + let metadata = passkeyNSErrorMetadata(error) + + XCTAssertEqual( + metadata, + "error_domain=com.example.passkey error_code=42" + ) + XCTAssertFalse(metadata.contains("private")) + XCTAssertFalse(metadata.contains("localized")) + XCTAssertFalse(metadata.contains("userInfo")) + } + + func testNSErrorMetadataIncludesUnderlyingErrorChainWithoutText() { + let inner = NSError( + domain: "com.apple.AuthenticationServicesCore.ASCAuthorizationError", + code: 6, + userInfo: [NSLocalizedDescriptionKey: "private inner description"] + ) + let error = NSError( + domain: "com.apple.AuthenticationServices.AuthorizationError", + code: 1001, + userInfo: [ + NSLocalizedDescriptionKey: "private outer description", + NSUnderlyingErrorKey: inner, + ] + ) + + let metadata = passkeyNSErrorMetadata(error) + + XCTAssertEqual( + metadata, + "error_domain=com.apple.AuthenticationServices.AuthorizationError error_code=1001 " + + "underlying=com.apple.AuthenticationServicesCore.ASCAuthorizationError:6" + ) + XCTAssertFalse(metadata.contains("private")) + } + + func testNonAuthorizationFailureReturnsOnlySanitizedMetadata() { + let delegate = PasskeyDelegate(context: .discoverAssertion) + let request = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: "example.com" + ).createCredentialAssertionRequest(challenge: Data(count: 32)) + delegate.authorizationController( + controller: ASAuthorizationController(authorizationRequests: [request]), + didCompleteWithError: NSError( + domain: "com.example.passkey", + code: 42, + userInfo: [NSLocalizedDescriptionKey: "private localized description"] + ) + ) + + XCTAssertThrowsError(try delegate.waitForResult {}) { error in + let description = String(describing: error) + + XCTAssertTrue(description.contains("com.example.passkey")) + XCTAssertTrue(description.contains("42")) + XCTAssertFalse(description.contains("private")) + XCTAssertFalse(description.contains("localized")) + } + } + func testInteractiveRequestTimeoutCancelsOnMainQueueAndReturnsPresentedFailure() { let delegate = PasskeyDelegate(context: .registration, timeout: 0.01) let cancellation = expectation(description: "controller cancelled") @@ -49,6 +146,18 @@ final class PasskeyProviderImplTests: XCTestCase { userInfo: [NSLocalizedDescriptionKey: "late callback"] ) ) + let terminalDiagnostics = delegate.diagnostics.logFields() + + delegate.authorizationController( + controller: ASAuthorizationController(authorizationRequests: [request]), + didCompleteWithError: NSError( + domain: "PasskeyProviderImplTests", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "second late callback"] + ) + ) + + XCTAssertEqual(delegate.diagnostics.logFields(), terminalDiagnostics) XCTAssertThrowsError( try delegate.waitForResult { diff --git a/ios/CoveTests/PresentationTransitionCoordinatorTests.swift b/ios/CoveTests/PresentationTransitionCoordinatorTests.swift index cced4ec61..e573dac83 100644 --- a/ios/CoveTests/PresentationTransitionCoordinatorTests.swift +++ b/ios/CoveTests/PresentationTransitionCoordinatorTests.swift @@ -123,4 +123,271 @@ final class PresentationTransitionCoordinatorTests: XCTestCase { return XCTFail("Expected a stale readiness signal to leave the new presentation active") } } + + @MainActor + func testPendingActionDoesNotDispatchBeforeReadiness() throws { + let coordinator = PresentationTransitionCoordinator() + let handoff = PresentationActionHandoff() + coordinator.present(.first) + let transition = try XCTUnwrap( + coordinator.dismissCurrentPresentationForTransition() + ) + handoff.stage( + action: .sensitive, + presentation: .first, + transition: transition + ) + + let dispatched: [Presentation] = [] + + XCTAssertNotNil(handoff.pendingAction) + XCTAssertTrue(dispatched.isEmpty) + XCTAssertTrue(coordinator.isAwaitingPresenterReadiness) + } + + @MainActor + func testPendingActionDispatchesExactlyOnceAfterMatchingReadiness() throws { + let coordinator = PresentationTransitionCoordinator() + let handoff = PresentationActionHandoff() + coordinator.present(.first) + let transition = try XCTUnwrap( + coordinator.dismissCurrentPresentationForTransition() + ) + handoff.stage( + action: .sensitive, + presentation: .first, + transition: transition + ) + + var dispatched: [Presentation] = [] + handoff.presenterDidBecomeReady( + transition.readinessRequestID, + currentPresentation: .first, + isHostAvailable: true, + using: coordinator + ) { dispatched.append($0) } + + XCTAssertEqual(dispatched, [.sensitive]) + XCTAssertNil(coordinator.currentPresentation) + XCTAssertNil(coordinator.queuedPresentation) + + handoff.presenterDidBecomeReady( + transition.readinessRequestID, + currentPresentation: .first, + isHostAvailable: true, + using: coordinator + ) { dispatched.append($0) } + + XCTAssertEqual(dispatched, [.sensitive]) + } + + @MainActor + func testStaleReadinessCancelsPendingAction() throws { + let coordinator = PresentationTransitionCoordinator() + let handoff = PresentationActionHandoff() + coordinator.present(.first) + let staleTransition = try XCTUnwrap( + coordinator.dismissCurrentPresentationForTransition() + ) + handoff.stage( + action: .sensitive, + presentation: .first, + transition: staleTransition + ) + + coordinator.hostDidDisappear() + coordinator.present(.second) + coordinator.present(.first) + let currentTransition = try XCTUnwrap(coordinator.transitionRequest) + var dispatched: [Presentation] = [] + + handoff.presenterDidBecomeReady( + staleTransition.readinessRequestID, + currentPresentation: .first, + isHostAvailable: true, + using: coordinator + ) { dispatched.append($0) } + + XCTAssertNil(handoff.pendingAction) + XCTAssertEqual(coordinator.transitionRequest, currentTransition) + XCTAssertTrue(dispatched.isEmpty) + } + + @MainActor + func testUnrelatedReadinessDoesNotCancelPendingAction() throws { + let coordinator = PresentationTransitionCoordinator() + let handoff = PresentationActionHandoff() + coordinator.present(.first) + let transition = try XCTUnwrap( + coordinator.dismissCurrentPresentationForTransition() + ) + handoff.stage( + action: .sensitive, + presentation: .first, + transition: transition + ) + + var dispatched: [Presentation] = [] + handoff.presenterDidBecomeReady( + UUID(), + currentPresentation: .first, + isHostAvailable: true, + using: coordinator + ) { dispatched.append($0) } + + XCTAssertNotNil(handoff.pendingAction) + XCTAssertTrue(dispatched.isEmpty) + + handoff.presenterDidBecomeReady( + transition.readinessRequestID, + currentPresentation: .first, + isHostAvailable: true, + using: coordinator + ) { dispatched.append($0) } + + XCTAssertEqual(dispatched, [.sensitive]) + } + + @MainActor + func testChangedPromptCancelsPendingActionWithoutReopeningOldPrompt() throws { + let coordinator = PresentationTransitionCoordinator() + let handoff = PresentationActionHandoff() + coordinator.present(.first) + let transition = try XCTUnwrap( + coordinator.dismissCurrentPresentationForTransition() + ) + handoff.stage( + action: .sensitive, + presentation: .first, + transition: transition + ) + + var dispatched: [Presentation] = [] + handoff.presenterDidBecomeReady( + transition.readinessRequestID, + currentPresentation: .second, + isHostAvailable: true, + using: coordinator + ) { dispatched.append($0) } + + XCTAssertNil(handoff.pendingAction) + XCTAssertNil(coordinator.currentPresentation) + XCTAssertNil(coordinator.queuedPresentation) + XCTAssertTrue(dispatched.isEmpty) + } + + @MainActor + func testCompetingQueuedPromptCancelsPendingActionAndPresentsPrompt() throws { + let coordinator = PresentationTransitionCoordinator() + let handoff = PresentationActionHandoff() + coordinator.present(.first) + let transition = try XCTUnwrap( + coordinator.dismissCurrentPresentationForTransition() + ) + handoff.stage( + action: .sensitive, + presentation: .first, + transition: transition + ) + coordinator.queue(.second) + + var dispatched: [Presentation] = [] + handoff.presenterDidBecomeReady( + transition.readinessRequestID, + currentPresentation: .first, + isHostAvailable: true, + using: coordinator + ) { dispatched.append($0) } + + XCTAssertNil(handoff.pendingAction) + guard case .second = coordinator.currentPresentation?.item else { + return XCTFail("Expected the competing queued prompt after readiness") + } + + XCTAssertNil(coordinator.queuedPresentation) + XCTAssertTrue(dispatched.isEmpty) + } + + @MainActor + func testReadinessWithoutPendingActionFallsBackToHostOrCoordinator() throws { + let coordinator = PresentationTransitionCoordinator() + let handoff = PresentationActionHandoff() + coordinator.present(.first) + coordinator.present(.second) + let requestID = try XCTUnwrap(coordinator.readinessRequestID) + var fallbackRequests: [UUID] = [] + var dispatched: [Presentation] = [] + + handoff.presenterDidBecomeReady( + requestID, + currentPresentation: nil, + isHostAvailable: true, + using: coordinator, + withoutPendingAction: { fallbackRequests.append($0) } + ) { dispatched.append($0) } + + XCTAssertEqual(fallbackRequests, [requestID]) + XCTAssertTrue(coordinator.isAwaitingPresenterReadiness) + + handoff.presenterDidBecomeReady( + requestID, + currentPresentation: nil, + isHostAvailable: true, + using: coordinator + ) { dispatched.append($0) } + + guard case .second = coordinator.currentPresentation?.item else { + return XCTFail("Expected the coordinator to present the queued presentation") + } + + XCTAssertTrue(dispatched.isEmpty) + } + + @MainActor + func testHostDisappearanceCancelsPendingAction() throws { + let coordinator = PresentationTransitionCoordinator() + let handoff = PresentationActionHandoff() + coordinator.present(.first) + let transition = try XCTUnwrap( + coordinator.dismissCurrentPresentationForTransition() + ) + handoff.stage( + action: .sensitive, + presentation: .first, + transition: transition + ) + + handoff.hostDidDisappear(using: coordinator) + + XCTAssertNil(handoff.pendingAction) + XCTAssertFalse(coordinator.hasPresentationActivity) + } + + @MainActor + func testMatchingReadinessDoesNotReopenQueuedOldPrompt() throws { + let coordinator = PresentationTransitionCoordinator() + let handoff = PresentationActionHandoff() + coordinator.present(.first) + let transition = try XCTUnwrap( + coordinator.dismissCurrentPresentationForTransition() + ) + coordinator.queue(.first) + handoff.stage( + action: .sensitive, + presentation: .first, + transition: transition + ) + + var dispatched: [Presentation] = [] + handoff.presenterDidBecomeReady( + transition.readinessRequestID, + currentPresentation: .first, + isHostAvailable: true, + using: coordinator + ) { dispatched.append($0) } + + XCTAssertEqual(dispatched, [.sensitive]) + XCTAssertNil(coordinator.currentPresentation) + XCTAssertNil(coordinator.queuedPresentation) + } } diff --git a/ios/CoveTests/WalletTransitionTests.swift b/ios/CoveTests/WalletTransitionTests.swift index 0b7b5fbb6..2a0a9f5e8 100644 --- a/ios/CoveTests/WalletTransitionTests.swift +++ b/ios/CoveTests/WalletTransitionTests.swift @@ -251,6 +251,45 @@ final class WalletTransitionTests: XCTestCase { XCTAssertFalse(expectedManager.canApplyReconcileMessages) } + @MainActor + func testClearingWalletManagerInvalidatesInFlightLoad() async throws { + let expectedManager = WalletManager(preview: .only) + var loadStarted = false + var resumeLoad: CheckedContinuation? + let cache = ManagerCache( + backgroundScanTaskHandler: BackgroundScanTaskHandler(), + loadWalletManager: { _, _ in + loadStarted = true + await withCheckedContinuation { (continuation: CheckedContinuation) in + resumeLoad = continuation + } + + return expectedManager + } + ) + let delegate = TestWalletManagerDelegate() + + let load = Task { @MainActor in + try await cache.ensureWalletManagerLoaded(id: expectedManager.id, delegate: delegate) + } + try await waitUntilWalletLoadStarts({ loadStarted }) { + load.cancel() + resumeLoad?.resume() + resumeLoad = nil + } + + cache.clearWalletManager() + resumeLoad?.resume() + + do { + _ = try await load.value + XCTFail("cleared wallet load must be cancelled") + } catch is CancellationError {} + + XCTAssertNil(cache.cachedWalletManager(id: expectedManager.id)) + XCTAssertFalse(expectedManager.canApplyReconcileMessages) + } + @MainActor func testCurrentWalletLoadReceivesWinnerWhenAnotherWaiterIsStale() async throws { let expectedManager = WalletManager(preview: .only) @@ -305,6 +344,54 @@ final class WalletTransitionTests: XCTestCase { cache.clearWalletManager() } + @MainActor + func testClearingWalletManagerAlsoClearsRelatedSendFlowManager() async throws { + let walletManager = WalletManager(preview: .only) + let presenter = SendFlowPresenter( + routing: TestSendFlowRouting(), + manager: walletManager + ) + let sendFlowManager = SendFlowManager( + TestSendFlowRustManager(walletId: walletManager.id), + presenter: presenter + ) + let cache = ManagerCache( + backgroundScanTaskHandler: BackgroundScanTaskHandler(), + makeSendFlowManager: { _, _ in sendFlowManager }, + loadWalletManager: { _, _ in walletManager } + ) + + _ = try await cache.ensureWalletManagerLoaded(id: walletManager.id, delegate: TestWalletManagerDelegate()) + _ = try cache.ensureSendFlowManager(walletManager, presenter: presenter) + + cache.clearWalletManager() + + XCTAssertNil(cache.cachedWalletManager(id: walletManager.id)) + XCTAssertNil(cache.cachedSendFlowManager(id: walletManager.id)) + XCTAssertFalse(walletManager.canApplyReconcileMessages) + XCTAssertFalse(sendFlowManager.canApplyReconcileMessages) + } + + @MainActor + func testClearingWalletManagerAlsoClearsCoinControlManager() async throws { + let walletManager = WalletManager(preview: .only) + let coinControlManager = CoinControlManager( + RustCoinControlManager.previewNew(outputCount: 1, changeCount: 0) + ) + let cache = ManagerCache( + backgroundScanTaskHandler: BackgroundScanTaskHandler(), + loadWalletManager: { _, _ in walletManager } + ) + + _ = try await cache.ensureWalletManagerLoaded(id: walletManager.id, delegate: TestWalletManagerDelegate()) + cache.setCoinControlManager(coinControlManager) + + cache.clearWalletManager() + + XCTAssertNil(cache.coinControlManager) + XCTAssertFalse(coinControlManager.canApplyReconcileMessages) + } + func testRepeatedInvalidationAdvancesWhenCacheIsEmpty() { var state = WalletManagerCacheState() state.invalidate(.wallet("wallet-b")) diff --git a/justfile b/justfile index 85ca60665..7c0cfe8ce 100644 --- a/justfile +++ b/justfile @@ -150,10 +150,13 @@ alias bir := build-ios-release # keep this path aligned with Xcode archives; passkeys fail in TestFlight if CLI signing diverges # xtask verifies Apple's AASA CDN before upload -# [long, external] Bump iOS build, build release bindings, then upload to TestFlight +# use this when the build number was already bumped and committed +# [long, external] Upload to TestFlight without bumping, copy previous test notes, and add to me-only [group('build')] -testflight: - just xtask testflight +upload-testflight: + just xtask upload-testflight + +alias utf := upload-testflight # [long] Build iOS debug for device [group('build')] @@ -403,6 +406,32 @@ fix *flags="": bump type targets="": just xtask bump-version {{ type }} {{ if targets != "" { "--targets " + targets } else { "" } }} +# xtask restores the iOS build number if the build or archive fails; a failed upload keeps it for upload-testflight +# [long, external] Bump iOS build, rebuild release bindings, and upload to TestFlight +[group('release')] +release-ios: + just xtask testflight + +alias reli := release-ios +alias tf := release-ios +alias testflight := release-ios + +# xtask restores the Android versionCode if Google Play has not been invoked +# [long, external] Bump Android build, build signed artifacts, and release to Google Play internal testing +[group('release')] +release-android: + just xtask release-android + +alias rela := release-android + +# use this when the versionCode was already bumped and the signed bundle already exists +# [external] Upload the existing signed Android bundle to Google Play internal testing without bumping +[group('release')] +upload-google-play: + just xtask upload-google-play + +alias ugp := upload-google-play + # ------------------------------------------------------------------------------ # xcode # ------------------------------------------------------------------------------ diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9217f5034..429d19e2f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -261,7 +261,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -272,7 +272,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -473,6 +473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -1231,7 +1232,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1332,9 +1333,11 @@ dependencies = [ "cove-ur", "cove-util", "csv", + "ctor", "data-encoding", "derive_more", "dirs", + "dtor", "eyre", "flate2", "flume", @@ -1763,6 +1766,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + [[package]] name = "ctr" version = "0.7.0" @@ -1841,6 +1854,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_more" version = "2.1.1" @@ -1920,7 +1939,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1940,6 +1959,15 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" +[[package]] +name = "dtor" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3f6c362b2bbd7063090886afd78978f5dbf1f11da7efcc40f81d32c943fac1" +dependencies = [ + "linktime-proc-macro", +] + [[package]] name = "dunce" version = "1.0.5" @@ -2021,7 +2049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3109,7 +3137,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3209,6 +3237,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + [[package]] name = "keccak" version = "0.1.6" @@ -3305,6 +3351,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "link-section" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3575,7 +3633,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3598,6 +3656,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -3811,6 +3875,16 @@ dependencies = [ "hmac 0.13.0", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4006,6 +4080,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4166,6 +4246,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -4475,7 +4556,9 @@ checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -4486,6 +4569,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -4519,7 +4603,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -4622,7 +4706,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4681,7 +4765,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4699,7 +4783,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5003,6 +5087,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5034,6 +5127,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint 0.4.6", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -5065,7 +5170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5180,7 +5285,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5255,6 +5360,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5754,6 +5889,12 @@ dependencies = [ "ctutils", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -6026,7 +6167,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6359,7 +6500,9 @@ dependencies = [ "gif", "hex", "image", + "jsonwebtoken", "qrcode", + "reqwest 0.13.3", "serde", "serde_json", "tempfile", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4394af7e5..358f15cb3 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -314,6 +314,8 @@ serde_bytes = "0.11.17" tokio = { workspace = true, features = ["rt", "test-util", "macros"] } uniffi = { workspace = true, features = ["bindgen-tests"] } +ctor = "1.0.13" +dtor = "1.0.6" [profile.dev] diff --git a/rust/crates/cove-cspp/src/cspp.rs b/rust/crates/cove-cspp/src/cspp.rs index 3ec4707ab..67744baec 100644 --- a/rust/crates/cove-cspp/src/cspp.rs +++ b/rust/crates/cove-cspp/src/cspp.rs @@ -25,6 +25,13 @@ static MASTER_KEY_CACHE: LazyLock>> = pub struct Cspp(S); +/// Snapshot of the active master-key entries in the CSPP store +/// +/// The storage keys and serialized values remain private to CSPP so callers can +/// restore a failed cross-store operation without depending on its key layout +#[derive(Clone)] +pub struct ActiveMasterKeySnapshot(StoredMasterKeyEntries); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MasterKeyPromotionActiveState { Prior, @@ -90,6 +97,31 @@ impl Cspp { MASTER_KEY_CACHE.store(None); } + /// Captures the active master-key entries for a later rollback + pub fn capture_active_master_key_snapshot(&self) -> ActiveMasterKeySnapshot { + let _guard = INIT_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + + ActiveMasterKeySnapshot(self.read_active_entries()) + } + + /// Restores active master-key entries captured before a failed operation + pub fn restore_active_master_key_snapshot( + &self, + snapshot: &ActiveMasterKeySnapshot, + ) -> Result<(), CsppError> { + let _guard = INIT_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + + if let Err(error) = self.restore_active_entries(&snapshot.0) { + Self::clear_cached_master_key(); + + return Err(error); + } + + Self::refresh_cache_from_entries(&snapshot.0); + + Ok(()) + } + /// Loads the master key from the store, or generates and saves a new one /// /// Uses double-checked locking to prevent a TOCTOU race where two threads @@ -887,6 +919,45 @@ mod tests { assert_eq!(loaded.as_bytes(), replacement.as_bytes()); } + #[test] + fn active_master_key_snapshot_restores_store_and_cache() { + let _guard = CACHE_TEST_LOCK.lock().unwrap(); + Cspp::::reset_cache(); + + let cspp = mock_cspp(); + let original = MasterKey::generate(); + let replacement = MasterKey::generate(); + cspp.save_master_key(&original).unwrap(); + let snapshot = cspp.capture_active_master_key_snapshot(); + + cspp.save_master_key(&replacement).unwrap(); + assert_eq!(cspp.get_or_create_master_key().unwrap().as_bytes(), replacement.as_bytes()); + + cspp.restore_active_master_key_snapshot(&snapshot).unwrap(); + assert_eq!(cspp.get_or_create_master_key().unwrap().as_bytes(), original.as_bytes()); + assert_eq!( + cspp.load_master_key_from_store().unwrap().unwrap().as_bytes(), + original.as_bytes() + ); + } + + #[test] + fn failed_active_master_key_snapshot_restore_clears_cache() { + let _guard = CACHE_TEST_LOCK.lock().unwrap(); + Cspp::::clear_cached_master_key(); + + let store = FailNthStore::new(); + let cspp = Cspp::new(store.clone()); + cspp.save_master_key(&MasterKey::generate()).unwrap(); + let snapshot = cspp.capture_active_master_key_snapshot(); + cspp.save_master_key(&MasterKey::generate()).unwrap(); + assert!(MASTER_KEY_CACHE.load().is_some()); + store.fail_nth_mutation(1); + + assert!(cspp.restore_active_master_key_snapshot(&snapshot).is_err()); + assert!(MASTER_KEY_CACHE.load().is_none()); + } + #[test] fn delete_master_key_clears_warm_cache() { let _guard = CACHE_TEST_LOCK.lock().unwrap(); diff --git a/rust/crates/cove-cspp/src/lib.rs b/rust/crates/cove-cspp/src/lib.rs index 7ebfc6920..03e9fb025 100644 --- a/rust/crates/cove-cspp/src/lib.rs +++ b/rust/crates/cove-cspp/src/lib.rs @@ -111,7 +111,8 @@ pub mod store; pub mod wallet_crypto; pub use cspp::{ - Cspp, MasterKeyPromotionActiveState, MasterKeyPromotionEvidence, MasterKeyPromotionStatus, + ActiveMasterKeySnapshot, Cspp, MasterKeyPromotionActiveState, MasterKeyPromotionEvidence, + MasterKeyPromotionStatus, }; pub use error::CsppError; pub use store::CsppStore; diff --git a/rust/src/app.rs b/rust/src/app.rs index b415a36e3..cb932b3d8 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -1,11 +1,12 @@ //! `AppManager` pub mod alert_state; +pub mod full_wipe; pub mod reconcile; use crate::database::global_config::SelectedWalletTarget; use std::{ - sync::Arc, + sync::{Arc, LazyLock}, time::{Duration, UNIX_EPOCH}, }; @@ -20,7 +21,9 @@ use crate::{ client::{FIAT_CLIENT, PriceResponse}, }, keychain::{Keychain, KeychainError}, - manager::cloud_backup_manager::{CLOUD_BACKUP_MANAGER, CloudBackupKeychain}, + manager::cloud_backup_manager::{ + CLOUD_BACKUP_MANAGER, CloudBackupKeychain, CloudBackupRecoveryCoverage, + }, manager::deferred_dispatch::{DeferredDispatch, Dispatchable}, manager::key_teleport_manager::RustKeyTeleportManager, manager::reconcile_channel::ReconcileChannel, @@ -34,7 +37,7 @@ use crate::{ PreparedFullWipe, PreparedWalletDeletion, WalletDeletionFailure, WalletDeletionIntent, WalletInventoryFailure, targets_from_inventory, }, - wallet::metadata::{WalletId, WalletMetadata, WalletType}, + wallet::metadata::{WalletId, WalletMetadata}, wallet_lifecycle::{ ShutdownAttemptId, ShutdownDeadlineTier, WalletLifecycleCoordinator, WalletLifecycleFailure, }, @@ -42,10 +45,10 @@ use crate::{ use cove_macros::impl_default_for; use cove_types::BlockSizeLast; use cove_util::ResultExt as _; +use full_wipe::FullWipeCompletion; use once_cell::sync::OnceCell; use parking_lot::RwLock; use reconcile::{AppStateReconcileMessage as AppMessage, FfiReconcile, Updater}; -use tap::TapFallible as _; use tracing::{debug, error, warn}; pub static APP: OnceCell = OnceCell::new(); @@ -122,6 +125,8 @@ pub enum AppError { pub enum LocalDataResetStage { /// Remaining Cove wallet keychain entries WalletKeychain, + /// Persisted KeyTeleport receive session + KeyTeleport, /// Orphan BDK stores and wallet-data directories WalletArtifacts, /// Cloud Backup local keychain or in-process state @@ -325,21 +330,7 @@ impl App { Updater::send_update(AppMessage::DatabaseUpdated); // reconcile restored settings so frontends update without restart - let config = &Database::global().global_config; - - Updater::send_update(AppMessage::SelectedNetworkChanged(config.selected_network())); - - match config.color_scheme() { - Ok(scheme) => Updater::send_update(AppMessage::ColorSchemeChanged(scheme)), - Err(e) => warn!("failed to read color scheme after import: {e}"), - } - - match config.fiat_currency() { - Ok(fiat) => Updater::send_update(AppMessage::FiatCurrencyChanged(fiat)), - Err(e) => warn!("failed to read fiat currency after import: {e}"), - } - - Updater::send_update(AppMessage::SelectedNodeChanged(config.selected_node())); + send_imported_settings_updates(); } } @@ -466,10 +457,13 @@ impl FfiApp { /// Get wallets that have not been backed up and verified pub fn unverified_wallet_ids(&self) -> Vec { let all_wallets = Database::global().wallets().all().unwrap_or_default(); + let cloud_coverage = LazyLock::get(&CLOUD_BACKUP_MANAGER) + .map(|manager| CloudBackupRecoveryCoverage::load(manager.as_ref())) + .unwrap_or_default(); all_wallets .into_iter() - .filter(|wallet| wallet.wallet_type == WalletType::Hot && !wallet.verified) + .filter(|wallet| cloud_coverage.needs_backup(wallet)) .map(|wallet| wallet.id) .collect::>() } @@ -573,7 +567,9 @@ impl FfiApp { } /// DANGER: This will wipe all wallet data on this device - pub fn dangerous_wipe_all_data(&self) -> Result<(), Error> { + /// + /// Returns the committed post-wipe state the frontend applies before releasing authentication + pub fn dangerous_wipe_all_data(&self) -> Result { run_lifecycle_sync(wipe_all_data_with_tier(ShutdownDeadlineTier::Initial, None)) } @@ -581,7 +577,7 @@ impl FfiApp { pub fn retry_dangerous_wipe_all_data( &self, attempt_id: ShutdownAttemptId, - ) -> Result<(), Error> { + ) -> Result { run_lifecycle_sync(wipe_all_data_with_tier(ShutdownDeadlineTier::Retry, Some(attempt_id))) } @@ -684,7 +680,7 @@ pub(crate) async fn delete_wallet_with_tier( async fn wipe_all_data_with_tier( tier: ShutdownDeadlineTier, retry: Option, -) -> Result<(), AppError> { +) -> Result { let database = Database::global(); // a failed bucket read must stop before actor shutdown or destructive work @@ -718,6 +714,10 @@ async fn wipe_all_data_with_tier( .delete_all_wallet_items() .map_err(|source| local_reset_error(LocalDataResetStage::WalletKeychain, source))?; + prepared + .delete_key_teleport_receive_session() + .map_err(|source| local_reset_error(LocalDataResetStage::KeyTeleport, source))?; + prepared .purge_orphan_wallet_artifacts() .map_err(|source| local_reset_error(LocalDataResetStage::WalletArtifacts, source))?; @@ -741,36 +741,57 @@ async fn wipe_all_data_with_tier( database .dangerous_reset_all_data() - .map_err(|source| local_reset_error(LocalDataResetStage::Database, source))?; - - Ok(()) + .map_err(|source| local_reset_error(LocalDataResetStage::Database, source)) } .await; - if let Err(error) = wipe_result { - return failed_wipe_result(error, prepared.resume_after_failure().await); - } + let completed_onboarding = match wipe_result { + Ok(completed_onboarding) => completed_onboarding, + Err(error) => return Err(failed_wipe_error(error, prepared.resume_after_failure().await)), + }; prepared.complete_after_database_reset().await.map_err(|_| { AppError::WalletLifecycle(WalletLifecycleFailure::CloudBackupRecoveryRequired) })?; - Ok(()) + let completion = FullWipeCompletion::after_reset(completed_onboarding); + completion.publish(); + + Ok(completion) +} + +/// Pushes the stored global settings to frontends after an import replaced the database +/// +/// A setting that cannot be read is logged and skipped, so a read failure never +/// overwrites the frontend's value with a guessed default +fn send_imported_settings_updates() { + let config = &Database::global().global_config; + + Updater::send_update(AppMessage::SelectedNetworkChanged(config.selected_network())); + + match config.color_scheme() { + Ok(scheme) => Updater::send_update(AppMessage::ColorSchemeChanged(scheme)), + Err(e) => warn!("failed to read color scheme after import: {e}"), + } + + match config.fiat_currency() { + Ok(fiat) => Updater::send_update(AppMessage::FiatCurrencyChanged(fiat)), + Err(e) => warn!("failed to read fiat currency after import: {e}"), + } + + Updater::send_update(AppMessage::SelectedNodeChanged(config.selected_node())); } -fn failed_wipe_result( +fn failed_wipe_error( wipe_error: AppError, cloud_recovery: Result< crate::manager::cloud_backup_manager::CloudBackupResetRecovery, crate::manager::cloud_backup_manager::CloudBackupError, >, -) -> Result<(), AppError> { +) -> AppError { match cloud_recovery { - Ok(_) => Err(wipe_error), - - Err(_) => { - Err(AppError::WalletLifecycle(WalletLifecycleFailure::CloudBackupRecoveryRequired)) - } + Ok(_) => wipe_error, + Err(_) => AppError::WalletLifecycle(WalletLifecycleFailure::CloudBackupRecoveryRequired), } } @@ -932,13 +953,7 @@ impl FfiApp { fn finish_wallet_deletion_presentation(&self, id: WalletId) { let database = Database::global(); - Updater::send_update(AppMessage::ClearCachedWalletManager(id.clone())); - - if database.global_config.selected_wallet().as_ref() == Some(&id) { - let _ = database.global_config.clear_selected_wallet().tap_err(|error| { - error!("Unable to clear selected wallet: {error}"); - }); - } + Updater::send_update(AppMessage::ClearCachedWalletManager(id)); let remaining_wallets = database.wallets().all().unwrap_or_default(); if let Some(next_wallet) = remaining_wallets.first() { @@ -1003,6 +1018,8 @@ enum SelectLatestWalletError { mod tests { use super::*; use crate::manager::cloud_backup_manager::{CloudBackupError, CloudBackupResetRecovery}; + use crate::router::SettingsRoute; + use crate::{auth::AuthType, manager::auth_manager::AuthSettings}; #[test] fn orphan_sweep_removes_wallet_data_without_deleting_unknown_entries() { @@ -1064,22 +1081,22 @@ mod tests { fn failed_wipe_preserves_original_error_after_safe_cloud_recovery() { let wipe_error = local_reset_error(LocalDataResetStage::WalletArtifacts, "disk error"); - let result = - failed_wipe_result(wipe_error.clone(), Ok(CloudBackupResetRecovery::SafelyDisabled)); + let error = + failed_wipe_error(wipe_error.clone(), Ok(CloudBackupResetRecovery::SafelyDisabled)); - assert_eq!(result, Err(wipe_error)); + assert_eq!(error, wipe_error); } #[test] fn failed_wipe_requires_recovery_when_cloud_writers_cannot_resume_safely() { let wipe_error = local_reset_error(LocalDataResetStage::WalletArtifacts, "disk error"); - let result = - failed_wipe_result(wipe_error, Err(CloudBackupError::Deferred("resume failed".into()))); + let error = + failed_wipe_error(wipe_error, Err(CloudBackupError::Deferred("resume failed".into()))); assert_eq!( - result, - Err(AppError::WalletLifecycle(WalletLifecycleFailure::CloudBackupRecoveryRequired)) + error, + AppError::WalletLifecycle(WalletLifecycleFailure::CloudBackupRecoveryRequired) ); } @@ -1117,7 +1134,7 @@ mod tests { } #[test] - fn dangerous_wipe_all_data_retains_metadata_until_wallet_secrets_are_deleted() { + fn dangerous_wipe_all_data_preserves_setup_and_resets_presentation_after_success() { let _guard = crate::test_support::global_state_test_lock().blocking_lock(); crate::test_support::ensure_tokio_runtime(); @@ -1140,6 +1157,26 @@ mod tests { .expect("wallet metadata is saved"); } + database.global_flag.mark_onboarding_complete().expect("onboarding completion is saved"); + database.global_config.select_wallet(first.id.clone()).expect("wallet is selected"); + database.global_config.set_auth_type(AuthType::Pin).expect("auth type is saved"); + database.global_config.set_wipe_data_pin("wipe".into()).expect("wipe PIN is saved"); + database.global_config.set_decoy_pin("decoy".into()).expect("decoy PIN is saved"); + database + .global_config + .set_color_scheme(ColorSchemeSelection::Dark) + .expect("color scheme is saved"); + FfiApp::global().reset_default_route_to(wallet_selection_loading_route( + first.id.clone(), + Some(Route::Settings(SettingsRoute::Main)), + )); + App::global().handle_action(AppAction::PushRoute(Route::Settings(SettingsRoute::About))); + + assert_eq!(database.global_config.selected_wallet(), Some(first.id.clone())); + let stale_state = App::global().get_state(); + assert!(matches!(stale_state.router.default, Route::LoadAndReset { .. })); + assert_eq!(stale_state.router.routes, vec![Route::Settings(SettingsRoute::About)]); + let first_id = first.id.as_str(); let second_id = second.id.as_str(); let first_secret = format!("{first_id}::wallet_mnemonic"); @@ -1186,10 +1223,51 @@ mod tests { keychain.get_entry(failed_secret).is_some(), "the injected wallet secret remains for the retry" ); + assert!( + matches!(App::global().get_state().router.default, Route::LoadAndReset { .. }), + "failed cleanup must not publish the success route" + ); + assert_eq!( + App::global().get_state().router.routes, + stale_state.router.routes, + "failed cleanup must keep the stale navigation stack" + ); + assert!( + Database::global().global_flag.is_onboarding_complete(), + "failed cleanup must not clear completed onboarding" + ); keychain.fail_delete_at(usize::MAX); - FfiApp::global().dangerous_wipe_all_data().expect("the retry succeeds"); + let completion = FfiApp::global().dangerous_wipe_all_data().expect("the retry succeeds"); + + let app_state = App::global().get_state(); + assert_eq!( + completion.router, app_state.router, + "the returned completion carries the committed post-wipe router" + ); + assert!(completion.wallets.is_empty(), "the returned completion has no wallets"); + assert!(!completion.needs_onboarding, "the returned completion keeps setup complete"); + assert_eq!( + completion.auth, + AuthSettings { + auth_type: AuthType::None, + is_wipe_data_pin_enabled: false, + is_decoy_pin_enabled: false, + }, + "the returned completion drops the wiped PIN and trick PINs" + ); + assert_eq!( + completion.color_scheme, + ColorSchemeSelection::System, + "the returned completion carries the reset color scheme" + ); + assert_eq!( + app_state.router.default, + Route::NewWallet(NewWalletRoute::Select), + "successful cleanup publishes the direct new-wallet route" + ); + assert!(app_state.router.routes.is_empty(), "successful cleanup clears navigation stack"); assert_eq!( Database::global() .wallets @@ -1198,6 +1276,19 @@ mod tests { None, "the retry removes the failed wallet row" ); + assert!( + Database::global().wallets.all().expect("wallet inventory is read").is_empty(), + "successful cleanup removes every wallet" + ); + assert!( + Database::global().global_flag.is_onboarding_complete(), + "successful cleanup preserves completed onboarding" + ); + assert_eq!( + Database::global().global_config.selected_wallet(), + None, + "successful cleanup clears the selected wallet" + ); keychain.reset(); } diff --git a/rust/src/app/full_wipe.rs b/rust/src/app/full_wipe.rs new file mode 100644 index 000000000..c0a30e3bb --- /dev/null +++ b/rust/src/app/full_wipe.rs @@ -0,0 +1,74 @@ +//! Committed state a frontend applies after a successful full wipe + +use super::{ + App, + reconcile::{AppStateReconcileMessage as AppMessage, Updater}, +}; +use crate::{ + color_scheme::ColorSchemeSelection, + database::Database, + fiat::FiatCurrency, + manager::auth_manager::{AUTH_MANAGER, AuthSettings}, + network::Network, + node::Node, + router::{NewWalletRoute, Route, Router}, + wallet::metadata::WalletMetadata, +}; + +/// Committed app and authentication state after a successful full wipe +/// +/// Frontends apply this synchronously before releasing authentication, so no frontend has +/// to decide on its own what a wiped app looks like +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct FullWipeCompletion { + /// Navigation after the wipe, starting at new-wallet selection with no pushed routes + pub router: Router, + /// Whether the app must show onboarding, carried over from before the wipe + pub needs_onboarding: bool, + pub selected_network: Network, + pub color_scheme: ColorSchemeSelection, + pub selected_node: Node, + pub fiat_currency: FiatCurrency, + /// Always empty, included so frontends replace their wallet list instead of re-reading it + pub wallets: Vec, + pub auth: AuthSettings, +} + +impl FullWipeCompletion { + /// Resets navigation and reads the committed state once the database has been reset + /// + /// `completed_onboarding` is the flag the database reset committed, passed in so a later + /// read failure cannot send a set-up device back to onboarding + pub(crate) fn after_reset(completed_onboarding: bool) -> Self { + let config = &Database::global().global_config; + let app = App::global(); + app.state.write().router.reset_routes_to(Route::NewWallet(NewWalletRoute::Select)); + + Self { + router: app.get_state().router, + needs_onboarding: !completed_onboarding, + selected_network: config.selected_network(), + color_scheme: config._color_scheme(), + selected_node: config.selected_node(), + fiat_currency: config.selected_fiat_currency(), + wallets: Vec::new(), + auth: AUTH_MANAGER.settings(), + } + } + + /// Queues this state behind any message sent before the wipe + /// + /// Frontends apply the returned completion first, and these value-carrying updates repeat + /// it so a stale message still in flight from before the wipe is not the last one applied + pub(crate) fn publish(&self) { + Updater::send_update(AppMessage::SelectedNetworkChanged(self.selected_network)); + Updater::send_update(AppMessage::ColorSchemeChanged(self.color_scheme)); + Updater::send_update(AppMessage::FiatCurrencyChanged(self.fiat_currency)); + Updater::send_update(AppMessage::SelectedNodeChanged(self.selected_node.clone())); + AUTH_MANAGER.publish(&self.auth); + Updater::send_update(AppMessage::DefaultRouteChanged( + self.router.default.clone(), + self.router.routes.clone(), + )); + } +} diff --git a/rust/src/backup/import.rs b/rust/src/backup/import.rs index c095fe79b..4d5cda95e 100644 --- a/rust/src/backup/import.rs +++ b/rust/src/backup/import.rs @@ -4,7 +4,8 @@ use std::{ sync::Arc, }; -use bdk_wallet::{bitcoin::bip32::Xpub, descriptor::ExtendedDescriptor}; +use bdk_wallet::bitcoin::bip32::Xpub; +use bdk_wallet::descriptor::{ExtendedDescriptor, IntoWalletDescriptor}; use bip39::Mnemonic; use cove_device::keychain::{Keychain, WalletSecret as KeychainWalletSecret, WalletXprv}; use cove_types::network::Network; @@ -28,8 +29,13 @@ use crate::wallet_secret::WalletSecretExt as _; use super::crypto; use super::error::BackupError; use super::model::{BackupImportReport, BackupPayload, WalletBackup, WalletSecret}; -use super::recovery::{ - RestoreArtifactSnapshot, RestoreMarkerGuard, ValidatedRestoreWalletId, WalletRestoreLease, +use super::recovery::{RestoreArtifactSnapshot, RestoreMarkerGuard, WalletRestoreLease}; + +mod cloud_restore; + +pub(crate) use cloud_restore::{ + CloudRestoreError, LocalWalletConflict, restore_cloud_descriptor_wallet, + restore_cloud_mnemonic_wallet, restore_cloud_xpriv_wallet, }; #[derive(Debug)] @@ -53,6 +59,15 @@ struct PreparedHotWallet { descriptors: Descriptors, } +impl PreparedHotWallet { + fn from_secret(secret: KeychainWalletSecret, metadata: &WalletMetadata) -> Self { + let xpub = secret.xpub(metadata.network); + let descriptors = secret.clone().into_descriptors(metadata.network, metadata.address_type); + + Self { secret, xpub, descriptors } + } +} + #[derive(Debug)] struct PreparedPublicWallet { xpub: Option, @@ -233,11 +248,7 @@ fn prepare_wallet_kind( )?; let secret = KeychainWalletSecret::Mnemonic(mnemonic); - let xpub = secret.xpub(metadata.network); - let descriptors = - secret.clone().into_descriptors(metadata.network, metadata.address_type); - - Ok(PreparedWalletKind::Hot(PreparedHotWallet { secret, xpub, descriptors })) + Ok(PreparedWalletKind::Hot(PreparedHotWallet::from_secret(secret, metadata))) } WalletSecret::Xprv(value) => { let xprv = WalletXprv::parse(value.as_str()).map_err_prefix( @@ -246,29 +257,16 @@ fn prepare_wallet_kind( )?; let secret = KeychainWalletSecret::Xpriv(xprv); - let xpub = secret.xpub(metadata.network); - let descriptors = - secret.clone().into_descriptors(metadata.network, metadata.address_type); - - Ok(PreparedWalletKind::Hot(PreparedHotWallet { secret, xpub, descriptors })) + Ok(PreparedWalletKind::Hot(PreparedHotWallet::from_secret(secret, metadata))) } - WalletSecret::TapSignerBackup(backup_bytes) => { - let public = prepare_public_wallet( + WalletSecret::TapSignerBackup(_) | WalletSecret::None | WalletSecret::Unknown => { + prepare_public_wallet( backup, metadata, validation == WalletTypeSecretValidation::Degraded, - )?; - Ok(PreparedWalletKind::Public(PreparedPublicWallet { - tap_signer_backup: Some(backup_bytes.clone()), - ..public - })) + ) + .map(PreparedWalletKind::Public) } - WalletSecret::None | WalletSecret::Unknown => prepare_public_wallet( - backup, - metadata, - validation == WalletTypeSecretValidation::Degraded, - ) - .map(PreparedWalletKind::Public), }?; validate_prepared_wallet_storage(metadata, &kind)?; @@ -347,7 +345,12 @@ fn prepare_public_wallet( ))); } - Ok(PreparedPublicWallet { xpub, descriptors, tap_signer_backup: None, degraded }) + let tap_signer_backup = match &backup.secret { + WalletSecret::TapSignerBackup(bytes) => Some(bytes.clone()), + _ => None, + }; + + Ok(PreparedPublicWallet { xpub, descriptors, tap_signer_backup, degraded }) } /// Check that an approval belongs to this exact payload and covers exactly the @@ -668,31 +671,70 @@ where } } -fn cloud_restore_snapshot( - metadata: &WalletMetadata, - expected_xpub: Option, -) -> Result { - let id = ValidatedRestoreWalletId::validate(&metadata.id)?; - let snapshot = RestoreArtifactSnapshot::capture(&id)?; +fn public_descriptor_pair(descriptors: &Descriptors) -> (ExtendedDescriptor, ExtendedDescriptor) { + ( + descriptors.external.extended_descriptor.clone(), + descriptors.internal.extended_descriptor.clone(), + ) +} - if snapshot.metadata || !snapshot.bdk_paths.is_empty() || snapshot.wallet_data_occupied { - return Err(BackupError::WalletIdOccupied(metadata.id.clone())); - } +/// The keychain writes a hot wallet restore performs +struct HotWalletWrites { + /// Descriptors with their key maps + /// + /// The BDK wallet is always created because every restore rejects a wallet + /// id that already owns BDK artifacts + bdk_descriptors: Descriptors, + secret: Option, + xpub: Option, + descriptors: Option<(ExtendedDescriptor, ExtendedDescriptor)>, +} - let has_non_xpub_keychain_item = snapshot.keychain_entries.keys().any(|kind| kind != "xpub"); - if has_non_xpub_keychain_item { - return Err(BackupError::WalletIdOccupied(metadata.id.clone())); +impl HotWalletWrites { + /// Write every item the backup carries + /// + /// The file-import callers reach this only after requiring an empty wallet + /// id or removing the artifacts an approval covered + fn create_all(prepared: PreparedHotWallet) -> Self { + let PreparedHotWallet { secret, xpub, descriptors } = prepared; + + Self { + descriptors: Some(public_descriptor_pair(&descriptors)), + bdk_descriptors: descriptors, + secret: Some(secret), + xpub: Some(xpub), + } } +} - let existing_xpub = Keychain::global() - .get_wallet_xpub(&metadata.id) - .map_err_prefix("cloud restore xpub", BackupError::Keychain)?; +/// The keychain writes a public wallet restore performs +struct PublicWalletWrites { + /// Descriptors used to create the BDK wallet when the backup carries them + bdk_descriptors: Option<(ExtendedDescriptor, ExtendedDescriptor)>, + xpub: Option, + descriptors: Option<(ExtendedDescriptor, ExtendedDescriptor)>, + tap_signer_backup: Option>>, +} - if existing_xpub.is_some() && existing_xpub != expected_xpub { - return Err(BackupError::WalletIdOccupied(metadata.id.clone())); +impl PublicWalletWrites { + /// Write every item the backup carries + /// + /// The file-import callers reach this only after requiring an empty wallet + /// id or removing the artifacts an approval covered + fn create_all(prepared: PreparedPublicWallet) -> Self { + let PreparedPublicWallet { xpub, descriptors, tap_signer_backup, .. } = prepared; + + Self { + bdk_descriptors: descriptors.clone(), + xpub, + descriptors, + tap_signer_backup: tap_signer_backup.map(Zeroizing::new), + } } +} - Ok(snapshot) +trait WalletWrites { + fn apply(self, metadata: &WalletMetadata) -> Result<(), BackupError>; } fn restore_hot_wallet_prepared_with_context( @@ -701,7 +743,7 @@ fn restore_hot_wallet_prepared_with_context( cleanup: RestoreCleanup<'_>, ) -> Result, (BackupError, Vec)> { let result = with_restore_journal(metadata, cleanup, || { - restore_hot_wallet_inner_prepared(metadata, prepared) + HotWalletWrites::create_all(prepared).apply(metadata) }); if result.is_ok() { schedule_cloud_backup_after_local_commit(metadata); @@ -716,7 +758,7 @@ fn restore_descriptor_wallet_prepared_with_context( cleanup: RestoreCleanup<'_>, ) -> Result, (BackupError, Vec)> { let result = with_restore_journal(metadata, cleanup, || { - restore_descriptor_wallet_inner_prepared(metadata, prepared) + PublicWalletWrites::create_all(prepared).apply(metadata) }); if result.is_ok() { schedule_cloud_backup_after_local_commit(metadata); @@ -725,165 +767,98 @@ fn restore_descriptor_wallet_prepared_with_context( result } -pub(crate) fn restore_cloud_mnemonic_wallet( - metadata: &WalletMetadata, - mnemonic: Mnemonic, -) -> Result<(), (BackupError, Vec)> { - let secret = KeychainWalletSecret::Mnemonic(mnemonic); - let xpub = secret.xpub(metadata.network); - let descriptors = secret.clone().into_descriptors(metadata.network, metadata.address_type); - let prepared = PreparedHotWallet { secret, xpub, descriptors }; - let snapshot = cloud_restore_snapshot(metadata, Some(prepared.xpub)) - .map_err(|error| (error, Vec::new()))?; - - let result = with_restore_journal(metadata, RestoreCleanup::Preserve(&snapshot), || { - restore_hot_wallet_inner_prepared(metadata, prepared) - }); - if let Ok(warnings) = &result { +impl WalletWrites for HotWalletWrites { + fn apply(self, metadata: &WalletMetadata) -> Result<(), BackupError> { let name = &metadata.name; - for warning in warnings { - warn!("cloud restore cleanup warning for {name}: {warning}"); - } - } - result.map(|_| ()) -} + let Self { bdk_descriptors, secret, xpub, descriptors } = self; -pub(crate) fn restore_cloud_xpriv_wallet( - metadata: &WalletMetadata, - xpriv: WalletXprv, -) -> Result<(), (BackupError, Vec)> { - let secret = KeychainWalletSecret::Xpriv(xpriv); - let xpub = secret.xpub(metadata.network); - let descriptors = secret.clone().into_descriptors(metadata.network, metadata.address_type); - let prepared = PreparedHotWallet { secret, xpub, descriptors }; - let snapshot = cloud_restore_snapshot(metadata, Some(prepared.xpub)) - .map_err(|error| (error, Vec::new()))?; - - let result = with_restore_journal(metadata, RestoreCleanup::Preserve(&snapshot), || { - restore_hot_wallet_inner_prepared(metadata, prepared) - }); - if let Ok(warnings) = &result { - let name = &metadata.name; - for warning in warnings { - warn!("cloud restore cleanup warning for {name}: {warning}"); - } - } - result.map(|_| ()) -} + // create BDK wallet first — if this fails we haven't touched the keychain yet + create_bdk_wallet( + metadata, + bdk_descriptors.external.into_tuple(), + bdk_descriptors.internal.into_tuple(), + )?; -fn restore_hot_wallet_inner_prepared( - metadata: &WalletMetadata, - prepared: PreparedHotWallet, -) -> Result<(), BackupError> { - let keychain = Keychain::global(); - let db = Database::global(); - let name = &metadata.name; - let network = metadata.network; - let PreparedHotWallet { secret, xpub, descriptors } = prepared; + if let Some(secret) = secret { + Keychain::global() + .save_wallet_secret(&metadata.id, secret) + .map_err_prefix(&format!("private key for {name}"), BackupError::Keychain)?; + } - let mut store = crate::bdk_store::BdkStore::try_new(&metadata.id, network) - .map_err(|e| BackupError::Restore(format!("BDK store for {name}: {e}")))?; + save_public_keychain_items(metadata, xpub, descriptors)?; + RestoredWalletMetadataStore::new(&Database::global()).save(metadata, name)?; - let ext_descriptor = descriptors.external.extended_descriptor.clone(); - let int_descriptor = descriptors.internal.extended_descriptor.clone(); + Ok(()) + } +} - // create BDK wallet first — if this fails we haven't touched the keychain yet - bdk_wallet::Wallet::create( - descriptors.external.into_tuple(), - descriptors.internal.into_tuple(), - ) - .network(network.into()) - .create_wallet(&mut store.conn) - .map_err(|e| BackupError::Restore(format!("BDK wallet for {name}: {e}")))?; +impl WalletWrites for PublicWalletWrites { + fn apply(self, metadata: &WalletMetadata) -> Result<(), BackupError> { + let name = &metadata.name; + let Self { bdk_descriptors, xpub, descriptors, tap_signer_backup } = self; - keychain - .save_wallet_secret(&metadata.id, secret) - .map_err(|e| BackupError::Keychain(format!("private key for {name}: {e}")))?; + save_public_keychain_items(metadata, xpub, descriptors)?; - keychain - .save_wallet_xpub(&metadata.id, xpub) - .map_err(|e| BackupError::Keychain(format!("xpub for {name}: {e}")))?; + // create the BDK wallet from the backup's descriptors, whether they were written or adopted + if let Some((external, internal)) = bdk_descriptors { + create_bdk_wallet(metadata, external, internal)?; + } - keychain - .save_public_descriptor(&metadata.id, ext_descriptor, int_descriptor) - .map_err(|e| BackupError::Keychain(format!("descriptors for {name}: {e}")))?; + // save tap signer backup inside the cleanup wrapper so failure triggers full rollback + if let Some(backup) = tap_signer_backup { + Keychain::global() + .save_tap_signer_backup(&metadata.id, &backup) + .map_err_prefix(&format!("tap signer backup for {name}"), BackupError::Keychain)?; + } - RestoredWalletMetadataStore::new(&db).save(metadata, name)?; + RestoredWalletMetadataStore::new(&Database::global()).save(metadata, name)?; - Ok(()) + Ok(()) + } } -pub(crate) fn restore_cloud_descriptor_wallet( +/// Creates the BDK wallet store a restored wallet syncs into +fn create_bdk_wallet( metadata: &WalletMetadata, - backup: &WalletBackup, -) -> Result<(), (BackupError, Vec)> { - let prepared = - prepare_public_wallet(backup, metadata, matches!(&backup.secret, WalletSecret::Unknown)) - .map_err(|error| (error, Vec::new()))?; - - let prepared = PreparedPublicWallet { - tap_signer_backup: match &backup.secret { - WalletSecret::TapSignerBackup(bytes) => Some(bytes.clone()), - _ => None, - }, - ..prepared - }; - let snapshot = - cloud_restore_snapshot(metadata, prepared.xpub).map_err(|error| (error, Vec::new()))?; + external: D, + internal: D, +) -> Result<(), BackupError> +where + D: IntoWalletDescriptor + Send + Clone + 'static, +{ + let name = &metadata.name; + let mut store = crate::bdk_store::BdkStore::try_new(&metadata.id, metadata.network) + .map_err_prefix(&format!("BDK store for {name}"), BackupError::Restore)?; - let result = with_restore_journal(metadata, RestoreCleanup::Preserve(&snapshot), || { - restore_descriptor_wallet_inner_prepared(metadata, prepared) - }); - if let Ok(warnings) = &result { - let name = &metadata.name; - for warning in warnings { - warn!("cloud restore cleanup warning for {name}: {warning}"); - } - } - result.map(|_| ()) + bdk_wallet::Wallet::create(external, internal) + .network(metadata.network.into()) + .create_wallet(&mut store.conn) + .map_err_prefix(&format!("BDK wallet for {name}"), BackupError::Restore)?; + + Ok(()) } -fn restore_descriptor_wallet_inner_prepared( +/// Saves the public keychain items a restore planned to write +fn save_public_keychain_items( metadata: &WalletMetadata, - prepared: PreparedPublicWallet, + xpub: Option, + descriptors: Option<(ExtendedDescriptor, ExtendedDescriptor)>, ) -> Result<(), BackupError> { let keychain = Keychain::global(); - let db = Database::global(); let name = &metadata.name; - let PreparedPublicWallet { xpub, descriptors, tap_signer_backup, .. } = prepared; - if let Some(xpub) = xpub { keychain .save_wallet_xpub(&metadata.id, xpub) - .map_err(|e| BackupError::Keychain(format!("xpub for {name}: {e}")))?; - } - - // save descriptors and create BDK wallet if present - if let Some((ext, int)) = descriptors { - keychain - .save_public_descriptor(&metadata.id, ext.clone(), int.clone()) - .map_err(|e| BackupError::Keychain(format!("descriptors for {name}: {e}")))?; - - // create BDK wallet store from descriptors - let mut store = crate::bdk_store::BdkStore::try_new(&metadata.id, metadata.network) - .map_err(|e| BackupError::Restore(format!("BDK store for {name}: {e}")))?; - - bdk_wallet::Wallet::create(ext, int) - .network(metadata.network.into()) - .create_wallet(&mut store.conn) - .map_err(|e| BackupError::Restore(format!("BDK wallet for {name}: {e}")))?; + .map_err_prefix(&format!("xpub for {name}"), BackupError::Keychain)?; } - // save tap signer backup inside the cleanup wrapper so failure triggers full rollback - if let Some(backup_bytes) = tap_signer_backup { + if let Some((external, internal)) = descriptors { keychain - .save_tap_signer_backup(&metadata.id, &backup_bytes) - .map_err(|e| BackupError::Keychain(format!("tap signer backup for {name}: {e}")))?; + .save_public_descriptor(&metadata.id, external, internal) + .map_err_prefix(&format!("descriptors for {name}"), BackupError::Keychain)?; } - RestoredWalletMetadataStore::new(&db).save(metadata, name)?; - Ok(()) } @@ -1006,22 +981,11 @@ mod tests { use cove_types::BlockSizeLast; - use crate::wallet::fingerprint::Fingerprint; + use crate::test_support::hot_wallet_metadata as hot_metadata; use crate::wallet::metadata::StoreType; use super::*; - fn hot_metadata(name: &str) -> WalletMetadata { - let mut metadata = WalletMetadata::preview_new(); - metadata.name = name.to_string(); - metadata.wallet_type = WalletType::Hot; - metadata.master_fingerprint = Some(Arc::new(Fingerprint::from( - bdk_wallet::bitcoin::bip32::Fingerprint::from_str("817e7be0").unwrap(), - ))); - - metadata - } - fn cold_metadata(name: &str) -> WalletMetadata { let mut metadata = hot_metadata(name); metadata.wallet_type = WalletType::Cold; diff --git a/rust/src/backup/import/cloud_restore.rs b/rust/src/backup/import/cloud_restore.rs new file mode 100644 index 000000000..9f8657b59 --- /dev/null +++ b/rust/src/backup/import/cloud_restore.rs @@ -0,0 +1,547 @@ +use bip39::Mnemonic; +use cove_device::keychain::{WalletSecret as KeychainWalletSecret, WalletXprv}; +use tracing::warn; +use zeroize::Zeroizing; + +use crate::backup::error::BackupError; +use crate::backup::model::{WalletBackup, WalletSecret}; +use crate::backup::recovery::{ + RestoreArtifactSnapshot, ValidatedRestoreWalletId, WalletKeychainItems, +}; +use crate::wallet::metadata::WalletMetadata; + +use super::{ + HotWalletWrites, PreparedHotWallet, PreparedPublicWallet, PublicWalletWrites, RestoreCleanup, + WalletWrites, prepare_public_wallet, public_descriptor_pair, with_restore_journal, +}; + +/// Why a cloud restore refused to touch the local data a wallet id already owns +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub(crate) enum LocalWalletConflict { + /// Local wallet data exists for this wallet id and differs from the backup + #[error("local wallet data does not match the backup")] + Mismatch, + + /// Local wallet data exists for this wallet id but could not be read to compare it + #[error("local wallet data could not be read")] + Unreadable, +} + +/// A cloud restore failure, keeping local conflicts distinct from every other failure +/// +/// A local conflict means the restore wrote nothing and kept local data unchanged, +/// which the reader must be told instead of a generic failure +#[derive(Debug, thiserror::Error)] +pub(crate) enum CloudRestoreError { + #[error(transparent)] + LocalConflict(#[from] LocalWalletConflict), + + #[error(transparent)] + Backup(#[from] BackupError), +} + +/// Decide what a restore does with one keychain item +/// +/// Local data the backup cannot account for is a conflict: a restore must never +/// write over local data it did not verify +/// +/// Returns the value to write, or `None` when the restore writes nothing +fn plan_entry( + local: Option, + incoming: Option, +) -> Result, LocalWalletConflict> { + match (local, incoming) { + (None, None) => Ok(None), + (None, Some(incoming)) => Ok(Some(incoming)), + (Some(local), Some(incoming)) if local == incoming => Ok(None), + _ => Err(LocalWalletConflict::Mismatch), + } +} + +/// A cloud restore whose every pre-existing local item was checked against the backup +/// +/// The validating constructors are the only way to build this type, so planned +/// writes can never skip the comparison that decided them +struct VerifiedCloudRestorePlan { + snapshot: RestoreArtifactSnapshot, + writes: W, +} + +impl VerifiedCloudRestorePlan { + /// Run the planned writes under a journal that preserves every adopted item + fn execute(self, metadata: &WalletMetadata) -> Result, (BackupError, Vec)> { + let Self { snapshot, writes } = self; + + with_restore_journal(metadata, RestoreCleanup::Preserve(&snapshot), || { + writes.apply(metadata) + }) + } +} + +/// Capture the local artifacts a wallet id owns and read its keychain items +/// +/// Metadata rows, BDK artifacts and occupied wallet-data paths still reserve the +/// wallet id outright: only keychain items can be verified and adopted +/// +/// A keychain read failure means local data exists that cannot be compared, so the +/// restore reports a conflict instead of writing over it +fn capture_local_wallet_state( + metadata: &WalletMetadata, +) -> Result<(RestoreArtifactSnapshot, WalletKeychainItems), CloudRestoreError> { + let wallet_id = &metadata.id; + let id = ValidatedRestoreWalletId::validate(wallet_id)?; + + let (snapshot, items) = match RestoreArtifactSnapshot::capture_with_keychain_items(&id) { + Ok(captured) => captured, + // capture only reads this wallet id, so a keychain failure is local data we cannot compare + Err(BackupError::Keychain(error)) => { + warn!( + "cloud restore cannot snapshot local keychain data for wallet id={wallet_id}: {error}" + ); + return Err(LocalWalletConflict::Unreadable.into()); + } + Err(error) => return Err(error.into()), + }; + + if snapshot.metadata || !snapshot.bdk_paths.is_empty() || snapshot.wallet_data_occupied { + return Err(BackupError::WalletIdOccupied(wallet_id.clone()).into()); + } + + // rollback can only preserve keychain kinds the snapshot fingerprinted, so a + // wallet id holding items the snapshot could not describe stays untouched + if snapshot.keychain_items && snapshot.keychain_entries.is_empty() { + warn!("cloud restore found undescribed local keychain items for wallet id={wallet_id}"); + return Err(LocalWalletConflict::Unreadable.into()); + } + + Ok((snapshot, items)) +} + +impl VerifiedCloudRestorePlan { + fn prepare( + metadata: &WalletMetadata, + prepared: PreparedHotWallet, + ) -> Result { + let (snapshot, local) = capture_local_wallet_state(metadata)?; + + // a hot wallet backup carries no TapSigner backup, so a local one is data we cannot verify + if local.tap_signer_backup.is_some() { + return Err(LocalWalletConflict::Mismatch.into()); + } + + let PreparedHotWallet { secret, xpub, descriptors } = prepared; + let writes = HotWalletWrites { + secret: plan_entry(local.secret, Some(secret))?, + xpub: plan_entry(local.xpub, Some(xpub))?, + descriptors: plan_entry(local.descriptors, Some(public_descriptor_pair(&descriptors)))?, + bdk_descriptors: descriptors, + }; + + Ok(Self { snapshot, writes }) + } +} + +impl VerifiedCloudRestorePlan { + fn prepare( + metadata: &WalletMetadata, + prepared: PreparedPublicWallet, + ) -> Result { + let (snapshot, local) = capture_local_wallet_state(metadata)?; + + // a public wallet backup carries no private key, so a local secret is data we cannot verify + if local.secret.is_some() { + return Err(LocalWalletConflict::Mismatch.into()); + } + + let PreparedPublicWallet { xpub, descriptors, tap_signer_backup, .. } = prepared; + let writes = PublicWalletWrites { + bdk_descriptors: descriptors.clone(), + xpub: plan_entry(local.xpub, xpub)?, + descriptors: plan_entry(local.descriptors, descriptors)?, + tap_signer_backup: plan_entry( + local.tap_signer_backup, + tap_signer_backup.map(Zeroizing::new), + )?, + }; + + Ok(Self { snapshot, writes }) + } +} + +pub(crate) fn restore_cloud_mnemonic_wallet( + metadata: &WalletMetadata, + mnemonic: Mnemonic, +) -> Result<(), CloudRestoreError> { + restore_cloud_hot_wallet(metadata, KeychainWalletSecret::Mnemonic(mnemonic)) +} + +pub(crate) fn restore_cloud_xpriv_wallet( + metadata: &WalletMetadata, + xpriv: WalletXprv, +) -> Result<(), CloudRestoreError> { + restore_cloud_hot_wallet(metadata, KeychainWalletSecret::Xpriv(xpriv)) +} + +fn restore_cloud_hot_wallet( + metadata: &WalletMetadata, + secret: KeychainWalletSecret, +) -> Result<(), CloudRestoreError> { + let prepared = PreparedHotWallet::from_secret(secret, metadata); + let plan = VerifiedCloudRestorePlan::::prepare(metadata, prepared)?; + + report_cloud_restore(metadata, plan.execute(metadata)) +} + +pub(crate) fn restore_cloud_descriptor_wallet( + metadata: &WalletMetadata, + backup: &WalletBackup, +) -> Result<(), CloudRestoreError> { + let prepared = + prepare_public_wallet(backup, metadata, matches!(&backup.secret, WalletSecret::Unknown))?; + let plan = VerifiedCloudRestorePlan::::prepare(metadata, prepared)?; + + report_cloud_restore(metadata, plan.execute(metadata)) +} + +/// Log the cleanup warnings a cloud restore left behind and drop them from the result +fn report_cloud_restore( + metadata: &WalletMetadata, + result: Result, (BackupError, Vec)>, +) -> Result<(), CloudRestoreError> { + let (result, warnings) = match result { + Ok(warnings) => (Ok(()), warnings), + Err((error, warnings)) => (Err(error.into()), warnings), + }; + + let name = &metadata.name; + for warning in warnings { + warn!("cloud restore cleanup warning for {name}: {warning}"); + } + + result +} + +#[cfg(test)] +mod tests { + use std::str::FromStr as _; + + use bdk_wallet::{bitcoin::bip32::Xpub, descriptor::ExtendedDescriptor}; + use cove_device::keychain::Keychain; + + use crate::database::Database; + use crate::test_support::{ + WALLET_MNEMONIC_CRYPTOR_KEY_SUFFIX, WALLET_MNEMONIC_KEY_SUFFIX, WALLET_XPUB_KEY_SUFFIX, + hot_wallet_metadata as hot_metadata, raw_wallet_keychain_entries as raw_keychain_entries, + }; + use crate::wallet::metadata::{WalletId, WalletType}; + use crate::wallet_secret::WalletSecretExt as _; + + use super::*; + + fn init_cloud_restore_test_state() { + crate::database::test_support::delete_database(); + crate::test_support::init_test_keychain(); + crate::test_support::shared_mock_keychain().reset(); + } + + fn cloud_restore_metadata(name: &str, wallet_type: WalletType) -> WalletMetadata { + let mut metadata = hot_metadata(name); + metadata.wallet_type = wallet_type; + metadata.id = WalletId::preview_new_random(); + metadata + } + + fn backup_mnemonic() -> Mnemonic { + Mnemonic::from_str( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .unwrap() + } + + fn unrelated_mnemonic() -> Mnemonic { + Mnemonic::from_str("zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong").unwrap() + } + + fn hot_wallet_items( + metadata: &WalletMetadata, + secret: KeychainWalletSecret, + ) -> (KeychainWalletSecret, Xpub, (ExtendedDescriptor, ExtendedDescriptor)) { + let xpub = secret.xpub(metadata.network); + let descriptors = secret.clone().into_descriptors(metadata.network, metadata.address_type); + + (secret, xpub, public_descriptor_pair(&descriptors)) + } + + fn public_wallet_backup( + metadata: &WalletMetadata, + xpub: Option, + descriptors: Option<(ExtendedDescriptor, ExtendedDescriptor)>, + secret: WalletSecret, + ) -> WalletBackup { + WalletBackup { + metadata: serde_json::to_value(metadata).unwrap(), + secret, + descriptors: descriptors.map(|(external, internal)| { + crate::backup::model::DescriptorPair { + external: external.to_string(), + internal: internal.to_string(), + } + }), + xpub: xpub.map(|xpub| xpub.to_string()), + labels_jsonl: None, + } + } + + fn restored_metadata_exists(metadata: &WalletMetadata) -> bool { + Database::global() + .wallets + .get(&metadata.id, metadata.network, metadata.wallet_mode) + .unwrap() + .is_some() + } + + fn bdk_artifacts_exist(id: &WalletId) -> bool { + crate::bdk_store::BdkStore::wallet_store_artifact_paths(id) + .into_iter() + .any(|path| path.exists()) + } + + #[test] + fn cloud_restore_adopts_matching_hot_wallet_keychain_items() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + init_cloud_restore_test_state(); + + let metadata = cloud_restore_metadata("Reinstalled hot wallet", WalletType::Hot); + let mnemonic = backup_mnemonic(); + let (secret, xpub, (external, internal)) = + hot_wallet_items(&metadata, KeychainWalletSecret::Mnemonic(mnemonic.clone())); + let keychain = Keychain::global(); + keychain.save_wallet_secret(&metadata.id, secret).unwrap(); + keychain.save_wallet_xpub(&metadata.id, xpub).unwrap(); + keychain.save_public_descriptor(&metadata.id, external, internal).unwrap(); + let before = raw_keychain_entries(&metadata.id); + + restore_cloud_mnemonic_wallet(&metadata, mnemonic).expect("adopt matching keychain items"); + + assert_eq!(raw_keychain_entries(&metadata.id), before); + assert!(restored_metadata_exists(&metadata)); + } + + #[test] + fn cloud_restore_adopts_matching_xpriv_and_creates_missing_items() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + init_cloud_restore_test_state(); + + let metadata = cloud_restore_metadata("Reinstalled xpriv wallet", WalletType::Hot); + let xpriv = WalletXprv::try_from( + bdk_wallet::bitcoin::bip32::Xpriv::new_master( + bdk_wallet::bitcoin::NetworkKind::Main, + &[7; 32], + ) + .unwrap(), + ) + .unwrap(); + let (secret, _, _) = + hot_wallet_items(&metadata, KeychainWalletSecret::Xpriv(xpriv.clone())); + Keychain::global().save_wallet_secret(&metadata.id, secret).unwrap(); + let before = raw_keychain_entries(&metadata.id); + + restore_cloud_xpriv_wallet(&metadata, xpriv).expect("adopt matching wallet secret"); + + let after = raw_keychain_entries(&metadata.id); + assert_eq!(after.get(WALLET_MNEMONIC_KEY_SUFFIX), before.get(WALLET_MNEMONIC_KEY_SUFFIX)); + assert_eq!( + after.get(WALLET_MNEMONIC_CRYPTOR_KEY_SUFFIX), + before.get(WALLET_MNEMONIC_CRYPTOR_KEY_SUFFIX) + ); + assert!(Keychain::global().get_wallet_xpub(&metadata.id).unwrap().is_some()); + assert!(Keychain::global().get_public_descriptor(&metadata.id).unwrap().is_some()); + assert!(restored_metadata_exists(&metadata)); + } + + #[test] + fn cloud_restore_adopts_matching_xpub_and_creates_the_missing_secret() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + init_cloud_restore_test_state(); + + let metadata = cloud_restore_metadata("Reinstalled xpub leftover", WalletType::Hot); + let mnemonic = backup_mnemonic(); + let (_, xpub, _) = + hot_wallet_items(&metadata, KeychainWalletSecret::Mnemonic(mnemonic.clone())); + Keychain::global().save_wallet_xpub(&metadata.id, xpub).unwrap(); + let before = raw_keychain_entries(&metadata.id); + + restore_cloud_mnemonic_wallet(&metadata, mnemonic.clone()).expect("adopt matching xpub"); + + let after = raw_keychain_entries(&metadata.id); + assert_eq!(after.get(WALLET_XPUB_KEY_SUFFIX), before.get(WALLET_XPUB_KEY_SUFFIX)); + assert_eq!( + Keychain::global().get_wallet_secret(&metadata.id).unwrap(), + Some(KeychainWalletSecret::Mnemonic(mnemonic)) + ); + assert!(Keychain::global().get_public_descriptor(&metadata.id).unwrap().is_some()); + assert!(restored_metadata_exists(&metadata)); + } + + #[test] + fn cloud_restore_adopts_matching_public_wallet_keychain_items() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + init_cloud_restore_test_state(); + + let metadata = cloud_restore_metadata("Reinstalled watch-only wallet", WalletType::Cold); + let (_, xpub, (external, internal)) = + hot_wallet_items(&metadata, KeychainWalletSecret::Mnemonic(backup_mnemonic())); + let keychain = Keychain::global(); + keychain.save_wallet_xpub(&metadata.id, xpub).unwrap(); + keychain.save_public_descriptor(&metadata.id, external.clone(), internal.clone()).unwrap(); + let before = raw_keychain_entries(&metadata.id); + let backup = public_wallet_backup( + &metadata, + Some(xpub), + Some((external, internal)), + WalletSecret::None, + ); + + restore_cloud_descriptor_wallet(&metadata, &backup) + .expect("adopt matching public keychain items"); + + assert_eq!(raw_keychain_entries(&metadata.id), before); + assert!(restored_metadata_exists(&metadata)); + } + + #[test] + fn cloud_restore_adopts_a_matching_tap_signer_backup() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + init_cloud_restore_test_state(); + + let metadata = cloud_restore_metadata("Reinstalled TapSigner wallet", WalletType::Cold); + let (_, xpub, (external, internal)) = + hot_wallet_items(&metadata, KeychainWalletSecret::Mnemonic(backup_mnemonic())); + let tap_signer_backup = vec![9u8; 64]; + let keychain = Keychain::global(); + keychain.save_wallet_xpub(&metadata.id, xpub).unwrap(); + keychain.save_public_descriptor(&metadata.id, external.clone(), internal.clone()).unwrap(); + keychain.save_tap_signer_backup(&metadata.id, &tap_signer_backup).unwrap(); + let before = raw_keychain_entries(&metadata.id); + let backup = public_wallet_backup( + &metadata, + Some(xpub), + Some((external, internal)), + WalletSecret::TapSignerBackup(tap_signer_backup.clone()), + ); + + restore_cloud_descriptor_wallet(&metadata, &backup) + .expect("adopt matching TapSigner backup"); + + assert_eq!(raw_keychain_entries(&metadata.id), before); + assert_eq!( + keychain.get_tap_signer_backup(&metadata.id).unwrap().as_deref(), + Some(&tap_signer_backup) + ); + assert!(restored_metadata_exists(&metadata)); + } + + #[test] + fn cloud_restore_keeps_a_mismatched_wallet_secret_and_writes_nothing() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + init_cloud_restore_test_state(); + + let metadata = cloud_restore_metadata("Mismatched hot wallet", WalletType::Hot); + let (secret, xpub, (external, internal)) = + hot_wallet_items(&metadata, KeychainWalletSecret::Mnemonic(unrelated_mnemonic())); + let keychain = Keychain::global(); + keychain.save_wallet_secret(&metadata.id, secret).unwrap(); + keychain.save_wallet_xpub(&metadata.id, xpub).unwrap(); + keychain.save_public_descriptor(&metadata.id, external, internal).unwrap(); + let before = raw_keychain_entries(&metadata.id); + + let result = restore_cloud_mnemonic_wallet(&metadata, backup_mnemonic()); + + assert!(matches!( + result, + Err(CloudRestoreError::LocalConflict(LocalWalletConflict::Mismatch)) + )); + assert_eq!(raw_keychain_entries(&metadata.id), before); + assert!(!restored_metadata_exists(&metadata)); + assert!(!bdk_artifacts_exist(&metadata.id)); + } + + #[test] + fn cloud_restore_keeps_an_unreadable_wallet_secret_and_writes_nothing() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + init_cloud_restore_test_state(); + + let metadata = cloud_restore_metadata("Unreadable hot wallet", WalletType::Hot); + let secret_key = format!("{}::wallet_mnemonic", metadata.id); + // an encrypted secret whose cryptor is missing cannot be compared to the backup + crate::test_support::shared_mock_keychain() + .set_entries(vec![(secret_key.as_str(), "half-written-ciphertext")]); + let before = raw_keychain_entries(&metadata.id); + + let result = restore_cloud_mnemonic_wallet(&metadata, backup_mnemonic()); + + assert!(matches!( + result, + Err(CloudRestoreError::LocalConflict(LocalWalletConflict::Unreadable)) + )); + assert_eq!(raw_keychain_entries(&metadata.id), before); + assert!(!restored_metadata_exists(&metadata)); + assert!(!bdk_artifacts_exist(&metadata.id)); + } + + #[test] + fn cloud_restore_keeps_a_local_secret_a_public_backup_cannot_explain() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + init_cloud_restore_test_state(); + + let metadata = cloud_restore_metadata("Unexpected secret", WalletType::Cold); + let (secret, xpub, _) = + hot_wallet_items(&metadata, KeychainWalletSecret::Mnemonic(backup_mnemonic())); + let keychain = Keychain::global(); + keychain.save_wallet_secret(&metadata.id, secret).unwrap(); + keychain.save_wallet_xpub(&metadata.id, xpub).unwrap(); + let before = raw_keychain_entries(&metadata.id); + let backup = public_wallet_backup(&metadata, Some(xpub), None, WalletSecret::None); + + let result = restore_cloud_descriptor_wallet(&metadata, &backup); + + assert!(matches!( + result, + Err(CloudRestoreError::LocalConflict(LocalWalletConflict::Mismatch)) + )); + assert_eq!(raw_keychain_entries(&metadata.id), before); + assert!(!restored_metadata_exists(&metadata)); + } + + #[test] + fn cloud_restore_refuses_a_wallet_id_that_already_owns_bdk_artifacts() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + init_cloud_restore_test_state(); + + let metadata = cloud_restore_metadata("Occupied by BDK data", WalletType::Hot); + let mnemonic = backup_mnemonic(); + let (_, xpub, _) = + hot_wallet_items(&metadata, KeychainWalletSecret::Mnemonic(mnemonic.clone())); + Keychain::global().save_wallet_xpub(&metadata.id, xpub).unwrap(); + let artifact = crate::bdk_store::BdkStore::wallet_store_artifact_paths(&metadata.id) + .into_iter() + .find(|path| path.to_string_lossy().ends_with("-wal")) + .expect("wallet store artifact paths include a WAL path"); + if let Some(parent) = artifact.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&artifact, b"pre-existing WAL").unwrap(); + let before = raw_keychain_entries(&metadata.id); + + let result = restore_cloud_mnemonic_wallet(&metadata, mnemonic); + + assert!(matches!( + result, + Err(CloudRestoreError::Backup(BackupError::WalletIdOccupied(id))) + if id == metadata.id + )); + assert_eq!(raw_keychain_entries(&metadata.id), before); + assert!(!restored_metadata_exists(&metadata)); + std::fs::remove_file(artifact).unwrap(); + } +} diff --git a/rust/src/backup/recovery.rs b/rust/src/backup/recovery.rs index 450ac1325..c913c8ff1 100644 --- a/rust/src/backup/recovery.rs +++ b/rust/src/backup/recovery.rs @@ -11,6 +11,7 @@ use std::{ sync::Arc, }; +use bdk_wallet::{bitcoin::bip32::Xpub, descriptor::ExtendedDescriptor}; use cove_device::keychain::Keychain; use cove_types::WalletId; use serde::{Deserialize, Serialize}; @@ -325,56 +326,76 @@ fn delete_keychain_kind(keychain: &Keychain, id: &WalletId, kind: KeychainArtifa fn capture_keychain_fingerprints( id: &WalletId, ) -> Result, BackupError> { - let keychain = Keychain::global(); - let mut fingerprints = BTreeMap::new(); + WalletKeychainItems::read(id).map(|items| items.fingerprints()) +} - if let Some(secret) = keychain - .get_wallet_secret(id) - .map_err_prefix("wallet secret snapshot", BackupError::Keychain)? - { - let fingerprint = match secret { - cove_device::keychain::WalletSecret::Mnemonic(mnemonic) => { - secret_fingerprint("mnemonic:", mnemonic) - } - cove_device::keychain::WalletSecret::Xpriv(xprv) => { - secret_fingerprint("xpriv:", xprv.expose()) - } - }; - fingerprints.insert(KeychainArtifactKind::Secret.as_str().to_string(), fingerprint); - } +/// The keychain items a wallet id owns, read once for both fingerprinting and comparison +pub(crate) struct WalletKeychainItems { + pub(crate) secret: Option, + pub(crate) xpub: Option, + pub(crate) descriptors: Option<(ExtendedDescriptor, ExtendedDescriptor)>, + pub(crate) tap_signer_backup: Option>>, +} - if let Some(xpub) = keychain - .get_wallet_xpub(id) - .map_err_prefix("wallet xpub snapshot", BackupError::Keychain)? - { - fingerprints.insert( - KeychainArtifactKind::Xpub.as_str().to_string(), - value_fingerprint(xpub.to_string().as_bytes()), - ); - } +impl WalletKeychainItems { + fn read(id: &WalletId) -> Result { + let keychain = Keychain::global(); - if let Some((external, internal)) = keychain - .get_public_descriptor(id) - .map_err_prefix("wallet descriptor snapshot", BackupError::Keychain)? - { - let value = format!("{external}\n{internal}"); - fingerprints.insert( - KeychainArtifactKind::Descriptors.as_str().to_string(), - value_fingerprint(value.as_bytes()), - ); + Ok(Self { + secret: keychain + .get_wallet_secret(id) + .map_err_prefix("wallet secret snapshot", BackupError::Keychain)?, + xpub: keychain + .get_wallet_xpub(id) + .map_err_prefix("wallet xpub snapshot", BackupError::Keychain)?, + descriptors: keychain + .get_public_descriptor(id) + .map_err_prefix("wallet descriptor snapshot", BackupError::Keychain)?, + tap_signer_backup: keychain + .get_tap_signer_backup(id) + .map_err_prefix("TapSigner backup snapshot", BackupError::Keychain)?, + }) } - if let Some(backup) = keychain - .get_tap_signer_backup(id) - .map_err_prefix("TapSigner backup snapshot", BackupError::Keychain)? - { - fingerprints.insert( - KeychainArtifactKind::TapSignerBackup.as_str().to_string(), - value_fingerprint(&backup), - ); - } + fn fingerprints(&self) -> BTreeMap { + let mut fingerprints = BTreeMap::new(); + + if let Some(secret) = &self.secret { + let fingerprint = match secret { + cove_device::keychain::WalletSecret::Mnemonic(mnemonic) => { + secret_fingerprint("mnemonic:", mnemonic) + } + cove_device::keychain::WalletSecret::Xpriv(xprv) => { + secret_fingerprint("xpriv:", xprv.expose()) + } + }; + fingerprints.insert(KeychainArtifactKind::Secret.as_str().to_string(), fingerprint); + } + + if let Some(xpub) = &self.xpub { + fingerprints.insert( + KeychainArtifactKind::Xpub.as_str().to_string(), + value_fingerprint(xpub.to_string().as_bytes()), + ); + } + + if let Some((external, internal)) = &self.descriptors { + let value = format!("{external}\n{internal}"); + fingerprints.insert( + KeychainArtifactKind::Descriptors.as_str().to_string(), + value_fingerprint(value.as_bytes()), + ); + } + + if let Some(backup) = &self.tap_signer_backup { + fingerprints.insert( + KeychainArtifactKind::TapSignerBackup.as_str().to_string(), + value_fingerprint(backup), + ); + } - Ok(fingerprints) + fingerprints + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -414,6 +435,16 @@ pub(crate) struct RestoreArtifactSnapshot { impl RestoreArtifactSnapshot { pub(crate) fn capture(id: &ValidatedRestoreWalletId) -> Result { + Self::capture_with_keychain_items(id).map(|(snapshot, _)| snapshot) + } + + /// Captures the snapshot and returns the keychain items it fingerprinted + /// + /// Callers that compare local items against a backup reuse these values + /// instead of reading the keychain a second time + pub(crate) fn capture_with_keychain_items( + id: &ValidatedRestoreWalletId, + ) -> Result<(Self, WalletKeychainItems), BackupError> { let metadata_present = metadata_exists(id)?; for entry in crate::database::wallet_data::wallet_data_root_entries() @@ -430,7 +461,9 @@ impl RestoreArtifactSnapshot { } } - let keychain_entries = capture_keychain_fingerprints(id.as_wallet_id())? + let keychain_items_read = WalletKeychainItems::read(id.as_wallet_id())?; + let keychain_entries = keychain_items_read + .fingerprints() .into_iter() .map(|(kind, fingerprint)| (kind, Some(fingerprint))) .collect::>(); @@ -483,7 +516,7 @@ impl RestoreArtifactSnapshot { .as_ref() .is_some_and(|fingerprint| fingerprint.kind != ArtifactKind::Directory); - Ok(Self { + let snapshot = Self { metadata: metadata_present, keychain_items, keychain_entries, @@ -493,7 +526,9 @@ impl RestoreArtifactSnapshot { wallet_data_fingerprints, wallet_data_directory, wallet_data_occupied, - }) + }; + + Ok((snapshot, keychain_items_read)) } pub(crate) fn is_occupied(&self) -> bool { diff --git a/rust/src/backup/verify.rs b/rust/src/backup/verify.rs index 2a6a93ff2..fdea48e46 100644 --- a/rust/src/backup/verify.rs +++ b/rust/src/backup/verify.rs @@ -164,28 +164,14 @@ impl WalletImportPreview { #[cfg(test)] mod tests { - use std::str::FromStr as _; - use std::sync::Arc; - use crate::backup::model::{ AppSettings, DescriptorPair, PAYLOAD_VERSION, WalletBackup, WalletSecret, }; - use crate::wallet::fingerprint::Fingerprint; + use crate::test_support::hot_wallet_metadata as hot_metadata; use crate::wallet::metadata::{WalletMetadata, WalletType}; use super::*; - fn hot_metadata(name: &str) -> WalletMetadata { - let mut metadata = WalletMetadata::preview_new(); - metadata.name = name.to_string(); - metadata.wallet_type = WalletType::Hot; - metadata.master_fingerprint = Some(Arc::new(Fingerprint::from( - bdk_wallet::bitcoin::bip32::Fingerprint::from_str("817e7be0").unwrap(), - ))); - - metadata - } - fn cold_metadata(name: &str) -> WalletMetadata { let mut metadata = hot_metadata(name); metadata.wallet_type = WalletType::Cold; diff --git a/rust/src/database.rs b/rust/src/database.rs index 0274626b8..cde6f09a6 100644 --- a/rust/src/database.rs +++ b/rust/src/database.rs @@ -88,8 +88,16 @@ impl Database { pub fn diagnostics_reports(&self) -> DiagnosticsReportsTable { self.diagnostics_reports.clone() } +} + +impl Database { + /// Replace the database file with a fresh one that keeps only completed onboarding + /// + /// Only the lifecycle coordinator's full wipe may call this. Returns whether onboarding + /// stays complete in the fresh database, so callers report the committed value + pub(crate) fn dangerous_reset_all_data(&self) -> Result { + let completed_onboarding = self.global_flag.try_is_onboarding_complete()?; - pub fn dangerous_reset_all_data(&self) -> Result<(), error::DatabaseError> { match std::fs::remove_file(database_location()) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} @@ -104,14 +112,12 @@ impl Database { error!("unable to clear diagnostics logs during data reset: {error}"); } - let db = Self::init()?; + let db = Self::init_with_completed_onboarding(completed_onboarding)?; DATABASE.get().expect("database not initialized").swap(Arc::new(db)); - Ok(()) + Ok(completed_onboarding) } -} -impl Database { pub fn global() -> Arc { Self::try_global().expect("failed to initialize main database") } @@ -146,6 +152,12 @@ impl Database { } fn init() -> Result { + Self::init_with_completed_onboarding(false) + } + + fn init_with_completed_onboarding( + completed_onboarding: bool, + ) -> Result { crate::bootstrap::ensure_storage_bootstrapped() .map_err_str(error::DatabaseError::BootstrapFailed)?; @@ -165,6 +177,10 @@ impl Database { let historical_prices = HistoricalPriceTable::new(main_db_arc.clone(), &write_txn); let diagnostics_reports = DiagnosticsReportsTable::new(main_db_arc, &write_txn); + if completed_onboarding { + global_flag.set_onboarding_complete_in_transaction(&write_txn)?; + } + write_txn.commit()?; let database = Self { @@ -226,31 +242,51 @@ fn database_location() -> PathBuf { #[cfg(test)] pub(crate) mod test_support { - use rand::{distr::Alphanumeric, prelude::*}; + use std::path::{Path, PathBuf}; + use std::sync::OnceLock; + use std::time::Duration; use super::*; - pub(crate) fn init_test_database() { - crate::bootstrap::tests::set_test_bootstrapped(); - crate::app::reconcile::test_support::init_noop_updater(); - let _ = DATABASE_LOCATION_OVERRIDE.set(test_database_location()); - } + const TEST_DATA_DIR_PREFIX: &str = "cove-test-"; + const STALE_TEST_DATA_DIR_AGE: Duration = Duration::from_secs(60 * 60); - fn test_database_location() -> PathBuf { - let mut rng = rand::rng(); - let random_string: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect(); - let cove_db = format!("cove_{random_string}.db"); + static TEST_ROOT: OnceLock = OnceLock::new(); + + /// Install the process test root before any test can touch the real data directory + /// + /// `ROOT_DATA_DIR` resolves lazily and cannot be redirected afterwards, so a test + /// that reached it before `init_test_database` pinned the whole process to + /// `~/.data`, and `delete_database` then removed the developer's real wallet data + #[ctor::ctor(unsafe)] + fn install_process_test_root() { + process_test_data_dir(); + } - let test_dir = ROOT_DATA_DIR.join("test"); - std::fs::create_dir_all(&test_dir).expect("failed to create test dir"); + /// Remove the process test root on normal exit; the stale sweep covers killed processes + #[dtor::dtor(unsafe)] + fn remove_process_test_root() { + if let Some(root) = TEST_ROOT.get() { + let _ = std::fs::remove_dir_all(root); + } + } - test_dir.join(cove_db) + pub(crate) fn init_test_database() { + let root = process_test_data_dir(); + crate::bootstrap::tests::set_test_bootstrapped(); + crate::app::reconcile::test_support::init_noop_updater(); + let _ = DATABASE_LOCATION_OVERRIDE.set(root.join("cove.encrypted.db")); } pub(crate) fn delete_database() { init_test_database(); + let root = process_test_data_dir(); let db_path = database_location(); - let wallet_data_dir = ROOT_DATA_DIR.join("wallet_data"); + let wallet_data_dir = cove_common::consts::wallet_data_dir_path(); + assert!( + db_path.starts_with(root) && wallet_data_dir.starts_with(root), + "test cleanup must stay inside the process test root {root:?}" + ); let _ = std::fs::remove_file(&db_path); let _ = std::fs::remove_dir_all(&wallet_data_dir); @@ -258,8 +294,97 @@ pub(crate) mod test_support { if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent).expect("failed to recreate test dir"); } + std::fs::create_dir_all(wallet_data_dir).expect("failed to recreate wallet data test dir"); } + + fn process_test_data_dir() -> &'static PathBuf { + TEST_ROOT.get_or_init(|| { + let parent = std::env::temp_dir(); + sweep_stale_test_data_dirs(&parent); + + let tempdir = tempfile::Builder::new() + .prefix(TEST_DATA_DIR_PREFIX) + .tempdir() + .expect("failed to create test data directory"); + + // keep the directory for the process lifetime; Drop would delete it too early + let path = tempdir.keep(); + + cove_common::consts::set_root_data_dir(path.clone()) + .expect("test root must be installed before any data directory access"); + assert_eq!( + *cove_common::consts::ROOT_DATA_DIR, + path, + "data directory resolved before the test root was installed" + ); + path + }) + } + + fn sweep_stale_test_data_dirs(parent: &Path) { + let Ok(entries) = std::fs::read_dir(parent) else { + return; + }; + + let now = std::time::SystemTime::now(); + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + + if !name.starts_with(TEST_DATA_DIR_PREFIX) { + continue; + } + + let Ok(metadata) = entry.metadata() else { + continue; + }; + + if !metadata.is_dir() { + continue; + } + + let Ok(modified) = metadata.modified() else { + continue; + }; + + let Ok(age) = now.duration_since(modified) else { + continue; + }; + + // nextest runs binaries in parallel, so only remove dirs older than one hour + if age > STALE_TEST_DATA_DIR_AGE { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + } + + #[test] + fn test_database_lives_under_temp_dir_not_home_data() { + init_test_database(); + let path = database_location(); + let temp = std::env::temp_dir(); + + assert!(path.starts_with(&temp), "test database {path:?} must be under {temp:?}"); + + let root = &*cove_common::consts::ROOT_DATA_DIR; + let wallet_data = cove_common::consts::wallet_data_dir_path(); + assert!(root.starts_with(&temp), "root data dir {root:?} must be under {temp:?}"); + assert!( + wallet_data.starts_with(root), + "wallet data {wallet_data:?} must be under {root:?}" + ); + + if let Some(home) = dirs::home_dir() { + let home_data = home.join(".data"); + assert!( + !path.starts_with(&home_data), + "test database {path:?} must not be under {home_data:?}" + ); + } + } } #[derive(Debug, Clone, uniffi::Enum)] diff --git a/rust/src/database/cloud_backup.rs b/rust/src/database/cloud_backup.rs index 8cf15106e..d54527fac 100644 --- a/rust/src/database/cloud_backup.rs +++ b/rust/src/database/cloud_backup.rs @@ -8,6 +8,7 @@ use super::Error; mod compat; mod compatibility; +pub(crate) mod restore_activation; mod state; mod tables; @@ -21,7 +22,7 @@ pub use state::{ PersistedDeepVerificationReport, PersistedDisablingCloudBackup, PersistedDriveAccountSwitch, PersistedDriveAccountSwitchPhase, PersistedDriveAccountSwitchState, PersistedPasskeyState, PersistedPendingVerificationCompletion, PersistedPendingVerificationUpload, - PersistedRestoreAllMarker, PersistedWalletVerificationIssues, + PersistedRestoreAllMarker, PersistedVerificationRequirement, PersistedWalletVerificationIssues, }; pub(crate) use tables::{CLOUD_BACKUP_STATE_TABLE, CLOUD_BLOB_SYNC_STATE_TABLE}; @@ -335,6 +336,7 @@ mod tests { let state = configured_state( PersistedPasskeyState::Available, PersistedBackupVerificationState::Required { + reason: PersistedVerificationRequirement::Unknown, last_verified_at: None, requested_at: Some(20), dismissed_at: Some(10), @@ -351,6 +353,7 @@ mod tests { let state = configured_state( PersistedPasskeyState::Available, PersistedBackupVerificationState::Required { + reason: PersistedVerificationRequirement::Unknown, last_verified_at: None, requested_at: Some(20), dismissed_at: Some(20), diff --git a/rust/src/database/cloud_backup/compatibility.rs b/rust/src/database/cloud_backup/compatibility.rs index 9121f22a1..d1bb90049 100644 --- a/rust/src/database/cloud_backup/compatibility.rs +++ b/rust/src/database/cloud_backup/compatibility.rs @@ -26,7 +26,7 @@ use super::{ PersistedCloudBlobState, PersistedCloudBlobSyncState, PersistedConfiguredCloudBackup, PersistedDisablingCloudBackup, PersistedDriveAccountSwitch, PersistedDriveAccountSwitchState, PersistedPasskeyState, PersistedPendingVerificationCompletion, - PersistedPendingVerificationUpload, + PersistedPendingVerificationUpload, PersistedVerificationRequirement, }; use crate::wallet::metadata::WalletId; @@ -120,6 +120,7 @@ fn legacy_verification_state( ) -> PersistedBackupVerificationState { if matches!(status, PersistedCloudBackupStatus::Unverified) { return PersistedBackupVerificationState::Required { + reason: PersistedVerificationRequirement::Unknown, last_verified_at, requested_at, dismissed_at, @@ -481,7 +482,7 @@ mod tests { PersistedConfiguredCloudBackup, PersistedDriveAccountSwitch, PersistedDriveAccountSwitchPhase, PersistedPasskeyState, PersistedPendingVerificationUpload, PersistedRestoreAllMarker, - PersistedWalletVerificationIssues, + PersistedVerificationRequirement, PersistedWalletVerificationIssues, }; fn configured_state( @@ -517,6 +518,17 @@ mod tests { assert_eq!(state.last_verified_at(), Some(11)); assert_eq!(state.last_verification_requested_at(), Some(20)); assert_eq!(state.last_verification_dismissed_at(), Some(12)); + assert!(matches!( + state, + PersistedCloudBackupState::Configured(ref configured) + if matches!( + configured.verification, + PersistedBackupVerificationState::Required { + reason: PersistedVerificationRequirement::Unknown, + .. + } + ) + )); assert!(state.pending_restore_all().is_none()); assert!(state.should_prompt_verification()); } @@ -683,6 +695,17 @@ mod tests { assert_eq!(state.last_verified_at(), Some(11)); assert_eq!(state.last_verification_requested_at(), Some(20)); assert_eq!(state.last_verification_dismissed_at(), Some(12)); + assert!(matches!( + state, + PersistedCloudBackupState::Configured(ref configured) + if matches!( + configured.verification, + PersistedBackupVerificationState::Required { + reason: PersistedVerificationRequirement::Unknown, + .. + } + ) + )); assert!(state.pending_restore_all().is_none()); } diff --git a/rust/src/database/cloud_backup/restore_activation.rs b/rust/src/database/cloud_backup/restore_activation.rs new file mode 100644 index 000000000..8063eec24 --- /dev/null +++ b/rust/src/database/cloud_backup/restore_activation.rs @@ -0,0 +1,95 @@ +use cove_util::result_ext::ResultExt as _; + +use super::{ + CLOUD_BACKUP_STATE_TABLE, CLOUD_BLOB_SYNC_STATE_TABLE, CURRENT_KEY, CloudBackupStateTable, + PersistedCloudBackupState, PersistedCloudBlobSyncState, +}; +use crate::database::Error; + +impl CloudBackupStateTable { + /// Writes restored configured state and dirty-wallet rows in one transaction + pub(crate) fn persist_restored_namespace_activation( + &self, + configured: &PersistedCloudBackupState, + dirty_states: &[PersistedCloudBlobSyncState], + ) -> Result<(), Error> { + let write_txn = self.db.begin_write().map_err_str(Error::DatabaseAccess)?; + + { + let mut table = + write_txn.open_table(CLOUD_BACKUP_STATE_TABLE).map_err_str(Error::TableAccess)?; + + #[cfg(test)] + test_support::fail_configured_state_write()?; + + table.insert(CURRENT_KEY, configured).map_err_str(Error::TableAccess)?; + } + + { + let mut table = write_txn + .open_table(CLOUD_BLOB_SYNC_STATE_TABLE) + .map_err_str(Error::TableAccess)?; + + for state in dirty_states { + #[cfg(test)] + test_support::fail_dirty_wallet_write()?; + + table.insert(state.record_id(), state).map_err_str(Error::TableAccess)?; + } + } + + write_txn.commit().map_err_str(Error::DatabaseAccess)?; + + Ok(()) + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use super::*; + + static FAIL_CONFIGURED_STATE_WRITE: AtomicBool = AtomicBool::new(false); + /// Dirty-wallet writes left to succeed before the injected failure, `usize::MAX` when disarmed + static DIRTY_WALLET_WRITES_BEFORE_FAILURE: AtomicUsize = AtomicUsize::new(usize::MAX); + + pub(crate) fn fail_next_configured_state_write() { + FAIL_CONFIGURED_STATE_WRITE.store(true, Ordering::SeqCst); + } + + /// Fails the dirty-wallet write at `index` of the next restored namespace activation + pub(crate) fn fail_dirty_wallet_write_at(index: usize) { + DIRTY_WALLET_WRITES_BEFORE_FAILURE.store(index, Ordering::SeqCst); + } + + pub(crate) fn reset() { + FAIL_CONFIGURED_STATE_WRITE.store(false, Ordering::SeqCst); + DIRTY_WALLET_WRITES_BEFORE_FAILURE.store(usize::MAX, Ordering::SeqCst); + } + + pub(crate) fn fail_configured_state_write() -> Result<(), Error> { + if FAIL_CONFIGURED_STATE_WRITE.swap(false, Ordering::SeqCst) { + return Err(Error::DatabaseAccess( + "injected restored namespace configured-state failure".into(), + )); + } + + Ok(()) + } + + pub(crate) fn fail_dirty_wallet_write() -> Result<(), Error> { + let remaining = DIRTY_WALLET_WRITES_BEFORE_FAILURE.load(Ordering::SeqCst); + if remaining == usize::MAX { + return Ok(()); + } + + if remaining > 0 { + DIRTY_WALLET_WRITES_BEFORE_FAILURE.store(remaining - 1, Ordering::SeqCst); + return Ok(()); + } + + DIRTY_WALLET_WRITES_BEFORE_FAILURE.store(usize::MAX, Ordering::SeqCst); + Err(Error::TableAccess("injected restored namespace dirty-wallet failure".into())) + } +} diff --git a/rust/src/database/cloud_backup/state.rs b/rust/src/database/cloud_backup/state.rs index 52c514b0c..c3ac0d275 100644 --- a/rust/src/database/cloud_backup/state.rs +++ b/rust/src/database/cloud_backup/state.rs @@ -1,4 +1,4 @@ -use cove_cspp::backup_data::MASTER_KEY_RECORD_ID; +use cove_cspp::backup_data::{MASTER_KEY_RECORD_ID, wallet_record_id}; use serde::{Deserialize, Serialize}; use crate::wallet::metadata::WalletId; @@ -223,6 +223,7 @@ impl PersistedCloudBackupState { Self::Configured(PersistedConfiguredCloudBackup { passkey: PersistedPasskeyState::Available, verification: PersistedBackupVerificationState::Required { + reason: PersistedVerificationRequirement::BackupReset, last_verified_at: None, requested_at: None, dismissed_at: None, @@ -261,6 +262,7 @@ impl PersistedCloudBackupState { configured.passkey = PersistedPasskeyState::Available; configured.verification = PersistedBackupVerificationState::Required { + reason: PersistedVerificationRequirement::BackupReset, last_verified_at: None, requested_at: None, dismissed_at: None, @@ -313,11 +315,33 @@ impl PersistedCloudBackupState { pub fn mark_verification_required(&mut self, requested_at: Option) { let Some(configured) = self.configured_mut() else { return }; - configured.verification = PersistedBackupVerificationState::Required { - last_verified_at: configured.verification.last_verified_at(), - requested_at, - dismissed_at: configured.verification.dismissed_at(), + configured.verification = configured + .verification + .required(PersistedVerificationRequirement::IntegrityIssue, requested_at); + } + + pub fn mark_verification_required_after_wallet_change(&mut self, requested_at: Option) { + if !matches!( + self.status(), + PersistedCloudBackupStatus::Enabled | PersistedCloudBackupStatus::Unverified + ) { + return; + } + + let Some(configured) = self.configured_mut() else { return }; + + let reason = match &configured.verification { + PersistedBackupVerificationState::NotVerified { .. } + | PersistedBackupVerificationState::Verified { .. } => { + PersistedVerificationRequirement::WalletSetChanged + } + PersistedBackupVerificationState::Required { reason, .. } => *reason, + PersistedBackupVerificationState::NeedsAttention { .. } => { + PersistedVerificationRequirement::IntegrityIssue + } }; + + configured.verification = configured.verification.required(reason, requested_at); } pub fn dismiss_verification_request(&mut self, dismissed_at: u64) -> bool { @@ -572,6 +596,20 @@ pub enum PersistedPasskeyState { Missing, } +/// Reason a configured backup requires a fresh deep verification +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PersistedVerificationRequirement { + /// The local wallet set changed after the last verified backup + WalletSetChanged, + /// A backup integrity or verification check invalidated the prior proof + IntegrityIssue, + /// The configured backup was reset or reinitialized + BackupReset, + /// The requirement was written by a version that did not record its cause + #[default] + Unknown, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "state", content = "data")] pub enum PersistedBackupVerificationState { @@ -600,6 +638,9 @@ pub enum PersistedBackupVerificationState { dismissed_at: Option, }, Required { + /// Why the backup requires a fresh deep verification + #[serde(default)] + reason: PersistedVerificationRequirement, #[serde(default)] last_verified_at: Option, #[serde(default)] @@ -610,6 +651,20 @@ pub enum PersistedBackupVerificationState { } impl PersistedBackupVerificationState { + /// Requires verification for `reason`, keeping when it last passed and was dismissed + fn required( + &self, + reason: PersistedVerificationRequirement, + requested_at: Option, + ) -> Self { + Self::Required { + reason, + last_verified_at: self.last_verified_at(), + requested_at, + dismissed_at: self.dismissed_at(), + } + } + fn status(&self) -> PersistedCloudBackupStatus { match self { Self::NotVerified { .. } | Self::Verified { .. } | Self::NeedsAttention { .. } => { @@ -669,9 +724,12 @@ impl PersistedBackupVerificationState { requested_at, dismissed_at: Some(dismissed_at), }, - Self::Required { last_verified_at, requested_at, .. } => { - Self::Required { last_verified_at, requested_at, dismissed_at: Some(dismissed_at) } - } + Self::Required { reason, last_verified_at, requested_at, .. } => Self::Required { + reason, + last_verified_at, + requested_at, + dismissed_at: Some(dismissed_at), + }, } } } @@ -751,6 +809,18 @@ impl PersistedCloudBlobSyncState { Self { namespace_id, record_key: CloudBackupRecordKey::Wallet(wallet_id, record_id), state } } + /// A wallet blob waiting to be uploaded after a change at `changed_at` + pub fn dirty_wallet(namespace_id: String, wallet_id: WalletId, changed_at: u64) -> Self { + let record_id = wallet_record_id(wallet_id.as_ref()); + + Self::wallet( + namespace_id, + wallet_id, + record_id, + PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at }), + ) + } + pub fn from_record_key( namespace_id: String, record_key: CloudBackupRecordKey, diff --git a/rust/src/database/global_config.rs b/rust/src/database/global_config.rs index 37faea1bf..38c18b6c9 100644 --- a/rust/src/database/global_config.rs +++ b/rust/src/database/global_config.rs @@ -1,8 +1,7 @@ use std::sync::Arc; -use redb::TableDefinition; -use tap::TapFallible as _; -use tracing::{error, warn}; +use redb::{ReadableTable as _, TableDefinition}; +use tracing::warn; use crate::{ app::reconcile::{Update, Updater}, @@ -144,55 +143,116 @@ impl GlobalConfigTable { } impl GlobalConfigTable { + /// Switch to decoy mode and restore its selected wallet atomically pub fn set_decoy_mode(&self) -> Result<()> { - // already in decoy mode, nothing to do - if self.is_in_decoy_mode() { - warn!("already in decoy mode"); - return Ok(()); - } + self.set_wallet_mode(WalletMode::Decoy) + } - // currently in main mode, save the selected wallet id as the decoy selected wallet id - if let Some(id) = self.selected_wallet() { - let _ = self - .set(GlobalConfigKey::MainSelectedWalletId, id.to_string()) - .tap_err(|error| error!("unable to set main selected wallet id ({id}): {error}")); - } + /// Switch to main mode and restore its selected wallet atomically + pub fn set_main_mode(&self) -> Result<()> { + self.set_wallet_mode(WalletMode::Main) + } + + fn set_wallet_mode(&self, mode: WalletMode) -> Result<()> { + let write_txn = self.db.begin_write().map_err_str(Error::DatabaseAccess)?; + + { + let mut table = write_txn.open_table(TABLE).map_err_str(Error::TableAccess)?; + let mode_key: &str = GlobalConfigKey::InDecoyMode.into(); + let is_decoy = table + .get(mode_key) + .map_err_str(GlobalConfigTableError::Read)? + .is_some_and(|value| value.value() == "true"); + let target_is_decoy = mode == WalletMode::Decoy; + if is_decoy == target_is_decoy { + return Ok(()); + } + + let (source_key, target_key) = match mode { + WalletMode::Decoy => { + (GlobalConfigKey::MainSelectedWalletId, GlobalConfigKey::DecoySelectedWalletId) + } + + WalletMode::Main => { + (GlobalConfigKey::DecoySelectedWalletId, GlobalConfigKey::MainSelectedWalletId) + } + }; + + let selected_key: &str = GlobalConfigKey::SelectedWalletId.into(); + let source_key: &str = source_key.into(); + let target_key: &str = target_key.into(); + let selected = table + .get(selected_key) + .map_err_str(GlobalConfigTableError::Read)? + .map(|value| value.value()); + let target = table + .get(target_key) + .map_err_str(GlobalConfigTableError::Read)? + .map(|value| value.value()); + + // an empty selection must replace the saved value, not inherit the other mode's wallet + for (key, value) in [(source_key, selected), (selected_key, target)] { + match value { + Some(value) => { + table.insert(key, value).map_err_str(GlobalConfigTableError::Save)?; + } + + None => { + table.remove(key).map_err_str(GlobalConfigTableError::Save)?; + } + } + } - // get the selected wallet id for decoy mode if it exists and select it - if let Some(id) = self.get(GlobalConfigKey::DecoySelectedWalletId).ok().flatten() { - let _ = self - .select_wallet(id.clone().into()) - .tap_err(|error| error!("unable to select wallet for decoy {id}: {error}")); + table + .insert(mode_key, target_is_decoy.to_string()) + .map_err_str(GlobalConfigTableError::Save)?; } - self.set(GlobalConfigKey::InDecoyMode, "true".to_string())?; + write_txn.commit().map_err_str(Error::DatabaseAccess)?; Updater::send_update(Update::DatabaseUpdated); Ok(()) } - pub fn set_main_mode(&self) -> Result<()> { - // already in main mode, nothing to do - if self.is_in_main_mode() { - warn!("already in main mode"); - return Ok(()); - } + /// Drop every selection that still points at a wallet that no longer exists + /// + /// Main and decoy selections are only rewritten on a mode switch, so a deleted + /// wallet would otherwise be re-selected the next time the mode changes + pub(crate) fn forget_wallet(&self, wallet_id: &WalletId) -> Result<()> { + let keys = [ + GlobalConfigKey::SelectedWalletId, + GlobalConfigKey::MainSelectedWalletId, + GlobalConfigKey::DecoySelectedWalletId, + ]; + let write_txn = self.db.begin_write().map_err_str(Error::DatabaseAccess)?; - // currently in decoy mode, save the selected wallet id as the decoy selected wallet id - if let Some(id) = self.selected_wallet() { - let _ = self - .set(GlobalConfigKey::DecoySelectedWalletId, id.to_string()) - .tap_err(|error| error!("unable to set decoy selected wallet id ({id}): {error}")); - } + let removed_any = { + let mut table = write_txn.open_table(TABLE).map_err_str(Error::TableAccess)?; + let mut removed_any = false; + + for key in keys { + let key: &str = key.into(); + let selects_wallet = table + .get(key) + .map_err_str(GlobalConfigTableError::Read)? + .is_some_and(|value| value.value() == wallet_id.as_str()); + if !selects_wallet { + continue; + } + + table.remove(key).map_err_str(GlobalConfigTableError::Save)?; + removed_any = true; + } + + removed_any + }; - // set the selected wallet id to the one saved if there is one - if let Some(id) = self.get(GlobalConfigKey::MainSelectedWalletId).ok().flatten() { - let _ = self - .select_wallet(id.clone().into()) - .tap_err(|error| error!("unable to select wallet for main {id}: {error}")); + if !removed_any { + write_txn.abort().map_err_str(Error::DatabaseAccess)?; + return Ok(()); } - self.set(GlobalConfigKey::InDecoyMode, "false".to_string())?; + write_txn.commit().map_err_str(Error::DatabaseAccess)?; Updater::send_update(Update::DatabaseUpdated); Ok(()) @@ -474,6 +534,51 @@ mod tests { use crate::custom_block_explorer::BlockExplorerOption; use cove_types::Network; + #[test] + fn mode_switch_clears_selection_when_target_mode_has_no_wallet() { + crate::app::reconcile::test_support::init_noop_updater(); + let (_tmp, table) = test_table(); + let main = cove_types::WalletId::preview_new_random(); + table.select_wallet(main.clone()).unwrap(); + + table.set_decoy_mode().unwrap(); + + assert!(table.is_in_decoy_mode()); + assert_eq!(table.selected_wallet(), None); + + table.set_main_mode().unwrap(); + + assert!(table.is_in_main_mode()); + assert_eq!(table.selected_wallet(), Some(main)); + assert_eq!(table.get(super::GlobalConfigKey::DecoySelectedWalletId).unwrap(), None); + } + + #[test] + fn mode_switch_forgets_cleared_selection_and_preserves_other_mode() { + crate::app::reconcile::test_support::init_noop_updater(); + let (_tmp, table) = test_table(); + let main = cove_types::WalletId::preview_new_random(); + let decoy = cove_types::WalletId::preview_new_random(); + table.select_wallet(main.clone()).unwrap(); + table.set_decoy_mode().unwrap(); + table.select_wallet(decoy.clone()).unwrap(); + table.set_main_mode().unwrap(); + assert_eq!(table.selected_wallet(), Some(main)); + + table.clear_selected_wallet().unwrap(); + table.set_decoy_mode().unwrap(); + assert_eq!(table.selected_wallet(), Some(decoy.clone())); + + table.set_decoy_mode().unwrap(); + assert_eq!(table.selected_wallet(), Some(decoy.clone())); + + table.set_main_mode().unwrap(); + assert_eq!(table.selected_wallet(), None); + + table.set_decoy_mode().unwrap(); + assert_eq!(table.selected_wallet(), Some(decoy)); + } + #[test] fn test_selected_node_key() { use super::GlobalConfigKey; @@ -702,6 +807,27 @@ mod tests { ); } + #[test] + fn forget_wallet_clears_every_matching_selection() { + crate::app::reconcile::test_support::init_noop_updater(); + let (_tmp, table) = test_table(); + let deleted = cove_types::WalletId::preview_new_random(); + let other = cove_types::WalletId::preview_new_random(); + + table.set(super::GlobalConfigKey::SelectedWalletId, deleted.to_string()).unwrap(); + table.set(super::GlobalConfigKey::MainSelectedWalletId, deleted.to_string()).unwrap(); + table.set(super::GlobalConfigKey::DecoySelectedWalletId, other.to_string()).unwrap(); + + table.forget_wallet(&deleted).unwrap(); + + assert_eq!(table.selected_wallet(), None); + assert_eq!(table.get(super::GlobalConfigKey::MainSelectedWalletId).unwrap(), None); + assert_eq!( + table.get(super::GlobalConfigKey::DecoySelectedWalletId).unwrap().as_deref(), + Some(other.as_str()) + ); + } + fn test_table() -> (tempfile::TempDir, super::GlobalConfigTable) { let tmp = tempfile::tempdir().unwrap(); let db = std::sync::Arc::new(redb::Database::create(tmp.path().join("test.redb")).unwrap()); diff --git a/rust/src/database/global_flag.rs b/rust/src/database/global_flag.rs index 145f0fc29..183c07c87 100644 --- a/rust/src/database/global_flag.rs +++ b/rust/src/database/global_flag.rs @@ -152,12 +152,7 @@ impl GlobalFlagTable { debug!("setting global flag: {key:?} to {value}"); let write_txn = self.db.begin_write().map_err_str(Error::DatabaseAccess)?; - { - let mut table = write_txn.open_table(TABLE).map_err_str(Error::TableAccess)?; - - let key: &'static str = key.into(); - table.insert(key, value).map_err_str(GlobalFlagTableError::Save)?; - } + self.set_inner_in_transaction(&write_txn, key, value)?; write_txn.commit().map_err_str(Error::DatabaseAccess)?; @@ -168,10 +163,35 @@ impl GlobalFlagTable { Ok(()) } + pub(crate) fn set_onboarding_complete_in_transaction( + &self, + write_txn: &redb::WriteTransaction, + ) -> Result<(), Error> { + self.set_inner_in_transaction(write_txn, GlobalFlagKey::CompletedOnboarding, true) + } + + fn set_inner_in_transaction( + &self, + write_txn: &redb::WriteTransaction, + key: GlobalFlagKey, + value: bool, + ) -> Result<(), Error> { + let mut table = write_txn.open_table(TABLE).map_err_str(Error::TableAccess)?; + + let key: &'static str = key.into(); + table.insert(key, value).map_err_str(GlobalFlagTableError::Save)?; + + Ok(()) + } + pub(crate) fn is_onboarding_complete(&self) -> bool { self.get_bool_config(GlobalFlagKey::CompletedOnboarding) } + pub(crate) fn try_is_onboarding_complete(&self) -> Result { + self.get(GlobalFlagKey::CompletedOnboarding) + } + pub(crate) fn mark_onboarding_complete(&self) -> Result<(), Error> { if self.is_onboarding_complete() { return Ok(()); diff --git a/rust/src/database/unsigned_transactions.rs b/rust/src/database/unsigned_transactions.rs index 892cfc2df..d1deabe24 100644 --- a/rust/src/database/unsigned_transactions.rs +++ b/rust/src/database/unsigned_transactions.rs @@ -115,6 +115,39 @@ impl UnsignedTransactionsTable { Ok(records) } + /// Remove every unsigned transaction that belongs to a wallet + /// + /// Wallet deletion owns this call so PSBT data does not outlive its wallet + pub(crate) fn delete_by_wallet_id(&self, wallet_id: &WalletId) -> Result<(), Error> { + let write_txn = self.db.begin_write().map_err_str(Error::DatabaseAccess)?; + + let removed_tx_ids = { + let mut by_wallet = + write_txn.open_table(BY_WALLET_TABLE).map_err_str(Error::TableAccess)?; + + by_wallet + .remove(wallet_id) + .map_err_str(UnsignedTransactionsTableError::Save)? + .map(|value| value.value()) + }; + + let Some(tx_ids) = removed_tx_ids else { + write_txn.abort().map_err_str(Error::DatabaseAccess)?; + return Ok(()); + }; + + { + let mut main = write_txn.open_table(MAIN_TABLE).map_err_str(Error::TableAccess)?; + for tx_id in &tx_ids { + main.remove(tx_id).map_err_str(UnsignedTransactionsTableError::Save)?; + } + } + + write_txn.commit().map_err_str(Error::DatabaseAccess)?; + + Ok(()) + } + fn delete_tx_id(&self, key: &TxId) -> Result<(), Error> { let write_txn = self.db.begin_write().map_err_str(Error::DatabaseAccess)?; @@ -227,3 +260,59 @@ impl UnsignedTransactionRecord { self.created_at } } + +#[cfg(test)] +mod tests { + use super::{UnsignedTransactionRecord, UnsignedTransactionsTable}; + use crate::transaction::TxId; + use cove_types::WalletId; + + fn test_table() -> (tempfile::TempDir, UnsignedTransactionsTable) { + let tmp = tempfile::tempdir().unwrap(); + let db = std::sync::Arc::new(redb::Database::create(tmp.path().join("test.redb")).unwrap()); + let write_txn = db.begin_write().unwrap(); + let table = UnsignedTransactionsTable::new(db, &write_txn); + write_txn.commit().unwrap(); + + (tmp, table) + } + + fn record(wallet_id: &WalletId) -> UnsignedTransactionRecord { + UnsignedTransactionRecord { + wallet_id: wallet_id.clone(), + tx_id: TxId::preview_new(), + confirm_details: cove_types::confirm::confirm_details_preview_new(), + created_at: 0, + } + } + + #[test] + fn delete_by_wallet_id_removes_only_that_wallets_records() { + let (_tmp, table) = test_table(); + let deleted = WalletId::preview_new_random(); + let kept = WalletId::preview_new_random(); + + let deleted_records = [record(&deleted), record(&deleted)]; + for record in &deleted_records { + table.save_tx(record.tx_id, record.clone()).unwrap(); + } + let kept_record = record(&kept); + table.save_tx(kept_record.tx_id, kept_record.clone()).unwrap(); + + table.delete_by_wallet_id(&deleted).unwrap(); + + assert!(table.get_by_wallet_id(&deleted).unwrap().is_empty()); + for record in &deleted_records { + assert_eq!(table.get_tx(&record.tx_id).unwrap(), None); + } + assert_eq!(table.get_by_wallet_id(&kept).unwrap(), vec![kept_record.clone()]); + assert_eq!(table.get_tx(&kept_record.tx_id).unwrap(), Some(kept_record)); + } + + #[test] + fn delete_by_wallet_id_is_a_no_op_for_unknown_wallets() { + let (_tmp, table) = test_table(); + + table.delete_by_wallet_id(&WalletId::preview_new_random()).unwrap(); + } +} diff --git a/rust/src/manager/auth_manager.rs b/rust/src/manager/auth_manager.rs index 38be5f40c..289a8be0a 100644 --- a/rust/src/manager/auth_manager.rs +++ b/rust/src/manager/auth_manager.rs @@ -21,8 +21,21 @@ pub static AUTH_MANAGER: LazyLock> = LazyLock::new(RustAuth #[allow(clippy::enum_variant_names)] // all variants describe state changes, postfix is intentional pub enum AuthManagerReconcileMessage { AuthTypeChanged(AuthType), - WipeDataPinChanged, - DecoyPinChanged, + /// Whether a wipe data PIN is now set + WipeDataPinChanged(bool), + /// Whether a decoy PIN is now set + DecoyPinChanged(bool), +} + +/// Authentication settings a frontend mirrors +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Record)] +pub struct AuthSettings { + /// How the app is unlocked + pub auth_type: AuthType, + /// Whether a wipe data PIN is set + pub is_wipe_data_pin_enabled: bool, + /// Whether a decoy PIN is set + pub is_decoy_pin_enabled: bool, } #[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] @@ -146,6 +159,21 @@ impl RustAuthManager { fn init() -> Arc { Self { reconciler: ReconcileChannel::new(1000) }.into() } + + /// Sends every setting in `settings` so frontends end on those values + pub(crate) fn publish(&self, settings: &AuthSettings) { + self.send(Message::AuthTypeChanged(settings.auth_type)); + self.send(Message::WipeDataPinChanged(settings.is_wipe_data_pin_enabled)); + self.send(Message::DecoyPinChanged(settings.is_decoy_pin_enabled)); + } + + pub(crate) fn settings(&self) -> AuthSettings { + AuthSettings { + auth_type: self.auth_type(), + is_wipe_data_pin_enabled: self.is_wipe_data_pin_enabled(), + is_decoy_pin_enabled: self.is_decoy_pin_enabled(), + } + } } #[uniffi::export] @@ -221,7 +249,7 @@ impl RustAuthManager { error!("unable to delete decoy pin: {error:?}"); } - self.send(Message::DecoyPinChanged); + self.send(Message::DecoyPinChanged(self.is_decoy_pin_enabled())); } /// Set the decoy pin @@ -230,7 +258,7 @@ impl RustAuthManager { // set the pin Database::global().global_config.set_decoy_pin(pin)?; - self.send(Message::DecoyPinChanged); + self.send(Message::DecoyPinChanged(true)); Ok(()) } @@ -264,7 +292,7 @@ impl RustAuthManager { // set the pin Database::global().global_config.set_wipe_data_pin(pin)?; - self.send(Message::WipeDataPinChanged); + self.send(Message::WipeDataPinChanged(true)); Ok(()) } @@ -282,7 +310,7 @@ impl RustAuthManager { error!("unable to delete wipe data pin: {error:?}"); } - self.send(Message::WipeDataPinChanged); + self.send(Message::WipeDataPinChanged(self.is_wipe_data_pin_enabled())); } // private diff --git a/rust/src/manager/cloud_backup_manager.rs b/rust/src/manager/cloud_backup_manager.rs index 4db27a879..a9f7f47a5 100644 --- a/rust/src/manager/cloud_backup_manager.rs +++ b/rust/src/manager/cloud_backup_manager.rs @@ -14,9 +14,11 @@ mod pending; mod pending_enable; mod pending_verification; mod reconcile; +mod recovery_coverage; mod remote_inventory; mod store; mod sync_health; +mod timing; mod verify; mod wallet_changes; mod wallets; @@ -86,7 +88,8 @@ pub(crate) use self::keychain::CloudBackupKeychain; pub(crate) use self::model::test_support; pub(crate) use self::model::{ CloudBackupAcceptedEnablePrompt, CloudBackupDetailInventorySnapshot, - CloudBackupDetailInventorySnapshotResult, CloudBackupDetailResult, CloudBackupDisableOutcome, + CloudBackupDetailInventorySnapshotResult, CloudBackupDetailProviderConfirmation, + CloudBackupDetailResult, CloudBackupDetailSnapshotCompletion, CloudBackupDisableOutcome, CloudBackupEnableState, CloudBackupExclusiveOperation, CloudBackupExclusiveOperationClaim, CloudBackupStateReducer, CloudBackupStateReducerEvent, CloudBackupStatus, }; @@ -117,6 +120,7 @@ pub(crate) use self::pending_verification::{ }; use self::reconcile::CloudBackupReconcileMessage; pub use self::reconcile::{DriveAccountSwitchPlatformState, DriveAccountSwitchReconcileAction}; +pub(crate) use self::recovery_coverage::CloudBackupRecoveryCoverage; pub(crate) use self::remote_inventory::current_namespace_wallet_record_ids; pub(crate) use self::store::CloudBackupStore; pub(crate) use self::sync_health::SYNC_HEALTH_MISSING_MASTER_KEY_MESSAGE; @@ -846,8 +850,7 @@ impl RustCloudBackupManager { .mutate(mutation) .map_err(|source| CloudBackupError::internal_context(context, source))?; - self.reconcile_runtime_status(Self::runtime_status_for(&committed.state)); - self.refresh_persisted_flags(); + self.reconcile_persisted_state(&committed.state); Ok(committed.outcome) } @@ -968,22 +971,6 @@ mod manager_test_support { use super::*; impl RustCloudBackupManager { - pub(crate) fn persist_cloud_backup_state( - &self, - state: &PersistedCloudBackupState, - context: &str, - ) -> Result<(), CloudBackupError> { - Database::global() - .cloud_backup_state - .set(state) - .map_err(|source| CloudBackupError::internal_context(context, source))?; - - self.reconcile_runtime_status(Self::runtime_status_for(state)); - self.refresh_persisted_flags(); - - Ok(()) - } - pub(crate) fn model_snapshot(&self) -> test_support::CloudBackupModelSnapshot { self.state.read().snapshot() } @@ -1308,12 +1295,6 @@ impl RustCloudBackupManager { send!(self.supervisor.cancel_restore()); } - pub(crate) async fn cancel_restore_and_wait(&self) { - if let Err(error) = call!(self.supervisor.cancel_restore()).await { - warn!("restore_from_cloud_backup: failed to await restore cancellation: {error}"); - } - } - pub(crate) fn restore_from_cloud_backup(&self) { info!("restore_from_cloud_backup: enqueueing restore task"); send!(self.supervisor.start_restore_from_cloud_backup()); @@ -1343,7 +1324,7 @@ mod tests { use super::*; use crate::database::cloud_backup::{ PersistedBackupSyncState, PersistedBackupVerificationState, PersistedConfiguredCloudBackup, - PersistedPasskeyState, + PersistedPasskeyState, PersistedVerificationRequirement, }; use act_zero::call; use cove_device::cloud_storage::CloudStorageError; @@ -1552,6 +1533,7 @@ mod tests { #[test] fn verification_metadata_is_needs_verification_when_unverified() { let db_state = persisted_configured_state(PersistedBackupVerificationState::Required { + reason: PersistedVerificationRequirement::Unknown, last_verified_at: Some(21), requested_at: None, dismissed_at: None, diff --git a/rust/src/manager/cloud_backup_manager/actors/restore.rs b/rust/src/manager/cloud_backup_manager/actors/restore.rs index ecf140262..3f56d9811 100644 --- a/rust/src/manager/cloud_backup_manager/actors/restore.rs +++ b/rust/src/manager/cloud_backup_manager/actors/restore.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use act_zero::{Addr, call}; @@ -10,6 +12,7 @@ use tokio::time::Instant; use tracing::{info, warn}; use zeroize::Zeroizing; +use crate::backup::import::LocalWalletConflict; use crate::database::cloud_backup::PersistedCloudBackupState; use crate::manager::cloud_backup_manager::ops::try_restore_from_local_master_key; use crate::manager::cloud_backup_manager::wallets::{ @@ -25,9 +28,13 @@ use crate::manager::cloud_backup_manager::{ GENERIC_CLOUD_BACKUP_ERROR_MESSAGE, RustCloudBackupManager, blocking_cloud_error, is_connectivity_related_issue, is_provider_wide_interruption, offline_error_for_step, }; +use crate::wallet::metadata::WalletId; -use crate::manager::cloud_backup_manager::keychain::CloudBackupKeychain; +use crate::manager::cloud_backup_manager::keychain::{ + CloudBackupKeychain, RestoreActivationKeychainSnapshot, +}; use crate::manager::cloud_backup_manager::model::CloudBackupExclusiveOperationClaim; +use crate::manager::cloud_backup_manager::timing::log_elapsed; use super::CloudBackupSupervisor; @@ -118,6 +125,50 @@ enum RestoreResolution { Nothing, } +/// What applying the downloaded wallets produced locally +struct RestoreApplication { + resolution: RestoreResolution, + failures: RestoreFailureCategory, +} + +/// The one failure category a restore can report when no wallet was restored +/// +/// A run where every wallet hit a local conflict must keep that category: the +/// reader has to learn their local data was kept unchanged +#[derive(Clone, Copy)] +enum RestoreFailureCategory { + None, + LocalConflict(LocalWalletConflict), + Mixed, +} + +impl RestoreFailureCategory { + fn record(self, error: &CloudBackupError) -> Self { + let CloudBackupError::LocalWalletConflict(conflict) = *error else { + return Self::Mixed; + }; + + match self { + Self::None => Self::LocalConflict(conflict), + Self::LocalConflict(recorded) if recorded == conflict => self, + // unreadable and mismatched items are both local data the restore kept, so the + // mismatch copy stays truthful for the whole run + Self::LocalConflict(_) => Self::LocalConflict(LocalWalletConflict::Mismatch), + Self::Mixed => Self::Mixed, + } + } + + /// The error to report when every wallet in the run failed + fn all_failed_error(self) -> CloudBackupError { + match self { + Self::LocalConflict(conflict) => CloudBackupError::LocalWalletConflict(conflict), + Self::None | Self::Mixed => { + CloudBackupError::Internal("all wallets failed to restore".into()) + } + } + } +} + impl RestoreResolution { fn restored(self, namespace_index: usize) -> Self { match self { @@ -134,29 +185,6 @@ impl RestoreResolution { } } -/// List the wallet records of each namespace and count them for download progress -async fn list_namespace_wallets( - cloud: &CloudStorageClient, - namespaces: Vec, -) -> Result<(Vec<(RestorableNamespace, Vec)>, u32), CloudBackupError> { - let mut namespace_wallets = Vec::with_capacity(namespaces.len()); - let mut listed_wallet_count = 0; - - for namespace in namespaces { - let wallet_record_ids = - cloud.list_wallet_backups(namespace.namespace_id.clone()).await.map_err(|error| { - blocking_cloud_error( - BlockingCloudStep::Restore, - CloudBackupError::cloud_storage_context("list wallet backups", error), - ) - })?; - listed_wallet_count += wallet_record_ids.len() as u32; - namespace_wallets.push((namespace, wallet_record_ids)); - } - - Ok((namespace_wallets, listed_wallet_count)) -} - pub(crate) async fn lookup_wallet_backup( reader: WalletBackupReader, record_id: String, @@ -170,28 +198,45 @@ pub(crate) struct RestoreOperation { operation_claim: CloudBackupExclusiveOperationClaim, supervisor: Addr, event_sender: Option>, + cancellation: Arc, } impl RestoreOperation { pub(crate) fn new( operation_claim: CloudBackupExclusiveOperationClaim, supervisor: Addr, + cancellation: Arc, ) -> Self { - Self { operation_claim, supervisor, event_sender: None } + Self { operation_claim, supervisor, event_sender: None, cancellation } } pub(crate) fn new_with_events( operation_claim: CloudBackupExclusiveOperationClaim, supervisor: Addr, event_sender: flume::Sender, + cancellation: Arc, ) -> Self { - Self { operation_claim, supervisor, event_sender: Some(event_sender) } + Self { operation_claim, supervisor, event_sender: Some(event_sender), cancellation } } pub(crate) async fn ensure_current(&self) -> Result<(), CloudBackupError> { + if self.cancellation_requested() { + return Err(CloudBackupError::Cancelled); + } + call!(self.supervisor.ensure_restore_current(self.operation_claim)) .await - .map_err(|_| CloudBackupError::Cancelled)? + .map_err(|_| CloudBackupError::Cancelled)??; + + if self.cancellation_requested() { + return Err(CloudBackupError::Cancelled); + } + + Ok(()) + } + + fn cancellation_requested(&self) -> bool { + self.cancellation.load(Ordering::Acquire) } pub(crate) async fn apply_status( @@ -256,26 +301,27 @@ impl RestoreOperation { .map_err(|_| CloudBackupError::Cancelled)? } - pub(crate) async fn save_keychain_state( + pub(crate) async fn commit_restored_namespace( &self, master_key: MasterKey, passkey: Option, namespace_id: String, + state: PersistedCloudBackupState, + wallet_ids: Vec, ) -> Result<(), CloudBackupError> { - call!(self.supervisor.save_restore_keychain_state( - self.operation_claim, - master_key, - passkey, - namespace_id - )) - .await - .map_err(|_| CloudBackupError::Cancelled)? + let commit = + RestoredNamespaceCommit { master_key, passkey, namespace_id, state, wallet_ids }; + + call!(self.supervisor.commit_restore_namespace_activation(self.operation_claim, commit)) + .await + .map_err(|_| CloudBackupError::Cancelled)? } pub(crate) async fn restore_from_cloud_backup( &self, manager: &RustCloudBackupManager, ) -> Result { + self.ensure_current().await?; manager.ensure_cloud_connectivity(BlockingCloudStep::Restore)?; self.clear_enable_progress().await?; self.apply_outcome(CloudBackupRestoreOutcome::ProgressCleared).await?; @@ -287,7 +333,7 @@ impl RestoreOperation { self.ensure_current().await?; let (namespace_wallets, listed_wallet_count) = - list_namespace_wallets(&cloud, restorable_namespaces).await?; + self.list_namespace_wallets(&cloud, restorable_namespaces).await?; let mut report = CloudBackupRestoreReport { wallets_restored: 0, @@ -304,15 +350,15 @@ impl RestoreOperation { &mut report, ) .await?; - let resolution = self.apply_downloaded_wallets(&downloaded_wallets, &mut report).await?; + let applied = self.apply_downloaded_wallets(&downloaded_wallets, &mut report).await?; - if matches!(resolution, RestoreResolution::Nothing) && report.wallets_failed > 0 { + if matches!(applied.resolution, RestoreResolution::Nothing) && report.wallets_failed > 0 { self.apply_outcome(CloudBackupRestoreOutcome::ProgressCleared).await?; - return Err(CloudBackupError::Internal("all wallets failed to restore".into())); + return Err(applied.failures.all_failed_error()); } let restored_status = - self.resolve_restored_status(manager, resolution, &namespace_wallets).await?; + self.resolve_restored_status(applied.resolution, &namespace_wallets).await?; self.apply_outcome(CloudBackupRestoreOutcome::ProgressCleared).await?; self.apply_status(restored_status).await?; @@ -321,6 +367,36 @@ impl RestoreOperation { Ok(report) } + /// Lists wallet records for each matched namespace + async fn list_namespace_wallets( + &self, + cloud: &CloudStorageClient, + namespaces: Vec, + ) -> Result<(Vec<(RestorableNamespace, Vec)>, u32), CloudBackupError> { + let mut namespace_wallets = Vec::with_capacity(namespaces.len()); + let mut listed_wallet_count = 0; + + for namespace in namespaces { + self.ensure_current().await?; + let result = log_elapsed( + "Restore: cloud wallet listing", + cloud.list_wallet_backups(namespace.namespace_id.clone()), + ) + .await; + self.ensure_current().await?; + let wallet_record_ids = result.map_err(|error| { + blocking_cloud_error( + BlockingCloudStep::Restore, + CloudBackupError::cloud_storage_context("list wallet backups", error), + ) + })?; + listed_wallet_count += wallet_record_ids.len() as u32; + namespace_wallets.push((namespace, wallet_record_ids)); + } + + Ok((namespace_wallets, listed_wallet_count)) + } + /// Passkey matching first, with the local master key as the fallback async fn discover_restorable_namespaces( &self, @@ -347,9 +423,11 @@ impl RestoreOperation { info!( "Restore: passkey matching found no restore, trying local master key fallback" ); + self.ensure_current().await?; let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - let (master_key, namespace_id) = try_restore_from_local_master_key(cloud, &cspp) - .await + let fallback = try_restore_from_local_master_key(cloud, &cspp).await; + self.ensure_current().await?; + let (master_key, namespace_id) = fallback .map_err(|error| blocking_cloud_error(BlockingCloudStep::Restore, error))? .ok_or(error)?; Ok(vec![RestorableNamespace { namespace_id, master_key, passkey: None }]) @@ -409,7 +487,7 @@ impl RestoreOperation { &self, downloaded_wallets: &[(usize, DownloadedWalletRecord)], report: &mut CloudBackupRestoreReport, - ) -> Result { + ) -> Result { let existing_identities = crate::wallet_identity::collect_existing_wallet_identities() .map_err(|source| { CloudBackupError::internal_context("collect wallet identities", source) @@ -417,6 +495,11 @@ impl RestoreOperation { let mut restore_session = WalletRestoreSession::new(existing_identities); let restore_total = downloaded_wallets.len() as u32; let mut resolution = RestoreResolution::Nothing; + // download failures already counted here have no local category of their own + let mut failures = match report.wallets_failed { + 0 => RestoreFailureCategory::None, + _ => RestoreFailureCategory::Mixed, + }; self.send_restore_progress(restore_progress_flow( RestoreProgressPhase::Restoring, @@ -446,6 +529,7 @@ impl RestoreOperation { Err(CloudBackupError::Cancelled) => return Err(CloudBackupError::Cancelled), Err(error) => { warn!("Failed to restore wallet backup: {error}"); + failures = failures.record(&error); report.wallets_failed += 1; report.failed_wallet_errors.push(error.reader_message()); } @@ -459,13 +543,12 @@ impl RestoreOperation { .await?; } - Ok(resolution) + Ok(RestoreApplication { resolution, failures }) } /// Pick the cloud backup status to end on and activate the namespace it came from async fn resolve_restored_status( &self, - manager: &RustCloudBackupManager, resolution: RestoreResolution, namespace_wallets: &[(RestorableNamespace, Vec)], ) -> Result { @@ -474,7 +557,7 @@ impl RestoreOperation { match resolution { RestoreResolution::Restored { namespace_index } => { if let Some(active) = namespace_at(namespace_index) { - self.activate_restored_namespace(manager, active).await?; + self.activate_restored_namespace(active).await?; } Ok(CloudBackupStatus::Enabled) @@ -485,7 +568,7 @@ impl RestoreOperation { if matches!(state, PersistedCloudBackupState::Disabled) && let Some(active) = namespace_at(namespace_index) { - self.activate_restored_namespace(manager, active).await?; + self.activate_restored_namespace(active).await?; return Ok(CloudBackupStatus::Enabled); } @@ -514,31 +597,26 @@ impl RestoreOperation { async fn activate_restored_namespace( &self, - manager: &RustCloudBackupManager, active: &RestorableNamespace, ) -> Result<(), CloudBackupError> { + self.ensure_current().await?; let master_key = MasterKey::from_bytes(*active.master_key.as_bytes()); let passkey = active.passkey.as_ref().map(RestoredPasskeyMaterial::from); let wallets = CloudBackupStore::global().all_wallets()?; let wallet_count = wallets.len() as u32; - - self.save_keychain_state(master_key, passkey, active.namespace_id.clone()).await?; - let enabled_state = PersistedCloudBackupState::configured_after_restore( cove_util::time::unix_timestamp_secs_or_zero(), wallet_count, ); - self.persist_cloud_backup_state( + + self.commit_restored_namespace( + master_key, + passkey, + active.namespace_id.clone(), enabled_state, - "persist restored cloud backup state".into(), + wallets.into_iter().map(|wallet| wallet.id).collect(), ) - .await?; - - manager.mark_wallet_blobs_dirty_for_background_upload( - wallets.into_iter().map(|wallet| wallet.id), - )?; - - Ok(()) + .await } async fn download_wallets_for_restore( @@ -549,34 +627,37 @@ impl RestoreOperation { report: &mut CloudBackupRestoreReport, progress: &mut RestoreDownloadProgress, ) -> Result, CloudBackupError> { + self.ensure_current().await?; let mut downloaded_wallets = Vec::with_capacity(wallet_record_ids.len()); - let mut lookups = stream::iter( - wallet_record_ids - .iter() - .cloned() - .map(|record_id| lookup_wallet_backup(reader.clone(), record_id)), - ) - .buffered(CLOUD_BACKUP_IO_CONCURRENCY); + let mut failed_wallet_errors = Vec::new(); + let mut lookups = stream::iter(wallet_record_ids.iter().cloned().enumerate().map( + |(index, record_id)| { + let reader = reader.clone(); + + async move { (index, lookup_wallet_backup(reader, record_id).await) } + }, + )) + .buffer_unordered(CLOUD_BACKUP_IO_CONCURRENCY); - while let Some((record_id, lookup)) = lookups.next().await { + while let Some((index, (record_id, lookup))) = lookups.next().await { self.ensure_current().await?; let record_name = format!("{namespace_id}/{record_id}"); match lookup { Ok(WalletBackupLookup::Found(wallet)) => { - downloaded_wallets.push((record_name.clone(), wallet)); + downloaded_wallets.push((index, (record_name, wallet))); } Ok(WalletBackupLookup::NotFound) => { let error = CloudBackupError::NoBackupFound.reader_message(); warn!("Failed to download wallet backup: {error}"); report.wallets_failed += 1; - report.failed_wallet_errors.push(error); + failed_wallet_errors.push((index, error)); } Ok(WalletBackupLookup::UnsupportedVersion(version)) => { warn!("Failed to download wallet backup: unsupported version {version}"); let error = CLOUD_BACKUP_COMPATIBILITY_MESSAGE.to_string(); report.wallets_failed += 1; - report.failed_wallet_errors.push(error); + failed_wallet_errors.push((index, error)); } Err(error) => { if is_provider_wide_interruption(&error) { @@ -585,7 +666,7 @@ impl RestoreOperation { let error = GENERIC_CLOUD_BACKUP_ERROR_MESSAGE.to_string(); warn!("Failed to download wallet backup: {error}"); report.wallets_failed += 1; - report.failed_wallet_errors.push(error); + failed_wallet_errors.push((index, error)); } } @@ -599,7 +680,16 @@ impl RestoreOperation { .await?; } - Ok(downloaded_wallets) + self.ensure_current().await?; + + // completion order must not change which duplicate wallet is restored first + downloaded_wallets.sort_by_key(|(index, _)| *index); + failed_wallet_errors.sort_by_key(|(index, _)| *index); + report + .failed_wallet_errors + .extend(failed_wallet_errors.into_iter().map(|(_, error)| error)); + + Ok(downloaded_wallets.into_iter().map(|(_, wallet)| wallet).collect()) } /// Restore via passkey-based namespace matching (fresh device path) @@ -607,14 +697,15 @@ impl RestoreOperation { /// Tries the selected passkey across all downloaded namespaces. If it /// doesn't match any of them, returns `PasskeyMismatch` so the caller can /// try local master key fallback or prompt the user to try a different - /// passkey. Successful matches are non-empty + /// passkey. Successful matches are non-empty, including the ones kept when the + /// user cancels or authentication fails after an earlier match async fn restore_via_passkey_matching( &self, cloud: &CloudStorageClient, passkey: &PasskeyAccess, ) -> Result, CloudBackupError> { let matcher = NamespacePasskeyMatcher::new(cloud, passkey); - let mut session = matcher.start_session(); + let mut session = matcher.start_session_with_cancellation(self.cancellation.clone()); let started_at = Instant::now(); let mut first_match_at = None; let mut no_match_refresh_index = 0; @@ -625,7 +716,11 @@ impl RestoreOperation { loop { self.ensure_current().await?; - let mut namespaces = match cloud.list_namespaces().await { + let namespace_result = + log_elapsed("Restore: cloud namespace listing", cloud.list_namespaces()).await; + self.ensure_current().await?; + + let mut namespaces = match namespace_result { Ok(namespaces) => namespaces, Err(error) if is_connectivity_related_issue(&error) => { warn!( @@ -651,7 +746,11 @@ impl RestoreOperation { "Restore: passkey candidate refresh refresh_index={refresh_index} namespace_count={}", namespaces.len() ); - match session.match_snapshot(&namespaces).await? { + let match_result = session.match_snapshot(&namespaces).await; + self.ensure_current().await?; + let match_result = match_result?; + + match match_result { NamespaceMatchSnapshotOutcome::Matched(matches) => { info!("Restore: matched {} namespace(s)", matches.len()); merge_namespace_matches(&mut accumulated_matches, matches); @@ -664,6 +763,20 @@ impl RestoreOperation { return Ok(accumulated_matches); } + NamespaceMatchSnapshotOutcome::Cancelled(matches) => { + merge_namespace_matches(&mut accumulated_matches, matches); + return Ok(accumulated_matches); + } + NamespaceMatchSnapshotOutcome::OperationCancelled => { + return Err(CloudBackupError::Cancelled); + } + NamespaceMatchSnapshotOutcome::AuthenticationFailed { matches, error } => { + merge_namespace_matches(&mut accumulated_matches, matches); + return NamespaceMatchSnapshotOutcome::authentication_failure_result( + accumulated_matches, + error, + ); + } NamespaceMatchSnapshotOutcome::Continue => {} } @@ -719,6 +832,14 @@ pub(crate) struct RestoredPasskeyMaterial { pub(crate) prf_salt: [u8; 32], } +pub(crate) struct RestoredNamespaceCommit { + pub(crate) master_key: MasterKey, + pub(crate) passkey: Option, + pub(crate) namespace_id: String, + pub(crate) state: PersistedCloudBackupState, + pub(crate) wallet_ids: Vec, +} + impl From<&RestorableNamespacePasskey> for RestoredPasskeyMaterial { fn from(passkey: &RestorableNamespacePasskey) -> Self { Self { credential_id: passkey.credential_id.clone(), prf_salt: passkey.prf_salt } @@ -734,7 +855,12 @@ impl std::fmt::Debug for RestoredPasskeyMaterial { } } +/// Saves restored keychain entries, rolling back to `snapshot` if any save fails +/// +/// The caller captures `snapshot` before this call so it can also roll back +/// later steps of the same restore commit pub(crate) fn save_restore_keychain_entries( + snapshot: &RestoreActivationKeychainSnapshot, master_key: MasterKey, passkey: Option, namespace_id: String, @@ -753,35 +879,149 @@ pub(crate) fn save_restore_keychain_entries( }; if let Err((context, error)) = metadata_save_result { - if let Err(rollback) = cloud_keychain.clear_local_state() { - return Err(CloudBackupError::Internal( - format!("{context}: {error}; rollback failed: {rollback}").into(), - )); - } - - return Err(CloudBackupError::Internal(format!("{context}: {error}").into())); + return Err(rollback_restore_activation_keychain( + snapshot, + CloudBackupError::internal_context(context, error), + )); } if let Err(error) = cspp.save_master_key(&master_key) { - if let Err(rollback) = cloud_keychain.clear_local_state() { - return Err(CloudBackupError::Internal( - format!("save master key: {error}; rollback failed: {rollback}").into(), - )); - } - - return Err(CloudBackupError::Internal(format!("save master key: {error}").into())); + return Err(rollback_restore_activation_keychain( + snapshot, + CloudBackupError::internal_context("save master key", error), + )); } Ok(()) } +pub(crate) fn rollback_restore_activation_keychain( + snapshot: &RestoreActivationKeychainSnapshot, + original: CloudBackupError, +) -> CloudBackupError { + match CloudBackupKeychain::global().restore_activation_snapshot(snapshot) { + Ok(()) => original, + Err(rollback) => { + CloudBackupError::Internal(format!("{original}; rollback failed: {rollback}").into()) + } + } +} + #[cfg(test)] mod tests { use super::*; + use crate::database::Database; + use crate::database::cloud_backup::{ + PersistedCloudBackupStatus, PersistedCloudBlobState, restore_activation, + }; + use crate::manager::cloud_backup_manager::CloudBackupLifecycle; use crate::manager::cloud_backup_manager::keychain::{ CSPP_CREDENTIAL_ID_KEY, CSPP_NAMESPACE_ID_KEY, CSPP_PRF_SALT_KEY, }; - use crate::manager::cloud_backup_manager::ops::test_support::{test_globals, test_lock}; + use crate::manager::cloud_backup_manager::ops::test_support::{ + async_test_lock, encrypted_wallet_backup_bytes, ensure_cloud_backup_test_tokio_runtime, + new_restore_operation_for_test, persist_dirty_blob_state, persist_xpub_wallets, + reset_cloud_backup_test_state, sample_xpub, test_globals, test_lock, + xpub_only_wallet_metadata, + }; + + #[tokio::test(flavor = "current_thread")] + async fn restore_downloads_refill_slots_before_the_first_file_finishes_and_keep_order() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + ensure_cloud_backup_test_tokio_runtime(); + let manager = RustCloudBackupManager::init(); + reset_cloud_backup_test_state(&manager, globals); + + let master_key = MasterKey::generate(); + let namespace = master_key.namespace_id(); + let mut record_ids = Vec::new(); + let mut gates = Vec::new(); + for _ in 0..CLOUD_BACKUP_IO_CONCURRENCY + 2 { + let metadata = xpub_only_wallet_metadata(); + Keychain::global() + .save_wallet_xpub(&metadata.id, sample_xpub(&metadata).parse().unwrap()) + .unwrap(); + + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_wallet_backup_bytes(&metadata, &master_key, "revision", 1).await, + ); + + gates.push(globals.cloud.gate_wallet_backup_download_after_successes( + namespace.clone(), + record_id.clone(), + 0, + )); + + record_ids.push(record_id); + } + + let reader = WalletBackupReader::new( + CloudStorage::global_explicit_client(), + namespace.clone(), + Zeroizing::new(master_key.critical_data_key()), + ); + let operation = new_restore_operation_for_test(&manager).await; + let mut report = CloudBackupRestoreReport { + wallets_restored: 0, + wallets_failed: 0, + failed_wallet_errors: Vec::new(), + labels_failed_wallet_names: Vec::new(), + labels_failed_errors: Vec::new(), + }; + let mut progress = RestoreDownloadProgress { completed: 0, total: record_ids.len() as u32 }; + let download = operation.download_wallets_for_restore( + &reader, + &namespace, + &record_ids, + &mut report, + &mut progress, + ); + let release_downloads = async { + for gate in &gates[..CLOUD_BACKUP_IO_CONCURRENCY] { + gate.wait_until_blocked().await; + } + + assert_eq!( + globals.cloud.wallet_backup_download_attempt_count(), + CLOUD_BACKUP_IO_CONCURRENCY + ); + + gates[1].release(); + gates[CLOUD_BACKUP_IO_CONCURRENCY].wait_until_blocked().await; + assert_eq!( + globals.cloud.wallet_backup_download_attempt_count(), + CLOUD_BACKUP_IO_CONCURRENCY + 1 + ); + + gates[CLOUD_BACKUP_IO_CONCURRENCY].release(); + gates[CLOUD_BACKUP_IO_CONCURRENCY + 1].wait_until_blocked().await; + + for gate in &gates { + gate.release(); + } + }; + + let (downloaded, ()) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(download, release_downloads) + }) + .await + .expect("later downloads must start while the first file is blocked"); + let downloaded = downloaded.unwrap(); + + assert_eq!( + downloaded.iter().map(|(record_name, _)| record_name.clone()).collect::>(), + record_ids + .iter() + .map(|record_id| format!("{namespace}/{record_id}")) + .collect::>() + ); + assert_eq!(progress.completed, record_ids.len() as u32); + assert_eq!(report.wallets_failed, 0); + } #[test] fn restore_keychain_save_rolls_back_metadata_when_master_key_save_fails() { @@ -791,6 +1031,7 @@ mod tests { globals.keychain.fail_save_at(4); let result = save_restore_keychain_entries( + &CloudBackupKeychain::global().capture_restore_activation_snapshot(), cove_cspp::master_key::MasterKey::generate(), Some(RestoredPasskeyMaterial { credential_id: vec![1, 2, 3], prf_salt: [4; 32] }), "namespace-id".into(), @@ -807,24 +1048,32 @@ mod tests { let _guard = test_lock().lock(); let globals = test_globals(); globals.reset(); + let previous_master = cove_cspp::master_key::MasterKey::generate(); let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - cspp.save_master_key(&cove_cspp::master_key::MasterKey::generate()).unwrap(); + cspp.save_master_key(&previous_master).unwrap(); CloudBackupKeychain::global() .save_passkey_and_namespace(&[9, 8, 7], [6; 32], "old-namespace") .unwrap(); globals.keychain.fail_save_at(1); let result = save_restore_keychain_entries( + &CloudBackupKeychain::global().capture_restore_activation_snapshot(), cove_cspp::master_key::MasterKey::generate(), Some(RestoredPasskeyMaterial { credential_id: vec![1, 2, 3], prf_salt: [4; 32] }), "namespace-id".into(), ); assert!(result.is_err()); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); - assert!(globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).is_none()); - assert!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).is_none()); - assert!(globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).is_none()); + assert_eq!( + cspp.load_master_key_from_store().unwrap().as_ref().map(MasterKey::as_bytes), + Some(previous_master.as_bytes()) + ); + assert_eq!(globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).as_deref(), Some("090807")); + assert_eq!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY), Some(hex::encode([6; 32]))); + assert_eq!( + globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).as_deref(), + Some("old-namespace") + ); } #[test] @@ -832,24 +1081,252 @@ mod tests { let _guard = test_lock().lock(); let globals = test_globals(); globals.reset(); + let previous_master = cove_cspp::master_key::MasterKey::generate(); let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); - cspp.save_master_key(&cove_cspp::master_key::MasterKey::generate()).unwrap(); + cspp.save_master_key(&previous_master).unwrap(); CloudBackupKeychain::global() .save_passkey_and_namespace(&[9, 8, 7], [6; 32], "old-namespace") .unwrap(); globals.keychain.fail_save_at(1); let result = save_restore_keychain_entries( + &CloudBackupKeychain::global().capture_restore_activation_snapshot(), cove_cspp::master_key::MasterKey::generate(), None, "namespace-id".into(), ); assert!(result.is_err()); - assert!(cspp.load_master_key_from_store().unwrap().is_none()); - assert!(globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).is_none()); - assert!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY).is_none()); - assert!(globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).is_none()); + assert_eq!( + cspp.load_master_key_from_store().unwrap().as_ref().map(MasterKey::as_bytes), + Some(previous_master.as_bytes()) + ); + assert_eq!(globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).as_deref(), Some("090807")); + assert_eq!(globals.keychain.get_entry(CSPP_PRF_SALT_KEY), Some(hex::encode([6; 32]))); + assert_eq!( + globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).as_deref(), + Some("old-namespace") + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn restore_namespace_commit_stays_consistent_if_cancelled_after_keychain_save() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + ensure_cloud_backup_test_tokio_runtime(); + let manager = RustCloudBackupManager::init(); + reset_cloud_backup_test_state(&manager, globals); + + let first_wallet = xpub_only_wallet_metadata(); + let second_wallet = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![first_wallet.clone(), second_wallet.clone()]); + CloudBackupKeychain::global().save_namespace_id("previous-namespace").unwrap(); + Database::global().cloud_backup_state.set(&PersistedCloudBackupState::default()).unwrap(); + + let master_key = MasterKey::generate(); + let namespace_id = master_key.namespace_id(); + let active = RestorableNamespace { + namespace_id: namespace_id.clone(), + master_key, + passkey: Some(RestorableNamespacePasskey { + credential_id: vec![1, 2, 3], + prf_salt: [4; 32], + }), + }; + + let operation = new_restore_operation_for_test(&manager).await; + let cancellation = operation.cancellation.clone(); + globals.keychain.set_after_save(move |key| { + if key == CSPP_NAMESPACE_ID_KEY { + cancellation.store(true, Ordering::Release); + } + }); + + operation.activate_restored_namespace(&active).await.unwrap(); + + assert_eq!( + globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).as_deref(), + Some(namespace_id.as_str()) + ); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Enabled + ); + assert_eq!(manager.current_status(), CloudBackupStatus::Restoring); + assert!(matches!(manager.state().lifecycle, CloudBackupLifecycle::Configured(_))); + assert_eq!( + dirty_restore_wallet_ids(), + sorted_wallet_ids(&[first_wallet.id.clone(), second_wallet.id.clone()]) + ); + assert!(operation.cancellation.load(Ordering::Acquire)); + } + + #[tokio::test(flavor = "current_thread")] + async fn restore_namespace_commit_rolls_back_keychain_if_configured_state_persist_fails() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + ensure_cloud_backup_test_tokio_runtime(); + let manager = RustCloudBackupManager::init(); + reset_cloud_backup_test_state(&manager, globals); + + let previous = seed_previous_restore_activation(); + let first_wallet = xpub_only_wallet_metadata(); + let second_wallet = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![first_wallet, second_wallet]); + restore_activation::test_support::fail_next_configured_state_write(); + + let error = activate_new_restore_namespace(&manager).await.unwrap_err(); + + assert!( + error.to_string().contains("injected restored namespace configured-state failure"), + "{error}" + ); + assert_previous_restore_activation_preserved(globals, &manager, &previous); + assert!(dirty_restore_wallet_ids().is_empty()); + } + + #[tokio::test(flavor = "current_thread")] + async fn restore_namespace_commit_rolls_back_if_dirty_wallet_persist_fails_after_prefix() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + ensure_cloud_backup_test_tokio_runtime(); + let manager = RustCloudBackupManager::init(); + reset_cloud_backup_test_state(&manager, globals); + + let previous = seed_previous_restore_activation(); + let leftover = xpub_only_wallet_metadata(); + persist_dirty_blob_state(leftover.id.clone()); + let first_wallet = xpub_only_wallet_metadata(); + let second_wallet = xpub_only_wallet_metadata(); + persist_xpub_wallets(vec![first_wallet.clone(), second_wallet.clone()]); + restore_activation::test_support::fail_dirty_wallet_write_at(1); + + let error = activate_new_restore_namespace(&manager).await.unwrap_err(); + let dirty_wallet_ids = dirty_restore_wallet_ids(); + + assert!( + error.to_string().contains("injected restored namespace dirty-wallet failure"), + "{error}" + ); + assert_previous_restore_activation_preserved(globals, &manager, &previous); + assert_eq!(dirty_wallet_ids, vec![leftover.id.clone()]); + assert!(!dirty_wallet_ids.contains(&first_wallet.id)); + assert!(!dirty_wallet_ids.contains(&second_wallet.id)); + assert_eq!( + Database::global() + .cloud_blob_sync_states + .get(&cove_cspp::backup_data::wallet_record_id(leftover.id.as_ref())) + .unwrap() + .unwrap() + .namespace_id, + previous.namespace_id + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn restore_namespace_commit_preserves_persist_error_if_keychain_rollback_fails() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + ensure_cloud_backup_test_tokio_runtime(); + let manager = RustCloudBackupManager::init(); + reset_cloud_backup_test_state(&manager, globals); + + Database::global().cloud_backup_state.set(&PersistedCloudBackupState::default()).unwrap(); + restore_activation::test_support::fail_next_configured_state_write(); + globals.keychain.fail_delete_at(1); + + let error = activate_new_restore_namespace(&manager).await.unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("injected restored namespace configured-state failure"), + "{message}" + ); + assert!(message.contains("rollback failed"), "{message}"); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Disabled + ); + assert_eq!(manager.current_status(), CloudBackupStatus::Restoring); + assert!(matches!(manager.state().lifecycle, CloudBackupLifecycle::Restoring(_))); + assert!(dirty_restore_wallet_ids().is_empty()); + } + + struct PreviousRestoreActivation { + master_key: MasterKey, + namespace_id: String, + } + + fn seed_previous_restore_activation() -> PreviousRestoreActivation { + let master_key = MasterKey::generate(); + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + cspp.save_master_key(&master_key).unwrap(); + CloudBackupKeychain::global() + .save_passkey_and_namespace(&[9, 8, 7], [6; 32], "previous-namespace") + .unwrap(); + Database::global().cloud_backup_state.set(&PersistedCloudBackupState::default()).unwrap(); + + PreviousRestoreActivation { master_key, namespace_id: "previous-namespace".into() } + } + + fn assert_previous_restore_activation_preserved( + globals: &crate::manager::cloud_backup_manager::ops::test_support::TestGlobals, + manager: &RustCloudBackupManager, + previous: &PreviousRestoreActivation, + ) { + let cspp = cove_cspp::Cspp::new(Keychain::global().clone()); + assert_eq!( + cspp.load_master_key_from_store().unwrap().as_ref().map(MasterKey::as_bytes), + Some(previous.master_key.as_bytes()) + ); + assert_eq!( + globals.keychain.get_entry(CSPP_NAMESPACE_ID_KEY).as_deref(), + Some(previous.namespace_id.as_str()) + ); + assert_eq!(globals.keychain.get_entry(CSPP_CREDENTIAL_ID_KEY).as_deref(), Some("090807")); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Disabled + ); + assert_eq!(manager.current_status(), CloudBackupStatus::Restoring); + assert!(matches!(manager.state().lifecycle, CloudBackupLifecycle::Restoring(_))); + } + + async fn activate_new_restore_namespace( + manager: &RustCloudBackupManager, + ) -> Result<(), CloudBackupError> { + let master_key = MasterKey::generate(); + let active = RestorableNamespace { + namespace_id: master_key.namespace_id(), + master_key, + passkey: Some(RestorableNamespacePasskey { + credential_id: vec![1, 2, 3], + prf_salt: [4; 32], + }), + }; + let operation = new_restore_operation_for_test(manager).await; + operation.activate_restored_namespace(&active).await + } + + fn dirty_restore_wallet_ids() -> Vec { + let mut wallet_ids = Database::global() + .cloud_blob_sync_states + .list() + .unwrap() + .into_iter() + .filter_map(|state| match state.state { + PersistedCloudBlobState::Dirty(_) => state.wallet_id().cloned(), + _ => None, + }) + .collect::>(); + wallet_ids.sort(); + wallet_ids + } + + fn sorted_wallet_ids(wallet_ids: &[WalletId]) -> Vec { + let mut wallet_ids = wallet_ids.to_vec(); + wallet_ids.sort(); + wallet_ids } #[test] diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor.rs index 64ef60153..53c3b40f1 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor.rs @@ -18,7 +18,7 @@ use tracing::{error, info, warn}; use super::CloudBackupSyncHealthWorker; use super::cleanup::{CleanupSourceNamespace, CloudBackupCleanupJob, CloudBackupCleanupWorker}; -use super::restore::{self, CloudBackupRestoreEvent, RestoreOperation, RestoredPasskeyMaterial}; +use super::restore::{self, CloudBackupRestoreEvent, RestoreOperation, RestoredNamespaceCommit}; use super::uploads::CloudBackupUploadWorker; use super::write::{ CloudBackupUploadedWallet, CloudBackupUploadedWalletsStateMode, CloudBackupWriteBlocker, @@ -27,7 +27,8 @@ use super::write::{ use crate::database::Database; use crate::database::cloud_backup::{ CloudBackupRecordKey, CloudStorageIssue, DriveAccountSwitchId, PersistedCloudBackupState, - PersistedDisablingCloudBackup, PersistedDriveAccountSwitch, PersistedDriveAccountSwitchPhase, + PersistedCloudBlobSyncState, PersistedDisablingCloudBackup, PersistedDriveAccountSwitch, + PersistedDriveAccountSwitchPhase, }; use crate::manager::cloud_backup_manager::keychain::CloudBackupKeychain; use crate::manager::cloud_backup_manager::model::{ @@ -48,12 +49,14 @@ use crate::manager::cloud_backup_manager::{ BlockingCloudStep, CLOUD_BACKUP_DISABLE_ERROR_MESSAGE, CLOUD_BACKUP_LABELS_WARNING_MESSAGE, CloudBackupCloudOnlyFetchOutcome, CloudBackupCloudOnlyOperationWarning, CloudBackupCloudOnlyWalletOutcome, CloudBackupDetailInventorySnapshot, - CloudBackupDetailInventorySnapshotResult, CloudBackupDetailOutcome, CloudBackupDetailResult, - CloudBackupDisableOutcome, CloudBackupDisablePreparation, CloudBackupDriveAccountSwitchError, - CloudBackupEnableContext, CloudBackupEnablePasskeyPreparation, - CloudBackupEnablePasskeyRegistration, CloudBackupEnablePreparation, - CloudBackupEnableRecoveryCompletion, CloudBackupEnableRecoveryPreparation, - CloudBackupEnableState, CloudBackupError, CloudBackupInventoryIncompleteReason, + CloudBackupDetailInventorySnapshotResult, CloudBackupDetailOutcome, + CloudBackupDetailProviderConfirmation, CloudBackupDetailResult, + CloudBackupDetailSnapshotCompletion, CloudBackupDisableOutcome, CloudBackupDisablePreparation, + CloudBackupDriveAccountSwitchError, CloudBackupEnableContext, + CloudBackupEnablePasskeyPreparation, CloudBackupEnablePasskeyRegistration, + CloudBackupEnablePreparation, CloudBackupEnableRecoveryCompletion, + CloudBackupEnableRecoveryPreparation, CloudBackupEnableState, CloudBackupError, + CloudBackupInventoryAuthority, CloudBackupInventoryIncompleteReason, CloudBackupKeepEnabledPreparation, CloudBackupNoDiscoveryEnablePreparation, CloudBackupOtherBackupsOutcome, CloudBackupOtherBackupsState, CloudBackupPasskeyChoiceIntent, CloudBackupPendingEnableCleanupState, CloudBackupPendingEnableRecovery, @@ -198,6 +201,27 @@ fn apply_cloud_only_operation_refresh_detail_result( } } +fn apply_provider_confirmation_result( + manager: &RustCloudBackupManager, + result: &CloudBackupDetailResult, +) { + match result { + CloudBackupDetailResult::SuccessWithAuthority { detail, authority } => { + manager.apply_detail_outcome_preserving_cloud_only_if_consistent( + CloudBackupDetailOutcome::RefreshedWithAuthority { + detail: detail.clone(), + authority: *authority, + }, + ); + } + CloudBackupDetailResult::AccessError(error) => { + warn!( + "provider confirmation of trusted local inventory failed; keeping local snapshot detail: {error}" + ); + } + } +} + fn refresh_detail_needs_connectivity_retry( manager: &RustCloudBackupManager, attempt: DetailRefreshAttempt, @@ -233,8 +257,9 @@ struct PendingDisableWriteDrain { disabling: PersistedDisablingCloudBackup, } +/// A restore or Restore All run, whose worker keeps draining until it observes cancellation #[derive(Debug)] -struct RestoreAllRun { +struct RestoreRun { claim: CloudBackupExclusiveOperationClaim, cancellation: Arc, } @@ -242,7 +267,8 @@ struct RestoreAllRun { #[derive(Debug)] enum ActiveOperationRun { Standard(CloudBackupExclusiveOperationClaim), - RestoreAll(RestoreAllRun), + Restore(RestoreRun), + RestoreAll(RestoreRun), } #[derive(Debug, Default)] @@ -260,6 +286,7 @@ impl ActiveOperation { fn claim(&self) -> Option { match self.0.as_ref()? { ActiveOperationRun::Standard(claim) => Some(*claim), + ActiveOperationRun::Restore(run) => Some(run.claim), ActiveOperationRun::RestoreAll(run) => Some(run.claim), } } @@ -273,14 +300,44 @@ impl ActiveOperation { self.0 = Some(ActiveOperationRun::Standard(claim)); } - fn start_restore_all(&mut self, run: RestoreAllRun) { + fn start_restore(&mut self, run: RestoreRun) { + self.0 = Some(ActiveOperationRun::Restore(run)); + } + + fn start_restore_all(&mut self, run: RestoreRun) { self.0 = Some(ActiveOperationRun::RestoreAll(run)); } - fn restore_all(&self, claim: CloudBackupExclusiveOperationClaim) -> Option<&RestoreAllRun> { + fn restore(&self, claim: CloudBackupExclusiveOperationClaim) -> Option<&RestoreRun> { + match self.0.as_ref()? { + ActiveOperationRun::Restore(run) if run.claim == claim => Some(run), + ActiveOperationRun::Standard(_) + | ActiveOperationRun::Restore(_) + | ActiveOperationRun::RestoreAll(_) => None, + } + } + + fn restore_all(&self, claim: CloudBackupExclusiveOperationClaim) -> Option<&RestoreRun> { match self.0.as_ref()? { ActiveOperationRun::RestoreAll(run) if run.claim == claim => Some(run), - ActiveOperationRun::Standard(_) | ActiveOperationRun::RestoreAll(_) => None, + ActiveOperationRun::Standard(_) + | ActiveOperationRun::Restore(_) + | ActiveOperationRun::RestoreAll(_) => None, + } + } + + /// Cancels draining restore work but releases operations with no retained cancellation owner + fn prepare_local_reset(&mut self) -> Option { + match self.0.as_ref()? { + ActiveOperationRun::Restore(run) | ActiveOperationRun::RestoreAll(run) => { + run.cancellation.store(true, Ordering::Release); + None + } + ActiveOperationRun::Standard(claim) => { + let claim = *claim; + self.clear(); + Some(claim) + } } } @@ -330,6 +387,44 @@ impl Actor for CloudBackupSupervisor { } impl CloudBackupSupervisor { + fn commit_restored_namespace( + manager: &RustCloudBackupManager, + commit: RestoredNamespaceCommit, + ) -> Result<(), CloudBackupError> { + let RestoredNamespaceCommit { master_key, passkey, namespace_id, state, wallet_ids } = + commit; + let snapshot = CloudBackupKeychain::global().capture_restore_activation_snapshot(); + let changed_at = cove_util::time::unix_timestamp_secs_or_zero(); + let dirty_states = wallet_ids + .into_iter() + .map(|wallet_id| { + PersistedCloudBlobSyncState::dirty_wallet( + namespace_id.clone(), + wallet_id, + changed_at, + ) + }) + .collect::>(); + + restore::save_restore_keychain_entries(&snapshot, master_key, passkey, namespace_id)?; + + // keychain, configured state, and dirty-wallet rows are one restore commit + if let Err(source) = Database::global() + .cloud_backup_state + .persist_restored_namespace_activation(&state, &dirty_states) + { + let original = + CloudBackupError::internal_context("persist restored namespace activation", source); + + return Err(restore::rollback_restore_activation_keychain(&snapshot, original)); + } + + manager.reconcile_persisted_state(&state); + manager.refresh_sync_health(); + + Ok(()) + } + pub(crate) fn new( manager: Weak, cloud_writes: Addr, @@ -438,7 +533,14 @@ impl CloudBackupSupervisor { let operation_id = NEXT_SUPERVISOR_OPERATION_ID.fetch_add(1, Ordering::Relaxed); let claim = CloudBackupExclusiveOperationClaim::new(operation, operation_id); manager.project_exclusive_operation_started(claim); - self.active_operation.start_standard(claim); + if operation == CloudBackupExclusiveOperation::Restore { + self.active_operation.start_restore(RestoreRun { + claim, + cancellation: Arc::new(AtomicBool::new(false)), + }); + } else { + self.active_operation.start_standard(claim); + } Some(claim) } @@ -455,7 +557,7 @@ impl CloudBackupSupervisor { operation_id, ); let cancellation = Arc::new(AtomicBool::new(false)); - self.active_operation.start_restore_all(RestoreAllRun { claim, cancellation }); + self.active_operation.start_restore_all(RestoreRun { claim, cancellation }); Some(claim) } @@ -506,8 +608,17 @@ impl CloudBackupSupervisor { } fn restore_operation_is_current(&self, claim: CloudBackupExclusiveOperationClaim) -> bool { - self.active_operation.is_current(claim) - && claim.operation() == CloudBackupExclusiveOperation::Restore + let Some(run) = self.active_operation.restore(claim) else { return false }; + + !run.cancellation.load(Ordering::Acquire) + } + + /// The cancellation flag of a restore claim that was just started + fn restore_cancellation(&self, claim: CloudBackupExclusiveOperationClaim) -> Arc { + self.active_operation + .restore(claim) + .map(|run| run.cancellation.clone()) + .expect("restore claim must carry a cancellation state") } pub async fn ensure_restore_current( @@ -581,31 +692,24 @@ impl CloudBackupSupervisor { return Produces::ok(Err(CloudBackupError::Cancelled)); }; - let result = Database::global() - .cloud_backup_state - .set(&state) - .map_err(|error| CloudBackupError::Internal(format!("{context}: {error}").into())); - if result.is_ok() { - manager.reconcile_runtime_status(RustCloudBackupManager::runtime_status_for(&state)); - manager.refresh_persisted_flags(); - } + let result = manager.persist_cloud_backup_state(&state, context); Produces::ok(result) } - pub async fn save_restore_keychain_state( + pub async fn commit_restore_namespace_activation( &mut self, claim: CloudBackupExclusiveOperationClaim, - master_key: cove_cspp::master_key::MasterKey, - passkey: Option, - namespace_id: String, + commit: RestoredNamespaceCommit, ) -> ActorResult> { if !self.restore_operation_is_current(claim) { return Produces::ok(Err(CloudBackupError::Cancelled)); } + let Some(manager) = self.manager() else { + return Produces::ok(Err(CloudBackupError::Cancelled)); + }; - let result = restore::save_restore_keychain_entries(master_key, passkey, namespace_id); - Produces::ok(result) + Produces::ok(Self::commit_restored_namespace(&manager, commit)) } pub async fn start_enable_operation( @@ -785,7 +889,7 @@ impl CloudBackupSupervisor { } self.ensure_supplemental_inventory_discovery(manager.clone()); - if manager.detail_inventory_is_ready() { + if manager.detail_inventory_is_complete() { return Produces::ok(()); } @@ -836,8 +940,22 @@ impl CloudBackupSupervisor { claim: DetailRefreshClaim, ) { self.addr.send_fut_with(move |addr| async move { - let result = manager.complete_cloud_backup_detail_inventory_snapshot(snapshot).await; - send!(addr.complete_refresh_detail(result, attempt, claim)); + let completion = + manager.complete_cloud_backup_detail_inventory_snapshot(snapshot).await; + send!(addr.complete_refresh_detail_from_snapshot(completion, attempt, claim)); + }); + } + + fn schedule_confirm_refresh_detail( + &self, + manager: Arc, + confirmation: CloudBackupDetailProviderConfirmation, + attempt: DetailRefreshAttempt, + claim: DetailRefreshClaim, + ) { + self.addr.send_fut_with(move |addr| async move { + let result = manager.confirm_cloud_backup_detail_inventory(confirmation.clone()).await; + send!(addr.complete_confirm_refresh_detail(confirmation, result, attempt, claim)); }); } @@ -905,6 +1023,78 @@ impl CloudBackupSupervisor { Produces::ok(()) } + pub async fn complete_refresh_detail_from_snapshot( + &mut self, + completion: Option, + attempt: DetailRefreshAttempt, + claim: DetailRefreshClaim, + ) -> ActorResult<()> { + let Some(manager) = self.manager() else { return Produces::ok(()) }; + + if !self.detail_workflow.is_refresh_active(claim) { + return Produces::ok(()); + } + + match completion { + None => self.complete_refresh_detail(None, attempt, claim).await, + Some(CloudBackupDetailSnapshotCompletion::Final(result)) => { + self.complete_refresh_detail(Some(result), attempt, claim).await + } + Some(CloudBackupDetailSnapshotCompletion::TrustedLocal { detail, confirmation }) => { + if self.detail_workflow.is_latest_refresh(claim) { + manager.apply_detail_outcome( + CloudBackupDetailOutcome::RefreshedWithAuthority { + detail, + authority: + CloudBackupInventoryAuthority::LocalSnapshotMatchesKnownCount, + }, + ); + } + + self.ensure_supplemental_inventory_discovery(manager.clone()); + self.schedule_confirm_refresh_detail(manager, confirmation, attempt, claim); + + Produces::ok(()) + } + } + } + + pub async fn complete_confirm_refresh_detail( + &mut self, + confirmation: CloudBackupDetailProviderConfirmation, + result: Option, + attempt: DetailRefreshAttempt, + claim: DetailRefreshClaim, + ) -> ActorResult<()> { + let Some(manager) = self.manager() else { return Produces::ok(()) }; + + if !self.detail_workflow.is_refresh_active(claim) { + return Produces::ok(()); + } + + if refresh_detail_needs_connectivity_retry(&manager, attempt, &result) { + self.schedule_confirm_refresh_detail( + manager, + confirmation, + DetailRefreshAttempt::AutomaticConnectivityRetry, + claim, + ); + return Produces::ok(()); + } + + let completion = self.detail_workflow.complete_refresh(claim); + if completion.apply + && let Some(result) = result + { + apply_provider_confirmation_result(&manager, &result); + } + + self.ensure_supplemental_inventory_discovery(manager.clone()); + self.handle_detail_refresh_plan(manager, completion.next); + + Produces::ok(()) + } + pub async fn complete_refresh_detail_snapshot( &mut self, result: Option, @@ -1138,7 +1328,8 @@ impl CloudBackupSupervisor { return Produces::ok(()); }; - let operation = RestoreOperation::new(claim, addr.clone()); + let cancellation = self.restore_cancellation(claim); + let operation = RestoreOperation::new(claim, addr.clone(), cancellation); addr.send_fut_with(move |addr| async move { tracing::info!("restore_from_cloud_backup: task started"); match operation.restore_from_cloud_backup(&manager).await { @@ -1190,7 +1381,9 @@ impl CloudBackupSupervisor { return Produces::ok(()); }; - let operation = RestoreOperation::new_with_events(claim, addr.clone(), sender); + let cancellation = self.restore_cancellation(claim); + let operation = + RestoreOperation::new_with_events(claim, addr.clone(), sender, cancellation); addr.send_fut_with(move |addr| async move { tracing::info!("restore_from_cloud_backup: task started for onboarding"); match operation.restore_from_cloud_backup(&manager).await { @@ -1231,27 +1424,22 @@ impl CloudBackupSupervisor { } pub async fn cancel_restore(&mut self) -> ActorResult<()> { - let Some(manager) = self.manager() else { return Produces::ok(()) }; - let Some(claim) = self.active_operation.claim() else { + let Some(ActiveOperationRun::Restore(run)) = self.active_operation.0.as_ref() else { return Produces::ok(()); }; - if claim.operation() != CloudBackupExclusiveOperation::Restore { - return Produces::ok(()); - } - let status = manager.state.read().status(); - if !matches!(status, CloudBackupStatus::Restoring) { + if run.cancellation.swap(true, Ordering::AcqRel) { return Produces::ok(()); } - self.active_operation.clear(); - manager.project_exclusive_operation_finished(claim); + let Some(manager) = self.manager() else { return Produces::ok(()) }; + manager.clear_enable_progress_report(); manager.apply_restore_outcome(CloudBackupRestoreOutcome::ProgressCleared); manager.reconcile_runtime_status(RustCloudBackupManager::runtime_status_for( &RustCloudBackupManager::load_persisted_state(), )); - tracing::info!("restore_from_cloud_backup: cancelled active restore"); + tracing::info!("restore_from_cloud_backup: cancellation requested for active restore"); Produces::ok(()) } @@ -1264,18 +1452,11 @@ impl CloudBackupSupervisor { } pub async fn prepare_local_reset_runtime(&mut self) -> ActorResult<()> { - if let Some(run) = self.active_operation.0.as_ref() - && let ActiveOperationRun::RestoreAll(run) = run - { - run.cancellation.store(true, Ordering::Release); - } - - if let Some(claim) = self.active_operation.claim() + if let Some(released_claim) = self.active_operation.prepare_local_reset() && let Some(manager) = self.manager() { - manager.project_exclusive_operation_finished(claim); + manager.project_exclusive_operation_finished(released_claim); } - self.active_operation.clear(); self.active_sync_request = None; self.active_cloud_only_fetch_request = None; self.pending_disable_write_drain = None; @@ -1306,7 +1487,8 @@ pub(crate) mod test_support { let claim = self .begin_exclusive_operation(&manager, CloudBackupExclusiveOperation::Restore) .expect("begin restore operation"); - let operation = RestoreOperation::new(claim, addr); + let cancellation = self.restore_cancellation(claim); + let operation = RestoreOperation::new(claim, addr, cancellation); Produces::ok(operation) } diff --git a/rust/src/manager/cloud_backup_manager/actors/supervisor/tests.rs b/rust/src/manager/cloud_backup_manager/actors/supervisor/tests.rs index 396731a26..a7a7bd6e1 100644 --- a/rust/src/manager/cloud_backup_manager/actors/supervisor/tests.rs +++ b/rust/src/manager/cloud_backup_manager/actors/supervisor/tests.rs @@ -101,6 +101,28 @@ fn test_cloud_only_wallet(record_id: &str) -> CloudBackupWalletItem { } } +fn test_cloud_backup_detail(cloud_only_count: u32) -> CloudBackupDetail { + CloudBackupDetail { + last_sync: None, + up_to_date: Vec::new(), + needs_sync: Vec::new(), + cloud_only_count, + } +} + +fn begin_test_detail_refresh( + supervisor: &mut CloudBackupSupervisor, + manager: &RustCloudBackupManager, +) -> DetailRefreshClaim { + supervisor.detail_workflow.open(); + let DetailRefreshPlan::Start(claim) = supervisor.detail_workflow.request_refresh() else { + panic!("expected detail refresh to start"); + }; + manager.apply_detail_outcome(CloudBackupDetailOutcome::Checking); + + claim +} + fn prepare_restore_all_queue_fixture( manager: &RustCloudBackupManager, wallets: Vec<(WalletMetadata, cove_cspp::backup_data::WalletEntry)>, @@ -388,6 +410,26 @@ async fn supervisor_rejects_second_exclusive_operation_while_active() { assert_eq!(manager.projected_exclusive_operation(), None); } +#[test] +fn local_reset_cancels_restore_without_releasing_its_claim() { + let claim = CloudBackupExclusiveOperationClaim::new( + CloudBackupExclusiveOperation::Restore, + u64::MAX, + ); + let cancellation = Arc::new(AtomicBool::new(false)); + let mut active = ActiveOperation::default(); + active.start_restore(RestoreRun { + claim, + cancellation: Arc::clone(&cancellation), + }); + + let released_claim = active.prepare_local_reset(); + + assert!(released_claim.is_none()); + assert!(cancellation.load(Ordering::Acquire)); + assert_eq!(active, Some(claim)); +} + #[tokio::test(flavor = "current_thread")] async fn drive_account_switch_reports_busy_and_cancellation_releases_claim() { let _guard = async_test_lock().lock().await; @@ -1333,6 +1375,272 @@ async fn repair_passkey_refresh_failure_resolves_superseded_detail_refresh() { assert!(matches!(configured.detail, CloudBackupDetailState::Failed { .. })); } +#[tokio::test(flavor = "current_thread")] +async fn trusted_local_detail_stays_active_until_provider_confirmation() { + let _guard = async_test_lock().lock().await; + let manager = test_supervisor_manager(); + configure_enabled_cloud_backup(&manager, test_globals(), 2); + let mut supervisor = CloudBackupSupervisor::new( + Arc::downgrade(&manager), + spawn_actor(CloudBackupWriteSupervisor::new(Weak::new())), + ); + let claim = begin_test_detail_refresh(&mut supervisor, &manager); + let confirmation = CloudBackupDetailProviderConfirmation { + namespace: manager.current_namespace_id().unwrap(), + }; + let detail = test_cloud_backup_detail(2); + + supervisor + .complete_refresh_detail_from_snapshot( + Some(CloudBackupDetailSnapshotCompletion::TrustedLocal { + detail: detail.clone(), + confirmation: confirmation.clone(), + }), + DetailRefreshAttempt::Initial, + claim, + ) + .await + .unwrap(); + + assert!(supervisor.detail_workflow.is_refresh_active(claim)); + + let wallets = vec![test_cloud_only_wallet("first"), test_cloud_only_wallet("second")]; + manager.apply_cloud_only_fetch_outcome(CloudBackupCloudOnlyFetchOutcome::Loaded( + wallets.clone(), + )); + assert_eq!( + manager.projected_restore_all_state(), + CloudBackupRestoreAllState::StartDisabled { wallet_count: 2 }, + ); + + supervisor + .complete_confirm_refresh_detail( + confirmation, + Some(CloudBackupDetailResult::SuccessWithAuthority { + detail, + authority: CloudBackupInventoryAuthority::ProviderConfirmed, + }), + DetailRefreshAttempt::Initial, + claim, + ) + .await + .unwrap(); + + assert!(!supervisor.detail_workflow.is_refresh_active(claim)); + assert_eq!( + manager.projected_restore_all_state(), + CloudBackupRestoreAllState::StartAvailable { wallet_count: 2 }, + ); + + let CloudBackupLifecycle::Configured(configured) = manager.state().lifecycle else { + panic!("expected configured cloud backup"); + }; + let CloudBackupDetailState::Complete { state } = configured.detail else { + panic!("expected complete cloud backup detail"); + }; + + assert_eq!(state.inventory_authority, CloudBackupInventoryAuthority::ProviderConfirmed); + assert_eq!(state.cloud_only, CloudOnlyState::Loaded { wallets }); +} + +#[tokio::test(flavor = "current_thread")] +async fn provider_confirmation_failure_keeps_trusted_local_detail() { + let _guard = async_test_lock().lock().await; + let manager = test_supervisor_manager(); + configure_enabled_cloud_backup(&manager, test_globals(), 2); + let mut supervisor = CloudBackupSupervisor::new( + Arc::downgrade(&manager), + spawn_actor(CloudBackupWriteSupervisor::new(Weak::new())), + ); + let claim = begin_test_detail_refresh(&mut supervisor, &manager); + let confirmation = CloudBackupDetailProviderConfirmation { + namespace: manager.current_namespace_id().unwrap(), + }; + + supervisor + .complete_refresh_detail_from_snapshot( + Some(CloudBackupDetailSnapshotCompletion::TrustedLocal { + detail: test_cloud_backup_detail(2), + confirmation: confirmation.clone(), + }), + DetailRefreshAttempt::Initial, + claim, + ) + .await + .unwrap(); + + supervisor + .complete_confirm_refresh_detail( + confirmation, + Some(CloudBackupDetailResult::AccessError(CloudBackupError::Internal( + "metadata timed out".into(), + ))), + DetailRefreshAttempt::Initial, + claim, + ) + .await + .unwrap(); + + assert!(!supervisor.detail_workflow.is_refresh_active(claim)); + assert!(matches!( + manager.state().lifecycle, + CloudBackupLifecycle::Configured(ref configured) + if matches!( + configured.detail, + CloudBackupDetailState::Complete { ref state } + if state.inventory_authority + == CloudBackupInventoryAuthority::LocalSnapshotMatchesKnownCount + ) + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn provider_confirmation_connectivity_failure_retries_once() { + let _guard = async_test_lock().lock().await; + let manager = test_supervisor_manager(); + configure_enabled_cloud_backup(&manager, test_globals(), 2); + let mut supervisor = CloudBackupSupervisor::new( + Arc::downgrade(&manager), + spawn_actor(CloudBackupWriteSupervisor::new(Weak::new())), + ); + let claim = begin_test_detail_refresh(&mut supervisor, &manager); + let confirmation = CloudBackupDetailProviderConfirmation { + namespace: manager.current_namespace_id().unwrap(), + }; + + supervisor + .complete_refresh_detail_from_snapshot( + Some(CloudBackupDetailSnapshotCompletion::TrustedLocal { + detail: test_cloud_backup_detail(2), + confirmation: confirmation.clone(), + }), + DetailRefreshAttempt::Initial, + claim, + ) + .await + .unwrap(); + + supervisor + .complete_confirm_refresh_detail( + confirmation.clone(), + Some(CloudBackupDetailResult::AccessError(CloudBackupError::Offline( + "offline".into(), + ))), + DetailRefreshAttempt::Initial, + claim, + ) + .await + .unwrap(); + + assert!(supervisor.detail_workflow.is_refresh_active(claim)); + + supervisor + .complete_confirm_refresh_detail( + confirmation, + Some(CloudBackupDetailResult::AccessError(CloudBackupError::Offline( + "still offline".into(), + ))), + DetailRefreshAttempt::AutomaticConnectivityRetry, + claim, + ) + .await + .unwrap(); + + assert!(!supervisor.detail_workflow.is_refresh_active(claim)); +} + +#[tokio::test(flavor = "current_thread")] +async fn trailing_detail_refresh_is_scheduled_after_provider_confirmation() { + let _guard = async_test_lock().lock().await; + let manager = test_supervisor_manager(); + configure_enabled_cloud_backup(&manager, test_globals(), 2); + let mut supervisor = CloudBackupSupervisor::new( + Arc::downgrade(&manager), + spawn_actor(CloudBackupWriteSupervisor::new(Weak::new())), + ); + let claim = begin_test_detail_refresh(&mut supervisor, &manager); + let confirmation = CloudBackupDetailProviderConfirmation { + namespace: manager.current_namespace_id().unwrap(), + }; + + supervisor + .complete_refresh_detail_from_snapshot( + Some(CloudBackupDetailSnapshotCompletion::TrustedLocal { + detail: test_cloud_backup_detail(2), + confirmation: confirmation.clone(), + }), + DetailRefreshAttempt::Initial, + claim, + ) + .await + .unwrap(); + + assert_eq!(supervisor.detail_workflow.request_refresh(), DetailRefreshPlan::Queued); + + supervisor + .complete_confirm_refresh_detail( + confirmation, + Some(CloudBackupDetailResult::SuccessWithAuthority { + detail: test_cloud_backup_detail(2), + authority: CloudBackupInventoryAuthority::ProviderConfirmed, + }), + DetailRefreshAttempt::Initial, + claim, + ) + .await + .unwrap(); + + assert!(!supervisor.detail_workflow.is_refresh_active(claim)); + assert_eq!(supervisor.detail_workflow.request_refresh(), DetailRefreshPlan::Queued); +} + +#[tokio::test(flavor = "current_thread")] +async fn stale_provider_confirmation_does_not_overwrite_newer_operation_result() { + let _guard = async_test_lock().lock().await; + let manager = test_supervisor_manager(); + configure_enabled_cloud_backup(&manager, test_globals(), 2); + let mut supervisor = CloudBackupSupervisor::new( + Arc::downgrade(&manager), + spawn_actor(CloudBackupWriteSupervisor::new(Weak::new())), + ); + let claim = begin_test_detail_refresh(&mut supervisor, &manager); + let confirmation = CloudBackupDetailProviderConfirmation { + namespace: manager.current_namespace_id().unwrap(), + }; + + supervisor + .complete_refresh_detail_from_snapshot( + Some(CloudBackupDetailSnapshotCompletion::TrustedLocal { + detail: test_cloud_backup_detail(2), + confirmation: confirmation.clone(), + }), + DetailRefreshAttempt::Initial, + claim, + ) + .await + .unwrap(); + + supervisor.detail_workflow.start_operation_result(); + let newer_detail = test_cloud_backup_detail(1); + manager.apply_detail_outcome(CloudBackupDetailOutcome::Refreshed(newer_detail.clone())); + + supervisor + .complete_confirm_refresh_detail( + confirmation, + Some(CloudBackupDetailResult::SuccessWithAuthority { + detail: test_cloud_backup_detail(3), + authority: CloudBackupInventoryAuthority::ProviderConfirmed, + }), + DetailRefreshAttempt::Initial, + claim, + ) + .await + .unwrap(); + + assert!(!supervisor.detail_workflow.is_refresh_active(claim)); + assert_eq!(manager.model_snapshot().detail, Some(newer_detail)); +} + #[tokio::test(flavor = "current_thread")] @@ -2557,7 +2865,7 @@ async fn overlapping_cloud_only_refetch_preserves_active_restore_all_marker_and_ CloudBackupExclusiveOperation::RestoreAllCloudWallets, ) .unwrap(); - supervisor.active_operation.start_restore_all(RestoreAllRun { + supervisor.active_operation.start_restore_all(RestoreRun { claim, cancellation: Arc::new(AtomicBool::new(false)), }); @@ -2630,7 +2938,7 @@ async fn restore_all_queue_completion_without_remaining_wallets_clears_marker_an CloudBackupExclusiveOperation::RestoreAllCloudWallets, ) .unwrap(); - supervisor.active_operation.start_restore_all(RestoreAllRun { + supervisor.active_operation.start_restore_all(RestoreRun { claim, cancellation: Arc::new(AtomicBool::new(false)), }); @@ -2656,7 +2964,7 @@ async fn restore_all_cancellation_keeps_claim_until_record_boundary() { ) .unwrap(); let cancellation = Arc::new(AtomicBool::new(false)); - supervisor.active_operation.start_restore_all(RestoreAllRun { + supervisor.active_operation.start_restore_all(RestoreRun { claim, cancellation: cancellation.clone(), }); @@ -2683,7 +2991,7 @@ async fn supplemental_inventory_does_not_cancel_restore_all() { ) .unwrap(); let cancellation = Arc::new(AtomicBool::new(false)); - supervisor.active_operation.start_restore_all(RestoreAllRun { + supervisor.active_operation.start_restore_all(RestoreRun { claim, cancellation: cancellation.clone(), }); @@ -2913,7 +3221,7 @@ async fn restore_all_cancellation_during_preparation_clears_marker_after_complet ) .unwrap(); let cancellation = Arc::new(AtomicBool::new(true)); - supervisor.active_operation.start_restore_all(RestoreAllRun { + supervisor.active_operation.start_restore_all(RestoreRun { claim, cancellation: cancellation.clone(), }); @@ -2976,7 +3284,7 @@ async fn restore_all_provider_failure_during_success_refresh_stops_with_marker_r CloudBackupExclusiveOperation::RestoreAllCloudWallets, ) .unwrap(); - supervisor.active_operation.start_restore_all(RestoreAllRun { + supervisor.active_operation.start_restore_all(RestoreRun { claim, cancellation: Arc::new(AtomicBool::new(false)), }); @@ -3023,7 +3331,7 @@ async fn ordinary_restore_all_record_failure_keeps_batch_claim_for_next_record() CloudBackupExclusiveOperation::RestoreAllCloudWallets, ) .unwrap(); - supervisor.active_operation.start_restore_all(RestoreAllRun { + supervisor.active_operation.start_restore_all(RestoreRun { claim, cancellation: Arc::new(AtomicBool::new(false)), }); diff --git a/rust/src/manager/cloud_backup_manager/catastrophic_recovery.rs b/rust/src/manager/cloud_backup_manager/catastrophic_recovery.rs index c6619ca74..3c44f749c 100644 --- a/rust/src/manager/cloud_backup_manager/catastrophic_recovery.rs +++ b/rust/src/manager/cloud_backup_manager/catastrophic_recovery.rs @@ -141,6 +141,9 @@ fn wipe_local_data_for_catastrophic_recovery() -> Result<(), CatastrophicRecover let cleanup = crate::wallet::deletion::RecoveryCleanup::prepare_database_unavailable() .map_err_str(CatastrophicRecoveryError::Failure)?; cleanup.delete_all_wallet_items().map_err_str(CatastrophicRecoveryError::Failure)?; + cleanup + .delete_key_teleport_receive_session() + .map_err_str(CatastrophicRecoveryError::Failure)?; CloudBackupKeychain::global() .clear_local_state() .map_err_str(CatastrophicRecoveryError::Failure)?; diff --git a/rust/src/manager/cloud_backup_manager/detail.rs b/rust/src/manager/cloud_backup_manager/detail.rs index 793393e82..db039fb23 100644 --- a/rust/src/manager/cloud_backup_manager/detail.rs +++ b/rust/src/manager/cloud_backup_manager/detail.rs @@ -279,7 +279,7 @@ impl RustCloudBackupManager { } impl RustCloudBackupManager { - fn detail_inventory_is_complete(&self) -> bool { + pub(crate) fn detail_inventory_is_complete(&self) -> bool { self.state.read().detail_inventory_is_complete() } diff --git a/rust/src/manager/cloud_backup_manager/dto.rs b/rust/src/manager/cloud_backup_manager/dto.rs index 84b4d7a31..3da5ce2c5 100644 --- a/rust/src/manager/cloud_backup_manager/dto.rs +++ b/rust/src/manager/cloud_backup_manager/dto.rs @@ -222,6 +222,24 @@ impl CloudBackupPasskeyHint { registered_at: hint.registered_at, } } + + /// The passkey hint a master key wrapper advertises + /// + /// Fails when the wrapper metadata does not belong to `namespace_id`, because + /// a hint copied from another namespace would point the user at the wrong passkey + pub(crate) fn from_master_key_wrapper( + encrypted: &cove_cspp::backup_data::EncryptedMasterKeyBackup, + namespace_id: &str, + ) -> Result, cove_cspp::backup_data::remote_payload::RemotePayloadError> { + encrypted.remote_metadata.normalized_master_key(namespace_id)?; + + Ok(encrypted.passkey_provider_hint.as_ref().map(Self::from_provider_hint)) + } + + /// Whether this hint was registered after `other`, so it describes the newer passkey + pub(crate) fn is_newer_than(&self, other: &Self) -> bool { + self.registered_at > other.registered_at + } } /// Operation state for recovering or deleting other backup namespaces diff --git a/rust/src/manager/cloud_backup_manager/error.rs b/rust/src/manager/cloud_backup_manager/error.rs index 4d9a16f88..1f079d221 100644 --- a/rust/src/manager/cloud_backup_manager/error.rs +++ b/rust/src/manager/cloud_backup_manager/error.rs @@ -3,6 +3,7 @@ use std::{error::Error as StdError, fmt, ops::Deref}; use cove_device::passkey::{PasskeyFailureReason, PasskeyOperation}; use cove_device::{cloud_storage::CloudStorageError, passkey::PasskeyError}; +use crate::backup::import::LocalWalletConflict; use crate::database::cloud_backup::CloudStorageIssue; const PASSKEY_ACCESS_RECOVERY_MESSAGE: &str = "Cove couldn't access your passkey. Check your connection and passkey account, then try again. If this keeps happening, choose another passkey provider or contact support."; @@ -39,6 +40,15 @@ pub(crate) const CLOUD_BACKUP_COMPATIBILITY_MESSAGE: &str = "This cloud backup was created by an unsupported version of Cove."; const CLOUD_BACKUP_WALLET_SUPPORT_MESSAGE: &str = "This cloud backup contains a wallet this version of Cove can't restore."; +const PASSKEY_REQUEST_INCOMPLETE_MESSAGE: &str = "The passkey request did not complete. Try again."; +const LOCAL_WALLET_MISMATCH_MESSAGE: &str = concat!( + "Cove unlocked your backup, but some saved wallet data on this device does not match it. ", + "Cove kept that data unchanged." +); +const LOCAL_WALLET_UNREADABLE_MESSAGE: &str = concat!( + "Cove unlocked your backup, but some saved wallet data on this device could not be read. ", + "Cove kept that data unchanged." +); const ANDROID_PASSKEY_ASSOCIATION_MESSAGE: &str = concat!( "Cove could not verify Android passkey setup yet. Wait a few minutes and try again. ", "If this keeps happening, update Cove or contact support." @@ -263,6 +273,7 @@ impl From<&CloudBackupError> for CloudStorageIssue { | CloudBackupError::PasskeyMismatch | CloudBackupError::NoBackupFound | CloudBackupError::PasskeyDiscoveryCancelled + | CloudBackupError::LocalWalletConflict(_) | CloudBackupError::Cancelled => Self::Other, } } @@ -397,6 +408,10 @@ pub(crate) enum CloudBackupError { #[error("restore cancelled")] Cancelled, + + /// A restore kept local wallet data unchanged because it did not match the backup + #[error("local wallet conflict: {0}")] + LocalWalletConflict(#[source] LocalWalletConflict), } impl CloudBackupError { @@ -450,7 +465,7 @@ impl CloudBackupError { operation: PasskeyOperation::Registration, reason: PasskeyFailureReason::DeviceNotConfigured, }) => ANDROID_PASSKEY_ASSOCIATION_MESSAGE.into(), - Some(PasskeyError::UserCancelled) => Self::PasskeyDiscoveryCancelled.to_string(), + Some(PasskeyError::UserCancelled) => PASSKEY_REQUEST_INCOMPLETE_MESSAGE.into(), Some(PasskeyError::NoCredentialFound) => PASSKEY_NOT_FOUND_MESSAGE.into(), Some(PasskeyError::PrfUnsupportedProvider) => { UNSUPPORTED_PASSKEY_PROVIDER_MESSAGE.into() @@ -468,10 +483,16 @@ impl CloudBackupError { Self::Cloud(_) | Self::Deferred(_) | Self::Internal(_) => { GENERIC_CLOUD_BACKUP_ERROR_MESSAGE.into() } - Self::PasskeyMismatch - | Self::NoBackupFound - | Self::PasskeyDiscoveryCancelled - | Self::Cancelled => self.to_string(), + Self::LocalWalletConflict(LocalWalletConflict::Mismatch) => { + LOCAL_WALLET_MISMATCH_MESSAGE.into() + } + Self::LocalWalletConflict(LocalWalletConflict::Unreadable) => { + LOCAL_WALLET_UNREADABLE_MESSAGE.into() + } + // a bare platform cancellation does not prove the reader cancelled, so the + // copy states the outcome instead of claiming intent + Self::PasskeyDiscoveryCancelled => PASSKEY_REQUEST_INCOMPLETE_MESSAGE.into(), + Self::PasskeyMismatch | Self::NoBackupFound | Self::Cancelled => self.to_string(), } } } @@ -598,7 +619,11 @@ mod tests { let unsupported = CloudBackupError::from(PasskeyError::PrfUnsupportedProvider).reader_message(); - assert_eq!(cancellation, CloudBackupError::PasskeyDiscoveryCancelled.to_string()); + assert_eq!(cancellation, PASSKEY_REQUEST_INCOMPLETE_MESSAGE); + assert_eq!( + CloudBackupError::PasskeyDiscoveryCancelled.reader_message(), + PASSKEY_REQUEST_INCOMPLETE_MESSAGE + ); assert_eq!(missing, PASSKEY_NOT_FOUND_MESSAGE); assert_eq!(unsupported, UNSUPPORTED_PASSKEY_PROVIDER_MESSAGE); assert_ne!(cancellation, missing); diff --git a/rust/src/manager/cloud_backup_manager/keychain.rs b/rust/src/manager/cloud_backup_manager/keychain.rs index a71a2bdc9..d2713125c 100644 --- a/rust/src/manager/cloud_backup_manager/keychain.rs +++ b/rust/src/manager/cloud_backup_manager/keychain.rs @@ -1,4 +1,4 @@ -use cove_cspp::CsppStore as _; +use cove_cspp::{ActiveMasterKeySnapshot, Cspp, CsppStore as _}; use cove_device::keychain::{Keychain, KeychainError}; use tracing::warn; @@ -11,6 +11,23 @@ pub(crate) const CSPP_PRF_SALT_KEY: &str = "cspp::v1::prf_salt"; pub(crate) const CSPP_NAMESPACE_ID_KEY: &str = "cspp::v1::namespace_id"; pub(crate) const CSPP_PENDING_ENABLE_JOURNAL_KEY: &str = "cspp::v1::pending_enable_journal"; +#[derive(Clone)] +pub(crate) struct RestoreActivationKeychainSnapshot { + metadata: PendingEnableLocalMetadataSnapshot, + master_key: ActiveMasterKeySnapshot, +} + +impl PendingEnableLocalMetadataSnapshot { + /// The keychain entries this snapshot restores, in save order + fn keychain_entries(&self) -> [(String, Option); 3] { + [ + (CSPP_CREDENTIAL_ID_KEY.into(), self.credential_id.clone()), + (CSPP_PRF_SALT_KEY.into(), self.prf_salt.clone()), + (CSPP_NAMESPACE_ID_KEY.into(), self.namespace_id.clone()), + ] + } +} + #[derive(Debug, Clone)] pub(crate) struct CloudBackupKeychain(Keychain); @@ -32,6 +49,20 @@ pub(crate) enum CloudBackupKeychainError { #[error("unsupported pending Cloud Backup enable state version {0}")] UnsupportedPendingEnableVersion(u8), + + #[error("failed to restore Cloud Backup metadata: {0}")] + RestoreActivationMetadata(KeychainError), + + #[error("failed to restore the CSPP master key: {0}")] + RestoreActivationMasterKey(cove_cspp::CsppError), + + #[error( + "failed to restore Cloud Backup metadata ({metadata}) and the CSPP master key ({master_key})" + )] + RestoreActivationMetadataAndMasterKey { + metadata: KeychainError, + master_key: cove_cspp::CsppError, + }, } impl CloudBackupKeychain { @@ -83,6 +114,38 @@ impl CloudBackupKeychain { ]) } + pub(crate) fn capture_restore_activation_snapshot(&self) -> RestoreActivationKeychainSnapshot { + RestoreActivationKeychainSnapshot { + metadata: self.snapshot_passkey_metadata(), + master_key: Cspp::new(self.0.clone()).capture_active_master_key_snapshot(), + } + } + + pub(crate) fn restore_activation_snapshot( + &self, + snapshot: &RestoreActivationKeychainSnapshot, + ) -> Result<(), CloudBackupKeychainError> { + let metadata = self.restore_all_entries(&snapshot.metadata.keychain_entries()); + let master_key = + Cspp::new(self.0.clone()).restore_active_master_key_snapshot(&snapshot.master_key); + + match (metadata, master_key) { + (Ok(()), Ok(())) => Ok(()), + (Err(metadata), Ok(())) => { + Err(CloudBackupKeychainError::RestoreActivationMetadata(metadata)) + } + (Ok(()), Err(master_key)) => { + Err(CloudBackupKeychainError::RestoreActivationMasterKey(master_key)) + } + (Err(metadata), Err(master_key)) => { + Err(CloudBackupKeychainError::RestoreActivationMetadataAndMasterKey { + metadata, + master_key, + }) + } + } + } + pub(crate) fn snapshot_passkey_metadata(&self) -> PendingEnableLocalMetadataSnapshot { PendingEnableLocalMetadataSnapshot { credential_id: self.0.get(CSPP_CREDENTIAL_ID_KEY.into()), @@ -95,11 +158,7 @@ impl CloudBackupKeychain { &self, snapshot: &PendingEnableLocalMetadataSnapshot, ) -> Result<(), CloudBackupKeychainError> { - self.restore_entries(&[ - (CSPP_CREDENTIAL_ID_KEY.into(), snapshot.credential_id.clone()), - (CSPP_PRF_SALT_KEY.into(), snapshot.prf_salt.clone()), - (CSPP_NAMESPACE_ID_KEY.into(), snapshot.namespace_id.clone()), - ])?; + self.restore_entries(&snapshot.keychain_entries())?; Ok(()) } @@ -246,21 +305,34 @@ impl CloudBackupKeychain { previous_values: &[(String, Option)], ) -> Result<(), KeychainError> { for (key, previous_value) in previous_values { - match previous_value { - Some(value) => { - self.0.save(key.clone(), value.clone())?; - } - None => { - if self.0.get(key.clone()).is_some() && !self.0.delete(key.clone()) { - return Err(KeychainError::Delete); - } - } - } + self.restore_entry(key, previous_value.as_deref())?; } Ok(()) } + fn restore_all_entries( + &self, + previous_values: &[(String, Option)], + ) -> Result<(), KeychainError> { + let mut first_error = None; + + for (key, previous_value) in previous_values { + if let Err(error) = self.restore_entry(key, previous_value.as_deref()) { + first_error.get_or_insert(error); + } + } + + first_error.map_or(Ok(()), Err) + } + + fn restore_entry(&self, key: &str, previous_value: Option<&str>) -> Result<(), KeychainError> { + match previous_value { + Some(value) => self.0.save(key.to_owned(), value.to_owned()), + None => self.delete_keychain_item_if_present(key), + } + } + fn delete_keychain_item_if_present(&self, key: &str) -> Result<(), KeychainError> { if self.0.get(key.to_owned()).is_some() && !self.0.delete(key.to_owned()) { warn!("Failed to delete cloud backup keychain item"); diff --git a/rust/src/manager/cloud_backup_manager/model.rs b/rust/src/manager/cloud_backup_manager/model.rs index 9986e7fec..0c5d699e1 100644 --- a/rust/src/manager/cloud_backup_manager/model.rs +++ b/rust/src/manager/cloud_backup_manager/model.rs @@ -93,6 +93,21 @@ pub(crate) struct CloudBackupDetailInventorySnapshot { pub(crate) provisional_detail: Option, } +/// Provider listing still owed after detail was built from a trusted local snapshot +#[derive(Debug, Clone)] +pub(crate) struct CloudBackupDetailProviderConfirmation { + pub(crate) namespace: String, +} + +/// Outcome of building detail from an inventory snapshot +#[derive(Debug)] +pub(crate) enum CloudBackupDetailSnapshotCompletion { + /// The refresh is finished with this result + Final(CloudBackupDetailResult), + /// Trusted local rows can be shown now; the provider must still confirm them + TrustedLocal { detail: CloudBackupDetail, confirmation: CloudBackupDetailProviderConfirmation }, +} + #[derive(Debug)] pub(crate) enum CloudBackupDetailInventorySnapshotResult { Success(CloudBackupDetailInventorySnapshot), @@ -1310,6 +1325,12 @@ impl CloudBackupStateReducer { self.state.public_state() } + /// The public configured state, or `None` outside the configured phase + pub(crate) fn configured_state(&self) -> Option { + matches!(self.state.phase, CloudBackupLifecyclePhase::Configured) + .then(|| self.state.public_configured_state()) + } + pub(crate) fn status(&self) -> CloudBackupStatus { self.state.status() } @@ -2242,6 +2263,55 @@ mod tests { assert!(model.detail_inventory_is_ready()); } + #[test] + fn provider_confirmation_upgrades_trusted_local_snapshot_and_keeps_cloud_only_rows() { + let mut model = CloudBackupStateReducer { + state: configured_state( + CloudBackupVerificationState::NotVerified, + CloudSyncHealth::Unknown, + ), + }; + let detail = test_detail(2); + let wallets = vec![ + cloud_only_wallet("wallet-1", CloudBackupWalletStatus::DeletedFromDevice), + cloud_only_wallet("wallet-2", CloudBackupWalletStatus::DeletedFromDevice), + ]; + + model.apply_event(CloudBackupStateReducerEvent::DetailRefreshApplied { + detail: Some(( + detail.clone(), + CloudBackupInventoryAuthority::LocalSnapshotMatchesKnownCount, + )), + reset_cloud_only: false, + }); + model.apply_event(CloudBackupStateReducerEvent::CloudOnlyStateResolved( + CloudOnlyState::Loaded { wallets: wallets.clone() }, + )); + + assert_eq!( + restore_all_state(&model), + CloudBackupRestoreAllState::StartDisabled { wallet_count: 2 }, + ); + + model.apply_event(CloudBackupStateReducerEvent::DetailRefreshApplied { + detail: Some((detail, CloudBackupInventoryAuthority::ProviderConfirmed)), + reset_cloud_only: false, + }); + + let CloudBackupLifecycle::Configured(configured) = model.public_state().lifecycle else { + panic!("expected configured lifecycle"); + }; + let CloudBackupDetailState::Complete { state } = configured.detail else { + panic!("expected complete detail"); + }; + + assert_eq!(state.cloud_only, CloudOnlyState::Loaded { wallets }); + assert_eq!( + configured.restore_all, + CloudBackupRestoreAllState::StartAvailable { wallet_count: 2 }, + ); + } + #[test] fn smaller_provisional_snapshot_does_not_drop_retained_rows() { let mut model = CloudBackupStateReducer { diff --git a/rust/src/manager/cloud_backup_manager/ops/enable.rs b/rust/src/manager/cloud_backup_manager/ops/enable.rs index a761db7c6..124c18ef3 100644 --- a/rust/src/manager/cloud_backup_manager/ops/enable.rs +++ b/rust/src/manager/cloud_backup_manager/ops/enable.rs @@ -3,7 +3,7 @@ mod types; use cove_cspp::backup_data::remote_payload::RemotePayloadMetadata; use cove_cspp::master_key_crypto; -use cove_device::cloud_storage::CloudStorage; +use cove_device::cloud_storage::{CloudStorage, CloudStorageClient}; use cove_device::keychain::Keychain; use cove_device::passkey::PasskeyAccess; use tracing::info; @@ -11,6 +11,7 @@ use zeroize::Zeroizing; use super::{BlockingCloudStep, RustCloudBackupManager, blocking_cloud_error}; use crate::manager::cloud_backup_manager::actors::CloudBackupWriteClient; +use crate::manager::cloud_backup_manager::timing::log_elapsed; use crate::manager::cloud_backup_manager::wallets::{ NamespaceMatchOutcome, NamespacePasskeyMatcher, PasskeyMaterialAcquirer, PasskeyMaterialOutcome, PreparedWalletBackup, StagedPrfKey, @@ -31,6 +32,22 @@ pub(crate) use types::{ EnablePasskeyRegistrationFlow, }; +/// Lists the cloud backup namespaces an enable must account for, timed under `log_label` +async fn list_existing_namespaces( + cloud: &CloudStorageClient, + log_label: &str, +) -> Result, CloudBackupError> { + log_elapsed(log_label, cloud.list_namespaces()).await.map_err(|error| { + blocking_cloud_error( + BlockingCloudStep::Enable, + CloudBackupError::cloud_storage_context( + "could not check for existing cloud backups, please try again when cloud storage is available", + error, + ), + ) + }) +} + impl RustCloudBackupManager { fn pending_verification_uploads( uploaded_wallets: &[PreparedWalletBackup], @@ -71,18 +88,8 @@ impl RustCloudBackupManager { return Ok(CloudBackupEnablePreparation::CreateNew { context }); } - let mut namespaces = cloud - .list_namespaces() - .await - .map_err(|error| { - blocking_cloud_error( - BlockingCloudStep::Enable, - CloudBackupError::cloud_storage_context( - "could not check for existing cloud backups, please try again when cloud storage is available", - error, - ), - ) - })?; + let mut namespaces = + list_existing_namespaces(&cloud, "Enable: cloud namespace listing").await?; namespaces.sort(); if namespaces.is_empty() { @@ -90,10 +97,8 @@ impl RustCloudBackupManager { } info!("Enable: found {} existing namespace(s), attempting recovery", namespaces.len()); - let passkey_hint = self.best_passkey_hint_for_namespaces(&cloud, &namespaces).await; - let matcher = NamespacePasskeyMatcher::new(&cloud, passkey); - let match_outcome = matcher.match_namespaces(&namespaces).await?; + let (match_outcome, passkey_hint) = matcher.match_namespaces_with_hint(&namespaces).await?; match match_outcome { NamespaceMatchOutcome::Matched(matches) => { if matches.is_empty() { @@ -196,15 +201,8 @@ impl RustCloudBackupManager { let existing_namespaces = if has_local_master_key { Vec::new() } else { - cloud.list_namespaces().await.map_err(|error| { - blocking_cloud_error( - BlockingCloudStep::Enable, - CloudBackupError::cloud_storage_context( - "could not check for existing cloud backups, please try again when cloud storage is available", - error, - ), - ) - })? + list_existing_namespaces(&cloud, "Enable (no discovery): cloud namespace listing") + .await? }; if !existing_namespaces.is_empty() { @@ -212,8 +210,8 @@ impl RustCloudBackupManager { "Enable (no discovery): found {} existing namespace(s), waiting for confirmation before creating passkey", existing_namespaces.len() ); - let passkey_hint = - self.best_passkey_hint_for_namespaces(&cloud, &existing_namespaces).await; + let matcher = NamespacePasskeyMatcher::new(&cloud, PasskeyAccess::global()); + let passkey_hint = matcher.passkey_hint_for_namespaces(&existing_namespaces).await; return Ok(CloudBackupNoDiscoveryEnablePreparation::ExistingBackupFound { context, passkey_hint, diff --git a/rust/src/manager/cloud_backup_manager/ops/test_support.rs b/rust/src/manager/cloud_backup_manager/ops/test_support.rs index b531e3d9f..c5f598841 100644 --- a/rust/src/manager/cloud_backup_manager/ops/test_support.rs +++ b/rust/src/manager/cloud_backup_manager/ops/test_support.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, OnceLock}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex as StdMutex, OnceLock}; use std::time::Duration; use act_zero::call; @@ -89,6 +90,7 @@ impl cove_cspp::CsppStore for MockStoreHandle { type MockDiscoverResult = Result<(Vec, Vec), PasskeyError>; type MockPasskeyActionResults = Arc, PasskeyError>>>>; +type MockPasskeyStickyResult = Arc, PasskeyError>>>>; type MockPasskeyCreateResult = Arc>>>; #[derive(Debug, Default)] struct MockCloudState { @@ -97,6 +99,9 @@ struct MockCloudState { master_key_download_errors: HashMap, backup_upload_state_errors: HashMap<(String, String), CloudStorageError>, master_key_download_attempts: usize, + master_key_download_active: usize, + master_key_download_max_active: usize, + master_key_download_gate: Option>, wallet_backups: HashMap<(String, String), Vec>, wallet_backup_download_overrides: HashMap<(String, String), Vec>, wallet_backup_download_errors: HashMap<(String, String), CloudStorageError>, @@ -141,6 +146,33 @@ pub(crate) struct MockCloudDownloadGate { release: tokio::sync::Notify, } +#[derive(Debug, Default)] +pub(crate) struct MockPasskeyCallGate { + started: AtomicBool, + released: AtomicBool, + wait_lock: StdMutex<()>, + wait_condvar: Condvar, +} + +impl MockPasskeyCallGate { + fn block(&self) { + self.started.store(true, Ordering::Release); + let mut guard = self.wait_lock.lock().expect("passkey call gate lock"); + while !self.released.load(Ordering::Acquire) { + guard = self.wait_condvar.wait(guard).expect("passkey call gate wait"); + } + } + + pub(crate) fn is_started(&self) -> bool { + self.started.load(Ordering::Acquire) + } + + pub(crate) fn release(&self) { + self.released.store(true, Ordering::Release); + self.wait_condvar.notify_all(); + } +} + impl MockCloudDownloadGate { async fn block(&self) { self.started.notify_one(); @@ -430,6 +462,20 @@ impl MockCloudStorage { self.state.lock().master_key_download_attempts } + pub(crate) fn gate_master_key_downloads(&self) -> Arc { + let gate = Arc::new(MockCloudDownloadGate::default()); + self.state.lock().master_key_download_gate = Some(gate.clone()); + gate + } + + pub(crate) fn clear_master_key_download_gate(&self) { + self.state.lock().master_key_download_gate = None; + } + + pub(crate) fn master_key_download_max_concurrency(&self) -> usize { + self.state.lock().master_key_download_max_active + } + pub(crate) fn wallet_backup_download_attempt_count_for_record( &self, namespace: &str, @@ -553,17 +599,34 @@ impl CloudStorageAccess for MockCloudStorage { _locations: Vec, _policy: CloudAccessPolicy, ) -> Result, CloudStorageError> { - let mut state = self.state.lock(); - state.master_key_download_attempts += 1; - if let Some(error) = state.master_key_download_errors.get(&namespace).cloned() { - return Err(error); + let gate = { + let mut state = self.state.lock(); + state.master_key_download_attempts += 1; + state.master_key_download_active += 1; + state.master_key_download_max_active = + state.master_key_download_max_active.max(state.master_key_download_active); + state.master_key_download_gate.clone() + }; + + if let Some(gate) = gate { + gate.block().await; } - state - .master_key_backups - .get(&namespace) - .cloned() - .ok_or(CloudStorageError::NotFound(namespace)) + let result = { + let state = self.state.lock(); + if let Some(error) = state.master_key_download_errors.get(&namespace).cloned() { + Err(error) + } else { + state + .master_key_backups + .get(&namespace) + .cloned() + .ok_or(CloudStorageError::NotFound(namespace)) + } + }; + + self.state.lock().master_key_download_active -= 1; + result } async fn download_wallet_backup( @@ -822,10 +885,21 @@ pub(crate) struct MockPasskeyProviderImpl { discover_results: Arc>>, create_result: MockPasskeyCreateResult, authenticate_results: MockPasskeyActionResults, + authenticate_result_default: MockPasskeyStickyResult, create_count: Arc>, authenticate_count: Arc>, discover_count: Arc>, authenticated_credential_ids: Arc>>>, + authenticated_requests: Arc>>, + authenticate_gate: Arc>>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MockPasskeyAuthenticationRequest { + pub(crate) rp_id: String, + pub(crate) credential_id: Vec, + pub(crate) prf_salt: Vec, + pub(crate) challenge_len: usize, } impl MockPasskeyProviderImpl { @@ -833,10 +907,13 @@ impl MockPasskeyProviderImpl { self.discover_results.lock().clear(); *self.create_result.lock() = None; self.authenticate_results.lock().clear(); + *self.authenticate_result_default.lock() = None; *self.create_count.lock() = 0; *self.authenticate_count.lock() = 0; *self.discover_count.lock() = 0; self.authenticated_credential_ids.lock().clear(); + self.authenticated_requests.lock().clear(); + *self.authenticate_gate.lock() = None; } pub(crate) fn set_discover_result( @@ -866,9 +943,8 @@ impl MockPasskeyProviderImpl { } pub(crate) fn set_authenticate_result(&self, result: Result, PasskeyError>) { - let mut results = self.authenticate_results.lock(); - results.clear(); - results.push_back(result); + self.authenticate_results.lock().clear(); + *self.authenticate_result_default.lock() = Some(result); } pub(crate) fn push_authenticate_result(&self, result: Result, PasskeyError>) { @@ -890,6 +966,16 @@ impl MockPasskeyProviderImpl { pub(crate) fn authenticated_credential_ids(&self) -> Vec> { self.authenticated_credential_ids.lock().clone() } + + pub(crate) fn authenticated_requests(&self) -> Vec { + self.authenticated_requests.lock().clone() + } + + pub(crate) fn gate_next_authenticate(&self) -> Arc { + let gate = Arc::new(MockPasskeyCallGate::default()); + *self.authenticate_gate.lock() = Some(gate.clone()); + gate + } } impl PasskeyProvider for MockPasskeyProviderImpl { @@ -912,20 +998,37 @@ impl PasskeyProvider for MockPasskeyProviderImpl { fn authenticate_with_prf( &self, - _rp_id: String, + rp_id: String, credential_id: Vec, - _prf_salt: Vec, - _challenge: Vec, + prf_salt: Vec, + challenge: Vec, ) -> Result, PasskeyError> { *self.authenticate_count.lock() += 1; - self.authenticated_credential_ids.lock().push(credential_id); - self.authenticate_results.lock().pop_front().unwrap_or_else(|| { - Err(PasskeyError::RequestFailed { - operation: PasskeyOperation::AuthenticateAssertion, - reason: PasskeyFailureReason::Unknown { - diagnostic_message: "unexpected authenticate_with_prf call".into(), - }, - }) + self.authenticated_credential_ids.lock().push(credential_id.clone()); + self.authenticated_requests.lock().push(MockPasskeyAuthenticationRequest { + rp_id, + credential_id, + prf_salt, + challenge_len: challenge.len(), + }); + + if let Some(gate) = self.authenticate_gate.lock().take() { + gate.block(); + } + + if let Some(result) = self.authenticate_results.lock().pop_front() { + return result; + } + + if let Some(result) = self.authenticate_result_default.lock().clone() { + return result; + } + + Err(PasskeyError::RequestFailed { + operation: PasskeyOperation::AuthenticateAssertion, + reason: PasskeyFailureReason::Unknown { + diagnostic_message: "unexpected authenticate_with_prf call".into(), + }, }) } @@ -1123,6 +1226,7 @@ pub(crate) fn reset_cloud_backup_test_state_with_hook( ) { ensure_cloud_backup_test_tokio_runtime(); wait_for_cleanup_idle_for_test(manager); + crate::database::cloud_backup::restore_activation::test_support::reset(); globals.reset(); clear_local_wallets(); let reset_manager = manager.clone(); @@ -1349,6 +1453,24 @@ pub(crate) async fn encrypted_remote_wallet_backup_bytes( bytes } +/// Build a wallet backup, then leave the device as an app reinstall does +/// +/// A reinstall removes local files and the database but keeps the iOS keychain items +pub(crate) async fn encrypted_reinstalled_wallet_backup_bytes( + metadata: &WalletMetadata, + master_key: &cove_cspp::master_key::MasterKey, + revision_hash: &str, + version: u32, +) -> Vec { + let bytes = encrypted_wallet_backup_bytes(metadata, master_key, revision_hash, version).await; + + crate::wallet::delete_wallet_specific_data(&metadata.id) + .expect("reinstall fixture removes local wallet data"); + assert!(Keychain::global().wallet_items_exist(&metadata.id)); + + bytes +} + pub(crate) fn wallet_entry_with_labels( metadata: &WalletMetadata, labels_jsonl: Option<&str>, @@ -1474,3 +1596,31 @@ pub(crate) async fn new_restore_operation_for_test( ) -> RestoreOperation { call!(manager.supervisor.new_restore_operation()).await.expect("create restore operation") } + +/// A master key wrapper whose passkey hint and metadata are stamped with `registered_at` +pub(crate) fn master_wrapper_for_test( + master_key: &cove_cspp::master_key::MasterKey, + prf_key: &[u8; 32], + prf_salt: &[u8; 32], + registered_at: u64, +) -> Vec { + let namespace = master_key.namespace_id(); + let encrypted = cove_cspp::master_key_crypto::encrypt_master_key_with_remote_metadata( + master_key, + prf_key, + prf_salt, + Some(cove_cspp::backup_data::PasskeyProviderHint { + aaguid: "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4".into(), + registered_platform: cove_cspp::backup_data::PasskeyRegistrationPlatform::Android, + registered_at, + name_suffix: format!("{registered_at}"), + }), + cove_cspp::backup_data::remote_payload::RemotePayloadMetadata::master_key( + &namespace, + registered_at, + ), + ) + .unwrap(); + + serde_json::to_vec(&encrypted).unwrap() +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests.rs b/rust/src/manager/cloud_backup_manager/ops/tests.rs index 9c00c7ced..412b21bd7 100644 --- a/rust/src/manager/cloud_backup_manager/ops/tests.rs +++ b/rust/src/manager/cloud_backup_manager/ops/tests.rs @@ -45,12 +45,14 @@ use crate::manager::cloud_backup_manager::wallets::{ }; use crate::manager::cloud_backup_manager::{ CLOUD_BACKUP_MANAGER, CORRUPTED_CLOUD_BACKUP_STATE_MESSAGE, - CloudBackupDetailInventorySnapshotResult, CloudBackupDetailOutcome, CloudBackupDetailResult, - CloudBackupDisableOutcome, CloudBackupEnableContext, CloudBackupEnablePromptChoice, - CloudBackupEnableState, CloudBackupInventoryAuthority, CloudBackupInventoryIncompleteReason, - CloudBackupKeychain, CloudBackupLifecycle, CloudBackupManagerAction, - CloudBackupOtherBackupsState, CloudBackupPasskeyChoiceIntent, CloudBackupRestoreEvent, - CloudBackupRootPrompt, CloudBackupVerificationPresentation, CloudBackupVerificationReason, + CloudBackupDetailInventorySnapshotResult, CloudBackupDetailOutcome, + CloudBackupDetailProviderConfirmation, CloudBackupDetailResult, + CloudBackupDetailSnapshotCompletion, CloudBackupDisableOutcome, CloudBackupEnableContext, + CloudBackupEnablePromptChoice, CloudBackupEnableState, CloudBackupInventoryAuthority, + CloudBackupInventoryIncompleteReason, CloudBackupKeychain, CloudBackupLifecycle, + CloudBackupManagerAction, CloudBackupOtherBackupsState, CloudBackupPasskeyChoiceIntent, + CloudBackupRestoreAllState, CloudBackupRestoreEvent, CloudBackupRootPrompt, + CloudBackupVerificationPresentation, CloudBackupVerificationReason, CloudBackupVerificationSource, CloudBackupWalletStatus, DeepVerificationFailure, DeepVerificationReport, DeepVerificationResult, GENERIC_CLOUD_BACKUP_ERROR_MESSAGE, PendingEnableNamespaceOwnership, PendingEnablePasskeyMetadata, PendingEnableSession, diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/connectivity.rs b/rust/src/manager/cloud_backup_manager/ops/tests/connectivity.rs index 002e05261..161f8f249 100644 --- a/rust/src/manager/cloud_backup_manager/ops/tests/connectivity.rs +++ b/rust/src/manager/cloud_backup_manager/ops/tests/connectivity.rs @@ -1,5 +1,128 @@ use super::*; +async fn configure_trusted_local_snapshot_with_two_cloud_only_wallets( + manager: &RustCloudBackupManager, + globals: &TestGlobals, +) { + configure_enabled_cloud_backup(manager, globals, 2); + + let namespace = CloudBackupKeychain::global().namespace_id().unwrap(); + let master_key = cove_cspp::Cspp::new(Keychain::global().clone()) + .load_master_key_from_store() + .unwrap() + .unwrap(); + let first_wallet = xpub_only_wallet_metadata(); + let mut second_wallet = xpub_only_wallet_metadata(); + second_wallet.network = crate::network::Network::Testnet; + let first_record_id = wallet_record_id(first_wallet.id.as_ref()); + let second_record_id = wallet_record_id(second_wallet.id.as_ref()); + + globals.cloud.set_wallet_backup( + namespace.clone(), + first_record_id.clone(), + encrypted_wallet_backup_bytes_for_entry( + &wallet_entry_with_labels(&first_wallet, None), + &master_key, + 1, + ), + ); + globals.cloud.set_wallet_backup( + namespace.clone(), + second_record_id.clone(), + encrypted_wallet_backup_bytes_for_entry( + &wallet_entry_with_labels(&second_wallet, None), + &master_key, + 1, + ), + ); + + let wallet_files = vec![ + wallet_filename_from_record_id(&first_record_id), + wallet_filename_from_record_id(&second_record_id), + ]; + globals.cloud.set_wallet_files(namespace.clone(), wallet_files.clone()); + globals.cloud.set_wallet_files_snapshot(namespace, wallet_files, false); +} + +#[tokio::test(flavor = "current_thread")] +async fn trusted_local_snapshot_is_confirmed_by_provider_in_background() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + let manager = init_manager(); + configure_trusted_local_snapshot_with_two_cloud_only_wallets(&manager, globals).await; + let initial_list_attempts = globals.cloud.list_wallet_files_attempt_count(); + + call!(manager.supervisor.start_refresh_detail()).await.unwrap(); + + wait_for_test_condition(Duration::from_secs(2), "expected provider-confirmed detail", || { + matches!( + manager.state().lifecycle, + CloudBackupLifecycle::Configured(ref configured) + if matches!( + configured.detail, + CloudBackupDetailState::Complete { ref state } + if state.inventory_authority + == CloudBackupInventoryAuthority::ProviderConfirmed + ) + && matches!( + configured.restore_all, + CloudBackupRestoreAllState::StartAvailable { wallet_count: 2 } + ) + ) + }) + .await; + + assert!(globals.cloud.list_wallet_files_attempt_count() > initial_list_attempts); +} + +#[tokio::test(flavor = "current_thread")] +async fn provider_confirmation_failure_keeps_trusted_local_detail_complete() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + let manager = init_manager(); + configure_trusted_local_snapshot_with_two_cloud_only_wallets(&manager, globals).await; + globals.cloud.fail_list_wallet_files("metadata timed out"); + let initial_list_attempts = globals.cloud.list_wallet_files_attempt_count(); + + call!(manager.supervisor.start_refresh_detail()).await.unwrap(); + + wait_for_test_condition( + Duration::from_secs(2), + "expected retained trusted local detail", + || { + globals.cloud.list_wallet_files_attempt_count() > initial_list_attempts + && matches!( + manager.state().lifecycle, + CloudBackupLifecycle::Configured(ref configured) + if matches!( + configured.detail, + CloudBackupDetailState::Complete { ref state } + if state.inventory_authority + == CloudBackupInventoryAuthority::LocalSnapshotMatchesKnownCount + ) + && matches!( + configured.restore_all, + CloudBackupRestoreAllState::StartDisabled { wallet_count: 2 } + ) + ) + }, + ) + .await; + + assert_test_condition_stays_true( + Duration::from_millis(150), + "provider confirmation failure should keep trusted local detail", + || { + matches!( + manager.state().lifecycle, + CloudBackupLifecycle::Configured(ref configured) + if matches!(configured.detail, CloudBackupDetailState::Complete { .. }) + ) + }, + ) + .await; +} + #[tokio::test(flavor = "current_thread")] async fn connectivity_reconnect_preserves_failed_wallet_upload_health() { let _guard = async_test_lock().lock().await; @@ -125,7 +248,7 @@ async fn manual_detail_refresh_recovers_after_automatic_retry_fails() { } #[tokio::test(flavor = "current_thread")] -async fn provider_signal_does_not_reopen_a_ready_detail_inventory() { +async fn provider_signal_reopens_only_incomplete_ready_detail_inventory() { let _guard = async_test_lock().lock().await; let globals = test_globals(); let manager = init_manager(); @@ -156,18 +279,24 @@ async fn provider_signal_does_not_reopen_a_ready_detail_inventory() { )); assert_eq!(globals.cloud.list_wallet_files_snapshot_attempt_count(), active_snapshot_attempts); - manager.apply_detail_outcome(CloudBackupDetailOutcome::Refreshed(complete_detail)); + manager.apply_detail_outcome(CloudBackupDetailOutcome::RefreshedWithAuthority { + detail: complete_detail, + authority: CloudBackupInventoryAuthority::LocalSnapshotMatchesKnownCount, + }); let snapshot_attempts = globals.cloud.list_wallet_files_snapshot_attempt_count(); manager.cloud_storage_did_change(); - tokio::time::sleep(Duration::from_millis(100)).await; + wait_for_test_condition(Duration::from_secs(6), "expected provider signal refresh", || { + globals.cloud.list_wallet_files_snapshot_attempt_count() > snapshot_attempts + }) + .await; assert!(matches!( manager.state().lifecycle, CloudBackupLifecycle::Configured(ref configured) if matches!(&configured.detail, CloudBackupDetailState::Complete { .. }) )); - assert_eq!(globals.cloud.list_wallet_files_snapshot_attempt_count(), snapshot_attempts); + assert!(globals.cloud.list_wallet_files_snapshot_attempt_count() > snapshot_attempts); } #[tokio::test(flavor = "current_thread")] diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/passkey.rs b/rust/src/manager/cloud_backup_manager/ops/tests/passkey.rs index 85ba11cec..a9f41d2be 100644 --- a/rust/src/manager/cloud_backup_manager/ops/tests/passkey.rs +++ b/rust/src/manager/cloud_backup_manager/ops/tests/passkey.rs @@ -1,5 +1,194 @@ use super::*; use cove_cspp::backup_data::MASTER_KEY_RECORD_ID; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[tokio::test(flavor = "current_thread")] +async fn enable_uses_one_wrapper_download_per_namespace() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + let first_master_key = cove_cspp::master_key::MasterKey::generate(); + let second_master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = first_master_key.namespace_id(); + let second_namespace = second_master_key.namespace_id(); + globals.cloud.set_master_key_backup( + first_namespace, + master_wrapper_for_test(&first_master_key, &[7; 32], &[9; 32], 1), + ); + globals.cloud.set_master_key_backup( + second_namespace, + master_wrapper_for_test(&second_master_key, &[8; 32], &[9; 32], 2), + ); + + let preparation = + manager.prepare_enable(CloudBackupEnableContext::settings_manual()).await.unwrap(); + + assert!(matches!(preparation, CloudBackupEnablePreparation::ExistingBackupFound { .. })); + assert_eq!(globals.cloud.master_key_download_attempt_count(), 2); +} + +#[tokio::test(flavor = "current_thread")] +async fn passkey_candidate_loads_are_limited_to_four_concurrent_wrapper_reads() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + globals.reset(); + + let mut namespaces = Vec::new(); + for registered_at in 0..8 { + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + globals.cloud.set_master_key_backup( + namespace.clone(), + master_wrapper_for_test(&master_key, &[7; 32], &[9; 32], registered_at), + ); + namespaces.push(namespace); + } + + let gate = globals.cloud.gate_master_key_downloads(); + let matcher = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ); + let task = tokio::spawn(async move { matcher.match_namespaces(&namespaces).await }); + + wait_for_test_condition(Duration::from_secs(1), "four candidate loads start", || { + globals.cloud.master_key_download_attempt_count() >= 4 + }) + .await; + assert_eq!(globals.cloud.master_key_download_max_concurrency(), 4); + + globals.cloud.clear_master_key_download_gate(); + for _ in 0..4 { + gate.release(); + } + + assert!(matches!(task.await.unwrap().unwrap(), NamespaceMatchOutcome::NoMatch)); +} + +#[tokio::test(flavor = "current_thread")] +async fn passkey_candidate_cancellation_does_not_start_queued_wrapper_reads() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + globals.reset(); + + let mut namespaces = Vec::new(); + for registered_at in 0..8 { + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + globals.cloud.set_master_key_backup( + namespace.clone(), + master_wrapper_for_test(&master_key, &[7; 32], &[9; 32], registered_at), + ); + namespaces.push(namespace); + } + + let gate = globals.cloud.gate_master_key_downloads(); + let matcher = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ); + let cancellation = Arc::new(AtomicBool::new(false)); + let cancellation_for_task = Arc::clone(&cancellation); + let task = tokio::spawn(async move { + matcher + .start_session_with_cancellation(cancellation_for_task) + .match_snapshot(&namespaces) + .await + }); + + wait_for_test_condition(Duration::from_secs(1), "four candidate loads start", || { + globals.cloud.master_key_download_attempt_count() >= 4 + }) + .await; + cancellation.store(true, Ordering::Release); + globals.cloud.clear_master_key_download_gate(); + for _ in 0..4 { + gate.release(); + } + + assert!(matches!( + task.await.unwrap().unwrap(), + NamespaceMatchSnapshotOutcome::OperationCancelled + )); + assert_eq!(globals.cloud.master_key_download_attempt_count(), 4); +} + +#[tokio::test(flavor = "current_thread")] +async fn passkey_matches_keep_registration_order_after_concurrent_loads() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + globals.reset(); + + let prf_key = [7; 32]; + let prf_salt = [9; 32]; + let mut candidates = Vec::new(); + for registered_at in [10, 30, 20] { + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + globals.cloud.set_master_key_backup( + namespace.clone(), + master_wrapper_for_test(&master_key, &prf_key, &prf_salt, registered_at), + ); + candidates.push((registered_at, namespace)); + } + + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + globals.passkey.push_authenticate_result(Ok(prf_key.to_vec())); + globals.passkey.push_authenticate_result(Ok(prf_key.to_vec())); + + let mut namespaces = + candidates.iter().map(|(_, namespace)| namespace.clone()).collect::>(); + namespaces.reverse(); + let outcome = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&namespaces) + .await + .unwrap(); + let NamespaceMatchOutcome::Matched(matches) = outcome else { + panic!("expected all candidates to match"); + }; + + let actual = matches.into_iter().map(|matched| matched.namespace_id).collect::>(); + let mut expected = candidates.clone(); + expected.sort_by_key(|(registered_at, _)| std::cmp::Reverse(*registered_at)); + let expected = expected.into_iter().map(|(_, namespace)| namespace).collect::>(); + assert_eq!(actual, expected); +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_and_missing_upload_states_skip_wrapper_downloads() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + globals.reset(); + + let pending_master_key = cove_cspp::master_key::MasterKey::generate(); + let pending_namespace = pending_master_key.namespace_id(); + globals.cloud.set_master_key_backup( + pending_namespace.clone(), + master_wrapper_for_test(&pending_master_key, &[7; 32], &[9; 32], 1), + ); + globals.cloud.set_uploaded_master_key_pending_confirmation(true); + let missing_namespace = cove_cspp::master_key::MasterKey::generate().namespace_id(); + + let outcome = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&[pending_namespace, missing_namespace]) + .await + .unwrap(); + + assert!(matches!(outcome, NamespaceMatchOutcome::Inconclusive)); + assert_eq!(globals.cloud.master_key_download_attempt_count(), 0); +} #[tokio::test(flavor = "current_thread")] async fn non_missing_discovery_failure_never_registers_enable_passkey() { @@ -545,6 +734,57 @@ async fn passkey_match_session_authenticates_new_namespace_after_discovery_refre assert_eq!(globals.passkey.authenticate_count(), 1); } +#[tokio::test(flavor = "current_thread")] +async fn passkey_match_session_refreshes_changed_wrapper_and_preserves_auth_parameters() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + globals.reset(); + + let selected_prf_key = [7u8; 32]; + let old_master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = old_master_key.namespace_id(); + globals.cloud.set_master_key_backup( + namespace.clone(), + master_wrapper_for_test(&old_master_key, &[8; 32], &[1; 32], 1), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: selected_prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + + let matcher = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ); + let mut session = matcher.start_session(); + let first = session.match_snapshot(std::slice::from_ref(&namespace)).await.unwrap(); + assert!(matches!(first, NamespaceMatchSnapshotOutcome::Continue)); + + globals.cloud.set_master_key_backup( + namespace.clone(), + master_wrapper_for_test(&old_master_key, &selected_prf_key, &[2; 32], 2), + ); + globals.passkey.set_authenticate_result(Ok(selected_prf_key.to_vec())); + + let refreshed = session.match_snapshot(std::slice::from_ref(&namespace)).await.unwrap(); + let NamespaceMatchSnapshotOutcome::Matched(matches) = refreshed else { + panic!("expected changed wrapper to match after refresh"); + }; + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].namespace_id, namespace); + assert_eq!(globals.passkey.authenticate_count(), 1); + assert_eq!( + globals.passkey.authenticated_requests(), + vec![MockPasskeyAuthenticationRequest { + rp_id: "covebitcoinwallet.com".into(), + credential_id: vec![1, 2, 3], + prf_salt: vec![2; 32], + challenge_len: 32, + }] + ); +} + #[tokio::test(flavor = "current_thread")] async fn passkey_match_session_retries_targeted_auth_failure_before_presentation() { let _guard = async_test_lock().lock().await; @@ -585,11 +825,11 @@ async fn passkey_match_session_retries_targeted_auth_failure_before_presentation current_namespace.clone(), serde_json::to_vec(¤t_encrypted).unwrap(), ); - globals.passkey.set_authenticate_result(Err(PasskeyError::RequestFailed { + globals.passkey.set_authenticate_result(Ok(selected_prf_key.to_vec())); + globals.passkey.push_authenticate_result(Err(PasskeyError::RequestFailed { operation: PasskeyOperation::AuthenticateAssertion, reason: PasskeyFailureReason::PlatformAuthorizationFailed, })); - globals.passkey.push_authenticate_result(Ok(selected_prf_key.to_vec())); let failed = session .match_snapshot(&[stale_namespace.clone(), current_namespace.clone()]) @@ -648,17 +888,20 @@ async fn passkey_match_session_does_not_retry_targeted_auth_failure_after_presen current_namespace.clone(), serde_json::to_vec(¤t_encrypted).unwrap(), ); - globals.passkey.set_authenticate_result(Err(PasskeyError::RequestFailed { + globals.passkey.set_authenticate_result(Ok(selected_prf_key.to_vec())); + globals.passkey.push_authenticate_result(Err(PasskeyError::RequestFailed { operation: PasskeyOperation::AuthenticateAssertion, reason: PasskeyFailureReason::PlatformAuthorizationFailedAfterPresentation, })); - globals.passkey.push_authenticate_result(Ok(selected_prf_key.to_vec())); let failed = session .match_snapshot(&[stale_namespace.clone(), current_namespace.clone()]) .await .unwrap(); - assert!(matches!(failed, NamespaceMatchSnapshotOutcome::Continue)); + let NamespaceMatchSnapshotOutcome::AuthenticationFailed { matches, .. } = failed else { + panic!("expected terminal authentication failure"); + }; + assert!(matches.is_empty()); let unchanged = session.match_snapshot(&[stale_namespace, current_namespace]).await.unwrap(); @@ -666,6 +909,94 @@ async fn passkey_match_session_does_not_retry_targeted_auth_failure_after_presen assert_eq!(globals.passkey.authenticate_count(), 1); } +#[tokio::test(flavor = "current_thread")] +async fn passkey_match_stops_after_terminal_targeted_auth_failure() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + globals.reset(); + + let discovered_prf_key = [8u8; 32]; + let mut candidates = Vec::new(); + for (registered_at, wrapper_prf_key) in + [(3, [7u8; 32]), (2, discovered_prf_key), (1, discovered_prf_key)] + { + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + globals.cloud.set_master_key_backup( + namespace.clone(), + master_wrapper_for_test(&master_key, &wrapper_prf_key, &[9; 32], registered_at), + ); + candidates.push(namespace); + } + + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: discovered_prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(discovered_prf_key.to_vec())); + globals.passkey.push_authenticate_result(Err(PasskeyError::RequestFailed { + operation: PasskeyOperation::AuthenticateAssertion, + reason: PasskeyFailureReason::InvalidResponse, + })); + + let result = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&candidates) + .await; + let Err(error) = result else { + panic!("terminal native failure must remain a passkey failure"); + }; + + assert!(matches!(error, CloudBackupError::Passkey(_))); + assert_eq!(globals.passkey.authenticate_count(), 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn passkey_match_keeps_earlier_matches_after_terminal_targeted_auth_failure() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + globals.reset(); + + let discovered_prf_key = [8u8; 32]; + let mut candidates = Vec::new(); + for registered_at in [3, 2, 1] { + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + globals.cloud.set_master_key_backup( + namespace.clone(), + master_wrapper_for_test(&master_key, &discovered_prf_key, &[9; 32], registered_at), + ); + candidates.push(namespace); + } + + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: discovered_prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(discovered_prf_key.to_vec())); + globals.passkey.push_authenticate_result(Err(PasskeyError::RequestFailed { + operation: PasskeyOperation::AuthenticateAssertion, + reason: PasskeyFailureReason::InvalidResponse, + })); + + let outcome = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&candidates) + .await + .unwrap(); + let NamespaceMatchOutcome::Matched(matches) = outcome else { + panic!("the namespace matched before the failure must stay restorable"); + }; + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].namespace_id, candidates[0]); + assert_eq!(globals.passkey.authenticate_count(), 1); +} + #[tokio::test(flavor = "current_thread")] async fn passkey_match_session_does_not_prompt_for_unchanged_wrapper() { let _guard = async_test_lock().lock().await; diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/restore.rs b/rust/src/manager/cloud_backup_manager/ops/tests/restore.rs index 61229a4b4..512d8e805 100644 --- a/rust/src/manager/cloud_backup_manager/ops/tests/restore.rs +++ b/rust/src/manager/cloud_backup_manager/ops/tests/restore.rs @@ -4,6 +4,7 @@ use crate::manager::cloud_backup_manager::{ CLOUD_BACKUP_COMPATIBILITY_MESSAGE, CLOUD_BACKUP_LABELS_WARNING_MESSAGE, GENERIC_CLOUD_BACKUP_ERROR_MESSAGE, }; +use crate::test_support::raw_wallet_keychain_entries; #[tokio::test(flavor = "current_thread")] async fn restore_downloaded_wallet_does_not_reupload_wallet_or_mutate_backup_counts() { @@ -353,6 +354,168 @@ async fn restore_with_one_passkey_restores_wallets_from_all_matching_namespaces( } } +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn restore_native_cancellation_after_match_restores_retained_matches_without_refresh() { + assert_terminal_native_stop_restores_retained_matches(PasskeyError::UserCancelled).await; +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn restore_terminal_auth_failure_after_match_restores_retained_matches_without_refresh() { + assert_terminal_native_stop_restores_retained_matches(PasskeyError::RequestFailed { + operation: PasskeyOperation::AuthenticateAssertion, + reason: PasskeyFailureReason::InvalidResponse, + }) + .await; +} + +/// The first namespace matches through discovery, then targeted authentication for the second +/// namespace stops with `targeted_auth_error` +async fn assert_terminal_native_stop_restores_retained_matches(targeted_auth_error: PasskeyError) { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let prf_key = [7u8; 32]; + let first_master_key = cove_cspp::master_key::MasterKey::generate(); + let second_master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = first_master_key.namespace_id(); + let second_namespace = second_master_key.namespace_id(); + globals.cloud.set_master_key_backup( + first_namespace.clone(), + master_wrapper_for_test(&first_master_key, &prf_key, &[9; 32], 2), + ); + globals.cloud.set_master_key_backup( + second_namespace.clone(), + master_wrapper_for_test(&second_master_key, &prf_key, &[8; 32], 1), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Err(targeted_auth_error)); + + let first_wallet = xpub_only_wallet_metadata(); + let second_wallet = xpub_only_wallet_metadata(); + let sample_xpub_from_entropy = |metadata: &WalletMetadata, byte| { + let entropy = [byte; 16]; + let mnemonic = Mnemonic::from_entropy(&entropy).unwrap(); + + crate::mnemonic::MnemonicExt::xpub(&mnemonic, metadata.network.into()).to_string() + }; + Keychain::global() + .save_wallet_xpub( + &first_wallet.id, + sample_xpub_from_entropy(&first_wallet, 1).parse().unwrap(), + ) + .unwrap(); + Keychain::global() + .save_wallet_xpub( + &second_wallet.id, + sample_xpub_from_entropy(&second_wallet, 2).parse().unwrap(), + ) + .unwrap(); + let first_record_id = wallet_record_id(first_wallet.id.as_ref()); + let second_record_id = wallet_record_id(second_wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + first_namespace.clone(), + first_record_id.clone(), + encrypted_remote_wallet_backup_bytes(&first_wallet, &first_master_key, "first-revision", 1) + .await, + ); + globals.cloud.set_wallet_backup( + second_namespace.clone(), + second_record_id.clone(), + encrypted_remote_wallet_backup_bytes( + &second_wallet, + &second_master_key, + "second-revision", + 1, + ) + .await, + ); + globals + .cloud + .set_wallet_files(first_namespace, vec![wallet_filename_from_record_id(&first_record_id)]); + globals.cloud.set_wallet_files( + second_namespace, + vec![wallet_filename_from_record_id(&second_record_id)], + ); + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + + assert_eq!(report.wallets_restored, 1); + assert_eq!(report.wallets_failed, 0); + assert_eq!(globals.passkey.discover_count(), 1); + assert_eq!(globals.passkey.authenticate_count(), 1); + assert_eq!(globals.cloud.list_namespaces_attempt_count(), 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_cancellation_retains_exclusive_claim_until_native_call_returns() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let discovered_prf_key = [7u8; 32]; + let mismatch_master_key = cove_cspp::master_key::MasterKey::generate(); + let matching_master_key = cove_cspp::master_key::MasterKey::generate(); + let mismatch_namespace = mismatch_master_key.namespace_id(); + let matching_namespace = matching_master_key.namespace_id(); + globals.cloud.set_master_key_backup( + mismatch_namespace, + master_wrapper_for_test(&mismatch_master_key, &[8; 32], &[9; 32], 2), + ); + globals.cloud.set_master_key_backup( + matching_namespace, + master_wrapper_for_test(&matching_master_key, &discovered_prf_key, &[8; 32], 1), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: discovered_prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(discovered_prf_key.to_vec())); + let native_gate = globals.passkey.gate_next_authenticate(); + + let (sender, _receiver) = flume::bounded(100); + call!(manager.supervisor.start_restore_from_cloud_backup_with_events(sender)).await.unwrap(); + wait_for_test_condition( + Duration::from_secs(2), + "targeted restore authentication starts", + || native_gate.is_started(), + ) + .await; + + let active_claim = manager.projected_exclusive_operation().expect("restore claim is active"); + assert_eq!(active_claim.operation(), CloudBackupExclusiveOperation::Restore); + call!(manager.supervisor.cancel_restore()).await.unwrap(); + assert_eq!(manager.projected_exclusive_operation(), Some(active_claim)); + + let (retry_sender, retry_receiver) = flume::bounded(1); + call!(manager.supervisor.start_restore_from_cloud_backup_with_events(retry_sender)) + .await + .unwrap(); + let retry_event = tokio::time::timeout(Duration::from_secs(2), retry_receiver.recv_async()) + .await + .expect("restore retry response") + .expect("restore retry event"); + assert!(matches!( + retry_event, + CloudBackupRestoreEvent::Failed(message) if message == "restore already in progress" + )); + + native_gate.release(); + wait_for_test_condition(Duration::from_secs(2), "cancelled restore releases ownership", || { + manager.projected_exclusive_operation().is_none() + }) + .await; + assert_eq!(globals.passkey.authenticate_count(), 1); +} + #[tokio::test(flavor = "current_thread", start_paused = true)] async fn restore_missing_wallet_listing_fails_closed_without_finalizing_empty_state() { let _guard = async_test_lock().lock().await; @@ -1613,3 +1776,154 @@ async fn restore_fails_when_all_listed_wallet_backups_are_missing() { PersistedCloudBackupStatus::Disabled ); } + +/// A hot wallet whose keychain items survived an app reinstall that removed the database +fn hot_wallet_with_surviving_keychain_items() -> WalletMetadata { + use crate::wallet_secret::WalletSecretExt as _; + + let mut metadata = WalletMetadata::preview_new(); + metadata.wallet_type = WalletType::Hot; + + // a seed of its own, so this wallet cannot be mistaken for the xpub-only fixture + let mnemonic = + bip39::Mnemonic::parse("zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong").unwrap(); + let secret = cove_device::keychain::WalletSecret::Mnemonic(mnemonic); + let xpub = secret.xpub(metadata.network); + let descriptors = secret.clone().into_descriptors(metadata.network, metadata.address_type); + let keychain = Keychain::global(); + + // the identity set treats two wallets with one fingerprint as duplicates + metadata.master_fingerprint = + Some(Arc::new(crate::wallet::fingerprint::Fingerprint::from(xpub.fingerprint()))); + keychain.save_wallet_xpub(&metadata.id, xpub).unwrap(); + keychain + .save_public_descriptor( + &metadata.id, + descriptors.external.extended_descriptor.clone(), + descriptors.internal.extended_descriptor, + ) + .unwrap(); + keychain.save_wallet_secret(&metadata.id, secret).unwrap(); + + metadata +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn restore_after_reinstall_adopts_surviving_keychain_items() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + + let hot_wallet = hot_wallet_with_surviving_keychain_items(); + let xpub_wallet = xpub_only_wallet_metadata(); + Keychain::global() + .save_wallet_xpub(&xpub_wallet.id, sample_xpub(&xpub_wallet).parse().unwrap()) + .unwrap(); + + let mut wallet_files = Vec::new(); + for wallet in [&hot_wallet, &xpub_wallet] { + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + // this fixture keeps the keychain items, which is what a reinstall leaves behind + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_reinstalled_wallet_backup_bytes(wallet, &master_key, "reinstall-revision", 2) + .await, + ); + wallet_files.push(wallet_filename_from_record_id(&record_id)); + } + globals.cloud.set_wallet_files(namespace, wallet_files); + + let before = + [raw_wallet_keychain_entries(&hot_wallet.id), raw_wallet_keychain_entries(&xpub_wallet.id)]; + + let operation = new_restore_operation_for_test(&manager).await; + let report = operation.restore_from_cloud_backup(&manager).await.unwrap(); + + assert_eq!( + (report.wallets_restored, report.wallets_failed, report.failed_wallet_errors.clone()), + (2, 0, Vec::new()) + ); + assert_eq!( + [raw_wallet_keychain_entries(&hot_wallet.id), raw_wallet_keychain_entries(&xpub_wallet.id),], + before + ); + for wallet in [&hot_wallet, &xpub_wallet] { + assert!( + Database::global() + .wallets() + .get(&wallet.id, wallet.network, wallet.wallet_mode) + .unwrap() + .is_some() + ); + } + + let second_operation = new_restore_operation_for_test(&manager).await; + let second_report = second_operation.restore_from_cloud_backup(&manager).await.unwrap(); + + assert_eq!(second_report.wallets_restored, 0); + assert_eq!(second_report.wallets_failed, 0); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn restore_keeps_the_conflict_category_when_every_wallet_conflicts() { + use crate::mnemonic::MnemonicExt as _; + + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + let manager = init_manager(); + + reset_cloud_backup_test_state(&manager, globals); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]).unwrap(); + globals + .cloud + .set_master_key_backup(namespace.clone(), serde_json::to_vec(&encrypted_master).unwrap()); + cove_cspp::Cspp::new(Keychain::global().clone()).save_master_key(&master_key).unwrap(); + + let wallet = xpub_only_wallet_metadata(); + Keychain::global().save_wallet_xpub(&wallet.id, sample_xpub(&wallet).parse().unwrap()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + namespace.clone(), + record_id.clone(), + encrypted_reinstalled_wallet_backup_bytes(&wallet, &master_key, "conflict-revision", 2) + .await, + ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); + + // the surviving keychain xpub belongs to another seed, so it cannot be adopted + let unrelated_xpub = + bip39::Mnemonic::parse("zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong") + .unwrap() + .xpub(wallet.network.into()); + Keychain::global().save_wallet_xpub(&wallet.id, unrelated_xpub).unwrap(); + let before = raw_wallet_keychain_entries(&wallet.id); + + let operation = new_restore_operation_for_test(&manager).await; + let error = operation.restore_from_cloud_backup(&manager).await.unwrap_err(); + + assert!(matches!( + error, + CloudBackupError::LocalWalletConflict(crate::backup::import::LocalWalletConflict::Mismatch) + )); + assert_eq!(raw_wallet_keychain_entries(&wallet.id), before); + assert_eq!( + Database::global().cloud_backup_state.get().unwrap().status(), + PersistedCloudBackupStatus::Disabled + ); +} diff --git a/rust/src/manager/cloud_backup_manager/ops/tests/sync_health.rs b/rust/src/manager/cloud_backup_manager/ops/tests/sync_health.rs index d35de2221..beb5ce47b 100644 --- a/rust/src/manager/cloud_backup_manager/ops/tests/sync_health.rs +++ b/rust/src/manager/cloud_backup_manager/ops/tests/sync_health.rs @@ -503,8 +503,9 @@ async fn incomplete_inventory_snapshot_is_provisional_and_final_failure_remains_ assert_eq!(globals.cloud.wallet_backup_download_attempt_count(), initial_download_attempts); globals.cloud.fail_list_wallet_files("metadata timed out"); - let Some(CloudBackupDetailResult::AccessError(error)) = - manager.complete_cloud_backup_detail_inventory_snapshot(snapshot).await + let Some(CloudBackupDetailSnapshotCompletion::Final(CloudBackupDetailResult::AccessError( + error, + ))) = manager.complete_cloud_backup_detail_inventory_snapshot(snapshot).await else { panic!("expected authoritative inventory failure"); }; @@ -533,10 +534,12 @@ async fn complete_inventory_snapshot_avoids_relisting_current_namespace() { assert_eq!(snapshot.authority, Some(CloudBackupInventoryAuthority::ProviderConfirmed)); assert!(snapshot.provisional_detail.is_none()); - let Some(CloudBackupDetailResult::SuccessWithAuthority { - authority: CloudBackupInventoryAuthority::ProviderConfirmed, - .. - }) = manager.complete_cloud_backup_detail_inventory_snapshot(snapshot).await + let Some(CloudBackupDetailSnapshotCompletion::Final( + CloudBackupDetailResult::SuccessWithAuthority { + authority: CloudBackupInventoryAuthority::ProviderConfirmed, + .. + }, + )) = manager.complete_cloud_backup_detail_inventory_snapshot(snapshot).await else { panic!("expected complete cloud backup detail"); }; @@ -572,10 +575,11 @@ async fn complete_local_snapshot_matching_known_count_avoids_metadata_relisting( encrypted_wallet_backup_bytes(&metadata, &master_key, "snapshot-revision", 1).await, ); globals.cloud.set_wallet_files_snapshot( - namespace, + namespace.clone(), vec![wallet_filename_from_record_id(&record_id)], false, ); + globals.cloud.set_wallet_files(namespace, vec![wallet_filename_from_record_id(&record_id)]); let Some(CloudBackupDetailInventorySnapshotResult::Success(snapshot)) = manager.load_cloud_backup_detail_inventory_snapshot().await @@ -589,16 +593,49 @@ async fn complete_local_snapshot_matching_known_count_avoids_metadata_relisting( ); assert!(snapshot.provisional_detail.is_none()); + let initial_list_attempts = globals.cloud.list_wallet_files_attempt_count(); globals.cloud.fail_list_wallet_files("metadata query should not run"); - let Some(CloudBackupDetailResult::SuccessWithAuthority { - authority: CloudBackupInventoryAuthority::LocalSnapshotMatchesKnownCount, - detail, - }) = manager.complete_cloud_backup_detail_inventory_snapshot(snapshot).await + let Some(CloudBackupDetailSnapshotCompletion::TrustedLocal { detail, confirmation }) = + manager.complete_cloud_backup_detail_inventory_snapshot(snapshot).await else { panic!("expected detail from the trusted local snapshot"); }; assert_eq!(detail.up_to_date.len() + detail.needs_sync.len(), 1); + assert_eq!(globals.cloud.list_wallet_files_attempt_count(), initial_list_attempts); + + globals.cloud.clear_list_wallet_files_error(); + let Some(CloudBackupDetailResult::SuccessWithAuthority { + authority: CloudBackupInventoryAuthority::ProviderConfirmed, + .. + }) = manager.confirm_cloud_backup_detail_inventory(confirmation).await + else { + panic!("expected provider-confirmed cloud backup detail"); + }; + + assert_eq!(globals.cloud.list_wallet_files_attempt_count(), initial_list_attempts + 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn provider_confirmation_rejects_a_changed_namespace() { + let _guard = async_test_lock().lock().await; + let globals = test_globals(); + let manager = init_manager(); + configure_enabled_cloud_backup(&manager, globals, 1); + + let Some(CloudBackupDetailResult::AccessError(error)) = manager + .confirm_cloud_backup_detail_inventory(CloudBackupDetailProviderConfirmation { + namespace: "different-namespace".into(), + }) + .await + else { + panic!("expected namespace mismatch error"); + }; + + assert!( + error.to_string().contains("namespace changed during inventory confirmation"), + "{error}" + ); } #[tokio::test(flavor = "current_thread")] diff --git a/rust/src/manager/cloud_backup_manager/other_backups.rs b/rust/src/manager/cloud_backup_manager/other_backups.rs index b4b2852da..824ff9f6d 100644 --- a/rust/src/manager/cloud_backup_manager/other_backups.rs +++ b/rust/src/manager/cloud_backup_manager/other_backups.rs @@ -80,17 +80,6 @@ impl RustCloudBackupManager { Ok(CloudBackupOtherBackupsSummary { namespace_count, wallet_count, passkey_hints }) } - pub(crate) async fn best_passkey_hint_for_namespaces( - &self, - cloud: &CloudStorageClient, - namespaces: &[String], - ) -> Option { - self.passkey_hints_for_namespaces(cloud, namespaces) - .await - .into_iter() - .max_by_key(|hint| hint.registered_at) - } - async fn passkey_hints_for_namespaces( &self, cloud: &CloudStorageClient, @@ -115,20 +104,20 @@ impl RustCloudBackupManager { }) else { continue; }; - if encrypted.remote_metadata.normalized_master_key(namespace).is_err() { - warn!("Failed to normalize passkey hint for namespace {namespace}"); - continue; - } - - let Some(provider_hint) = encrypted.passkey_provider_hint.as_ref() else { - continue; + let hint = match CloudBackupPasskeyHint::from_master_key_wrapper(&encrypted, namespace) + { + Ok(Some(hint)) => hint, + Ok(None) => continue, + Err(_) => { + warn!("Failed to normalize passkey hint for namespace {namespace}"); + continue; + } }; - let hint = CloudBackupPasskeyHint::from_provider_hint(provider_hint); hints_by_suffix .entry(hint.name_suffix.clone()) .and_modify(|current| { - if hint.registered_at > current.registered_at { + if hint.is_newer_than(current) { *current = hint.clone(); } }) diff --git a/rust/src/manager/cloud_backup_manager/pending/detail.rs b/rust/src/manager/cloud_backup_manager/pending/detail.rs index 75500bbb6..af24293c7 100644 --- a/rust/src/manager/cloud_backup_manager/pending/detail.rs +++ b/rust/src/manager/cloud_backup_manager/pending/detail.rs @@ -7,7 +7,8 @@ use crate::database::Database; use crate::database::cloud_backup::{CloudBlobConfirmedState, PersistedCloudBlobState}; use crate::manager::cloud_backup_manager::{ BlockingCloudStep, CloudBackupDetailInventorySnapshot, - CloudBackupDetailInventorySnapshotResult, CloudBackupDetailResult, CloudBackupError, + CloudBackupDetailInventorySnapshotResult, CloudBackupDetailProviderConfirmation, + CloudBackupDetailResult, CloudBackupDetailSnapshotCompletion, CloudBackupError, CloudBackupInventoryAuthority, CloudBackupStatus, RustCloudBackupManager, blocking_cloud_error, cloud_inventory::CloudWalletInventory, cloud_inventory::RemoteWalletTruth, offline_error_for_step, @@ -70,7 +71,7 @@ impl RustCloudBackupManager { pub(crate) async fn complete_cloud_backup_detail_inventory_snapshot( &self, snapshot: CloudBackupDetailInventorySnapshot, - ) -> Option { + ) -> Option { let status = self.state.read().status().clone(); if !matches!(status, CloudBackupStatus::Enabled | CloudBackupStatus::PasskeyMissing) { info!("complete_cloud_backup_detail_inventory_snapshot: skipping, status={status:?}"); @@ -79,38 +80,103 @@ impl RustCloudBackupManager { let current_namespace = match self.current_namespace_id() { Ok(namespace) => namespace, - Err(error) => return Some(CloudBackupDetailResult::AccessError(error)), + Err(error) => { + return Some(CloudBackupDetailSnapshotCompletion::Final( + CloudBackupDetailResult::AccessError(error), + )); + } }; if current_namespace != snapshot.namespace { - return Some(CloudBackupDetailResult::AccessError(CloudBackupError::Internal( - "cloud backup namespace changed during inventory refresh".into(), - ))); + return Some(CloudBackupDetailSnapshotCompletion::Final( + CloudBackupDetailResult::AccessError(CloudBackupError::Internal( + "cloud backup namespace changed during inventory refresh".into(), + )), + )); } if self.is_known_offline() && snapshot.authority.is_none() { - return Some(CloudBackupDetailResult::AccessError(offline_error_for_step( - BlockingCloudStep::DetailRefresh, - ))); + return Some(CloudBackupDetailSnapshotCompletion::Final( + CloudBackupDetailResult::AccessError(offline_error_for_step( + BlockingCloudStep::DetailRefresh, + )), + )); } let cloud = CloudStorage::global_explicit_client(); - let (wallet_record_ids, authority) = if let Some(authority) = snapshot.authority { - (snapshot.wallet_record_ids, authority) - } else { - match cloud.list_wallet_backups(snapshot.namespace.clone()).await { - Ok(record_ids) => (record_ids, CloudBackupInventoryAuthority::ProviderConfirmed), - Err(error) => { - let error = blocking_cloud_error( - BlockingCloudStep::DetailRefresh, - CloudBackupError::cloud_storage_context("list wallet backups", error), - ); - - return Some(CloudBackupDetailResult::AccessError(error)); - } + match snapshot.authority { + Some(CloudBackupInventoryAuthority::ProviderConfirmed) => { + Some(CloudBackupDetailSnapshotCompletion::Final( + self.finish_cloud_backup_detail_refresh( + snapshot.wallet_record_ids, + CloudBackupInventoryAuthority::ProviderConfirmed, + cloud, + ) + .await, + )) + } + Some(CloudBackupInventoryAuthority::LocalSnapshotMatchesKnownCount) => { + let result = self + .finish_cloud_backup_detail_refresh( + snapshot.wallet_record_ids, + CloudBackupInventoryAuthority::LocalSnapshotMatchesKnownCount, + cloud, + ) + .await; + + Some(match result { + CloudBackupDetailResult::SuccessWithAuthority { detail, .. } => { + CloudBackupDetailSnapshotCompletion::TrustedLocal { + detail, + confirmation: CloudBackupDetailProviderConfirmation { + namespace: snapshot.namespace, + }, + } + } + result @ CloudBackupDetailResult::AccessError(_) => { + CloudBackupDetailSnapshotCompletion::Final(result) + } + }) } + Some(CloudBackupInventoryAuthority::Provisional) | None => { + let result = match cloud.list_wallet_backups(snapshot.namespace).await { + Ok(record_ids) => { + self.finish_cloud_backup_detail_refresh( + record_ids, + CloudBackupInventoryAuthority::ProviderConfirmed, + cloud, + ) + .await + } + Err(error) => { + let error = blocking_cloud_error( + BlockingCloudStep::DetailRefresh, + CloudBackupError::cloud_storage_context("list wallet backups", error), + ); + + CloudBackupDetailResult::AccessError(error) + } + }; + + Some(CloudBackupDetailSnapshotCompletion::Final(result)) + } + } + } + + pub(crate) async fn confirm_cloud_backup_detail_inventory( + &self, + confirmation: CloudBackupDetailProviderConfirmation, + ) -> Option { + let current_namespace = match self.current_namespace_id() { + Ok(namespace) => namespace, + Err(error) => return Some(CloudBackupDetailResult::AccessError(error)), }; + if current_namespace != confirmation.namespace { + return Some(CloudBackupDetailResult::AccessError(CloudBackupError::Internal( + "cloud backup namespace changed during inventory confirmation".into(), + ))); + } - Some(self.finish_cloud_backup_detail_refresh(wallet_record_ids, authority, cloud).await) + self.refresh_cloud_backup_detail().await } /// List wallet backups in the current namespace and build detail diff --git a/rust/src/manager/cloud_backup_manager/reconcile.rs b/rust/src/manager/cloud_backup_manager/reconcile.rs index b41f6ce37..84dd8c2c7 100644 --- a/rust/src/manager/cloud_backup_manager/reconcile.rs +++ b/rust/src/manager/cloud_backup_manager/reconcile.rs @@ -1,11 +1,14 @@ use cove_device::cloud_storage::CloudSyncHealth; +use crate::database::Database; +use crate::database::cloud_backup::PersistedCloudBackupState; + use super::model::{CloudBackupStateReducerEffects, CloudBackupStateReducerEvent}; use super::verify::coordinator::{ CloudBackupVerificationCoordinator, CloudBackupVerificationEffect, }; use super::{ - CloudBackupDetailOutcome, CloudBackupEnableContext, CloudBackupLifecycle, + CloudBackupDetailOutcome, CloudBackupEnableContext, CloudBackupError, CloudBackupLifecycle, CloudBackupSettingsRowStatus, CloudBackupStatus, CloudBackupVerificationMetadata, CloudBackupVerificationPresentation, CloudBackupVerificationSource, PendingUploadVerificationState, RustCloudBackupManager, @@ -166,6 +169,28 @@ impl RustCloudBackupManager { }); } + /// Projects a just-persisted cloud backup state into runtime status and flags + pub(crate) fn reconcile_persisted_state(&self, state: &PersistedCloudBackupState) { + self.reconcile_runtime_status(Self::runtime_status_for(state)); + self.refresh_persisted_flags(); + } + + /// Persists `state`, then projects it into runtime status and flags + pub(crate) fn persist_cloud_backup_state( + &self, + state: &PersistedCloudBackupState, + context: impl std::fmt::Display, + ) -> Result<(), CloudBackupError> { + Database::global() + .cloud_backup_state + .set(state) + .map_err(|source| CloudBackupError::internal_context(context, source))?; + + self.reconcile_persisted_state(state); + + Ok(()) + } + fn apply_pending_upload_verification_value(&self, pending: PendingUploadVerificationState) { self.apply_model_event(CloudBackupStateReducerEvent::PendingUploadVerificationReconciled( pending, diff --git a/rust/src/manager/cloud_backup_manager/recovery_coverage.rs b/rust/src/manager/cloud_backup_manager/recovery_coverage.rs new file mode 100644 index 000000000..4a22b5e8a --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/recovery_coverage.rs @@ -0,0 +1,581 @@ +use std::collections::HashSet; + +use cove_cspp::backup_data::wallet_record_id; +use cove_device::cloud_storage::CloudSyncHealth; + +use super::model::{ + CloudBackupConfiguredState, CloudBackupDetailState, CloudBackupPasskeyState, + CloudBackupVerificationState, +}; +use super::{ + CloudBackupInventoryAuthority, CloudBackupKeychain, CloudBackupStatus, CloudBackupWalletStatus, + RustCloudBackupManager, +}; +use crate::database::Database; +use crate::database::cloud_backup::{ + PersistedBackupVerificationState, PersistedCloudBackupState, PersistedCloudBlobState, + PersistedCloudBlobSyncState, PersistedPasskeyState, PersistedVerificationRequirement, +}; +use crate::wallet::metadata::{WalletMetadata, WalletType}; + +/// Confirmed cloud recovery copies, separate from recovery-word verification +#[derive(Debug, Default)] +pub(crate) struct CloudBackupRecoveryCoverage(HashSet); + +impl CloudBackupRecoveryCoverage { + /// Load recovery coverage only from the current authoritative cloud state + pub(crate) fn load(manager: &RustCloudBackupManager) -> Self { + // the live state gates everything else, so skip keychain and database reads when it cannot qualify + let live_configured = { + let state = manager.state.read(); + if state.status() != CloudBackupStatus::Enabled || state.active_operation().is_some() { + return Self::default(); + } + + state.configured_state() + }; + let Some(live_configured) = live_configured else { + return Self::default(); + }; + + let Some(namespace) = + CloudBackupKeychain::global().namespace_id().filter(|namespace| !namespace.is_empty()) + else { + return Self::default(); + }; + let Ok(persisted) = Database::global().cloud_backup_state.get() else { + return Self::default(); + }; + let Ok(sync_states) = Database::global().cloud_blob_sync_states.list() else { + return Self::default(); + }; + + Self::from_states(&live_configured, &persisted, &namespace, &sync_states) + } + + fn from_states( + live_configured: &CloudBackupConfiguredState, + persisted: &PersistedCloudBackupState, + namespace: &str, + sync_states: &[PersistedCloudBlobSyncState], + ) -> Self { + let PersistedCloudBackupState::Configured(persisted_configured) = persisted else { + return Self::default(); + }; + + if persisted_configured.passkey != PersistedPasskeyState::Available + || persisted_configured.pending_verification_completion.is_some() + || persisted_configured.pending_restore_all.is_some() + { + return Self::default(); + } + + if !matches!(&live_configured.passkey, CloudBackupPasskeyState::Available) + || live_configured.sync_health != CloudSyncHealth::AllUploaded + || !verification_proof_is_usable( + &live_configured.verification, + &persisted_configured.verification, + ) + { + return Self::default(); + } + + let CloudBackupDetailState::Complete { state: loaded } = &live_configured.detail else { + return Self::default(); + }; + + if loaded.inventory_authority != CloudBackupInventoryAuthority::ProviderConfirmed { + return Self::default(); + } + + if sync_states.iter().any(PersistedCloudBlobSyncState::is_corrupted) { + return Self::default(); + } + + if sync_states.iter().filter(|sync_state| sync_state.namespace_id == namespace).any( + |sync_state| { + sync_state.is_master_key_wrapper() + && !matches!(&sync_state.state, PersistedCloudBlobState::Confirmed(_)) + }, + ) { + return Self::default(); + } + + let mut covered = loaded + .detail + .up_to_date + .iter() + .filter(|wallet| wallet.sync_status == CloudBackupWalletStatus::Confirmed) + .map(|wallet| wallet.record_id.clone()) + .collect::>(); + + for sync_state in sync_states.iter().filter(|sync_state| { + sync_state.namespace_id == namespace && sync_state.is_wallet_record() + }) { + if !matches!(&sync_state.state, PersistedCloudBlobState::Confirmed(_)) { + covered.remove(sync_state.record_id()); + } + } + + Self(covered) + } + + /// Whether a hot wallet still needs a recovery copy before local data can be wiped + pub(crate) fn needs_backup(&self, wallet: &WalletMetadata) -> bool { + wallet.wallet_type == WalletType::Hot + && !wallet.verified + && !self.0.contains(&wallet_record_id(wallet.id.as_ref())) + } +} + +fn verification_proof_is_usable( + live: &CloudBackupVerificationState, + persisted: &PersistedBackupVerificationState, +) -> bool { + matches!( + (live, persisted), + ( + CloudBackupVerificationState::Verified { .. } | CloudBackupVerificationState::Cancelled, + PersistedBackupVerificationState::Verified { .. }, + ) | ( + CloudBackupVerificationState::Verified { .. } + | CloudBackupVerificationState::Required + | CloudBackupVerificationState::Cancelled, + PersistedBackupVerificationState::Required { + reason: PersistedVerificationRequirement::WalletSetChanged, + last_verified_at: Some(_), + .. + }, + ) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::cloud_backup::{ + PersistedBackupSyncState, PersistedConfiguredCloudBackup, PersistedRestoreAllMarker, + }; + use crate::manager::cloud_backup_manager::model::{ + CloudBackupDestructiveOperationState, CloudBackupRestoreAllState, CloudBackupSyncState, + CloudBackupUndecryptableWalletDeletionState, LoadedCloudBackupDetail, + }; + use crate::manager::cloud_backup_manager::verify::test_support::{ + apply_startup_integrity_downgrade, apply_verification_result, + }; + use crate::manager::cloud_backup_manager::{ + CloudBackupOtherBackupsState, CloudBackupVerificationPresentation, DeepVerificationResult, + }; + use cove_device::cloud_storage::CloudSyncHealth; + + fn persisted_verified_state() -> PersistedCloudBackupState { + PersistedCloudBackupState::Configured(PersistedConfiguredCloudBackup { + passkey: PersistedPasskeyState::Available, + verification: PersistedBackupVerificationState::Verified { + last_verified_at: 10, + requested_at: None, + dismissed_at: None, + }, + sync: PersistedBackupSyncState { last_sync: Some(10), wallet_count: Some(1) }, + pending_verification_completion: None, + pending_restore_all: None, + }) + } + + fn cloud_state(detail: CloudBackupDetailState) -> CloudBackupConfiguredState { + cloud_state_with_verification( + detail, + CloudBackupVerificationState::Verified { report: None, last_verified_at: Some(10) }, + CloudSyncHealth::AllUploaded, + ) + } + + fn cloud_state_with_verification( + detail: CloudBackupDetailState, + verification: CloudBackupVerificationState, + sync_health: CloudSyncHealth, + ) -> CloudBackupConfiguredState { + CloudBackupConfiguredState { + passkey: CloudBackupPasskeyState::Available, + verification, + sync: CloudBackupSyncState::Idle, + destructive_operation: CloudBackupDestructiveOperationState::Idle, + undecryptable_wallet_deletion: CloudBackupUndecryptableWalletDeletionState::Idle, + detail, + other_backups: CloudBackupOtherBackupsState::NotChecked, + restore_all: CloudBackupRestoreAllState::NotShown, + root_prompt: super::super::CloudBackupRootPrompt::None, + sync_health, + verification_presentation: CloudBackupVerificationPresentation::Hidden { source: None }, + } + } + + fn loaded_detail( + authority: CloudBackupInventoryAuthority, + wallet_status: CloudBackupWalletStatus, + record_id: &str, + ) -> CloudBackupDetailState { + CloudBackupDetailState::Complete { + state: LoadedCloudBackupDetail { + detail: super::super::CloudBackupDetail { + last_sync: Some(10), + up_to_date: vec![super::super::CloudBackupWalletItem { + name: "Wallet".into(), + network: None, + wallet_mode: None, + wallet_type: None, + fingerprint: None, + label_count: None, + backup_updated_at: Some(10), + sync_status: wallet_status, + restore_failure: None, + record_id: record_id.into(), + }], + needs_sync: Vec::new(), + cloud_only_count: 0, + }, + inventory_authority: authority, + cloud_only: super::super::CloudOnlyState::NotFetched, + cloud_only_operation: super::super::CloudOnlyOperation::Idle, + other_backups_operation: super::super::OtherBackupsOperation::Idle, + }, + } + } + + fn wallet_with_id(id: &str) -> WalletMetadata { + let mut wallet = WalletMetadata::preview_new(); + wallet.id = id.into(); + wallet + } + + fn persisted_required_state( + reason: PersistedVerificationRequirement, + last_verified_at: Option, + ) -> PersistedCloudBackupState { + let mut state = persisted_verified_state(); + if let PersistedCloudBackupState::Configured(configured) = &mut state { + configured.verification = PersistedBackupVerificationState::Required { + reason, + last_verified_at, + requested_at: Some(20), + dismissed_at: None, + }; + } + + state + } + + #[test] + fn authoritative_green_detail_covers_wallet_without_persisted_blob_rows() { + let wallet = wallet_with_id("wallet-1"); + let record_id = wallet_record_id(wallet.id.as_ref()); + let coverage = CloudBackupRecoveryCoverage::from_states( + &cloud_state(loaded_detail( + CloudBackupInventoryAuthority::ProviderConfirmed, + CloudBackupWalletStatus::Confirmed, + &record_id, + )), + &persisted_verified_state(), + "namespace", + &[], + ); + + assert!(!coverage.needs_backup(&wallet)); + } + + #[test] + fn stale_or_provisional_detail_does_not_cover_wallet() { + let wallet = wallet_with_id("wallet-1"); + let record_id = wallet_record_id(wallet.id.as_ref()); + + for authority in [ + CloudBackupInventoryAuthority::LocalSnapshotMatchesKnownCount, + CloudBackupInventoryAuthority::Provisional, + ] { + let coverage = CloudBackupRecoveryCoverage::from_states( + &cloud_state(loaded_detail( + authority, + CloudBackupWalletStatus::Confirmed, + &record_id, + )), + &persisted_verified_state(), + "namespace", + &[], + ); + + assert!(coverage.needs_backup(&wallet)); + } + + let coverage = CloudBackupRecoveryCoverage::from_states( + &cloud_state(loaded_detail( + CloudBackupInventoryAuthority::ProviderConfirmed, + CloudBackupWalletStatus::Dirty, + &record_id, + )), + &persisted_verified_state(), + "namespace", + &[], + ); + + assert!(coverage.needs_backup(&wallet)); + } + + #[test] + fn pending_verification_or_restore_blocks_cloud_coverage() { + let wallet = wallet_with_id("wallet-1"); + let record_id = wallet_record_id(wallet.id.as_ref()); + let detail = cloud_state(loaded_detail( + CloudBackupInventoryAuthority::ProviderConfirmed, + CloudBackupWalletStatus::Confirmed, + &record_id, + )); + + let mut pending_verification = persisted_verified_state(); + if let PersistedCloudBackupState::Configured(configured) = &mut pending_verification { + configured.pending_verification_completion = + Some(crate::database::cloud_backup::PersistedPendingVerificationCompletion { + report: crate::database::cloud_backup::PersistedDeepVerificationReport { + master_key_wrapper_repaired: false, + local_master_key_repaired: false, + credential_recovered: false, + wallets_verified: 1, + wallets_failed: 0, + wallets_unsupported: 0, + wallet_issues: None, + }, + namespace_id: "namespace".into(), + uploads: Vec::new(), + created_at: None, + }); + } + assert!( + CloudBackupRecoveryCoverage::from_states( + &detail, + &pending_verification, + "namespace", + &[], + ) + .needs_backup(&wallet) + ); + + let mut pending_restore = persisted_verified_state(); + if let PersistedCloudBackupState::Configured(configured) = &mut pending_restore { + configured.pending_restore_all = + Some(PersistedRestoreAllMarker { namespace_id: "namespace".into() }); + } + assert!( + CloudBackupRecoveryCoverage::from_states(&detail, &pending_restore, "namespace", &[],) + .needs_backup(&wallet) + ); + } + + #[test] + fn missing_or_pending_recovery_key_blocks_cloud_coverage() { + let wallet = wallet_with_id("wallet-1"); + let record_id = wallet_record_id(wallet.id.as_ref()); + let detail = loaded_detail( + CloudBackupInventoryAuthority::ProviderConfirmed, + CloudBackupWalletStatus::Confirmed, + &record_id, + ); + + for sync_health in [ + CloudSyncHealth::Unknown, + CloudSyncHealth::Uploading, + CloudSyncHealth::Failed("missing master key".into()), + ] { + let coverage = CloudBackupRecoveryCoverage::from_states( + &cloud_state_with_verification( + detail.clone(), + CloudBackupVerificationState::Verified { + report: None, + last_verified_at: Some(10), + }, + sync_health, + ), + &persisted_verified_state(), + "namespace", + &[], + ); + + assert!(coverage.needs_backup(&wallet)); + } + + for sync_state in [ + PersistedCloudBlobState::Dirty(crate::database::cloud_backup::CloudBlobDirtyState { + changed_at: 20, + }), + PersistedCloudBlobState::Failed(crate::database::cloud_backup::CloudBlobFailedState { + revision_hash: None, + retryable: false, + error: "upload failed".into(), + issue: None, + failed_at: 20, + }), + ] { + let coverage = CloudBackupRecoveryCoverage::from_states( + &cloud_state(detail.clone()), + &persisted_verified_state(), + "namespace", + &[PersistedCloudBlobSyncState::master_key_wrapper("namespace".into(), sync_state)], + ); + + assert!(coverage.needs_backup(&wallet)); + } + } + + #[test] + fn corrupt_blob_sync_state_blocks_cloud_coverage() { + let wallet = wallet_with_id("wallet-1"); + let record_id = wallet_record_id(wallet.id.as_ref()); + let detail = cloud_state(loaded_detail( + CloudBackupInventoryAuthority::ProviderConfirmed, + CloudBackupWalletStatus::Confirmed, + &record_id, + )); + + let coverage = CloudBackupRecoveryCoverage::from_states( + &detail, + &persisted_verified_state(), + "namespace", + &[PersistedCloudBlobSyncState::corrupted("decode failed".into())], + ); + + assert!(coverage.needs_backup(&wallet)); + } + + #[test] + fn startup_integrity_downgrade_invalidates_wallet_set_recovery_coverage() { + let wallet = wallet_with_id("wallet-1"); + let record_id = wallet_record_id(wallet.id.as_ref()); + let mut persisted = persisted_verified_state(); + persisted.mark_verification_required_after_wallet_change(Some(20)); + + let live = cloud_state_with_verification( + loaded_detail( + CloudBackupInventoryAuthority::ProviderConfirmed, + CloudBackupWalletStatus::Confirmed, + &record_id, + ), + CloudBackupVerificationState::Required, + CloudSyncHealth::AllUploaded, + ); + let coverage = + CloudBackupRecoveryCoverage::from_states(&live, &persisted, "namespace", &[]); + + assert!(!coverage.needs_backup(&wallet)); + + let downgraded = apply_startup_integrity_downgrade(&persisted).unwrap(); + let coverage = + CloudBackupRecoveryCoverage::from_states(&live, &downgraded, "namespace", &[]); + + assert!(coverage.needs_backup(&wallet)); + } + + #[test] + fn cancelled_verification_preserves_prior_recovery_coverage() { + let wallet = wallet_with_id("wallet-1"); + let record_id = wallet_record_id(wallet.id.as_ref()); + let live = cloud_state_with_verification( + loaded_detail( + CloudBackupInventoryAuthority::ProviderConfirmed, + CloudBackupWalletStatus::Confirmed, + &record_id, + ), + CloudBackupVerificationState::Cancelled, + CloudSyncHealth::AllUploaded, + ); + let mut wallet_set_changed = persisted_verified_state(); + wallet_set_changed.mark_verification_required_after_wallet_change(Some(20)); + + for persisted in [persisted_verified_state(), wallet_set_changed] { + let cancelled = + apply_verification_result(&persisted, &DeepVerificationResult::UserCancelled(None)); + + assert_eq!(cancelled, persisted); + assert!( + !CloudBackupRecoveryCoverage::from_states(&live, &cancelled, "namespace", &[]) + .needs_backup(&wallet) + ); + } + } + + #[test] + fn cancelled_live_state_does_not_restore_integrity_invalidated_coverage() { + let wallet = wallet_with_id("wallet-1"); + let record_id = wallet_record_id(wallet.id.as_ref()); + let persisted = + persisted_required_state(PersistedVerificationRequirement::IntegrityIssue, Some(10)); + let cancelled = + apply_verification_result(&persisted, &DeepVerificationResult::UserCancelled(None)); + let live = cloud_state_with_verification( + loaded_detail( + CloudBackupInventoryAuthority::ProviderConfirmed, + CloudBackupWalletStatus::Confirmed, + &record_id, + ), + CloudBackupVerificationState::Cancelled, + CloudSyncHealth::AllUploaded, + ); + + assert_eq!(cancelled, persisted); + assert!( + CloudBackupRecoveryCoverage::from_states(&live, &cancelled, "namespace", &[]) + .needs_backup(&wallet) + ); + } + + #[test] + fn no_prior_verification_or_integrity_loss_still_blocks_cloud_coverage() { + let wallet = wallet_with_id("wallet-1"); + let record_id = wallet_record_id(wallet.id.as_ref()); + let detail = cloud_state_with_verification( + loaded_detail( + CloudBackupInventoryAuthority::ProviderConfirmed, + CloudBackupWalletStatus::Confirmed, + &record_id, + ), + CloudBackupVerificationState::Required, + CloudSyncHealth::AllUploaded, + ); + + for persisted in [ + persisted_required_state(PersistedVerificationRequirement::WalletSetChanged, None), + persisted_required_state(PersistedVerificationRequirement::IntegrityIssue, Some(10)), + ] { + let coverage = + CloudBackupRecoveryCoverage::from_states(&detail, &persisted, "namespace", &[]); + + assert!(coverage.needs_backup(&wallet)); + } + } + + #[test] + fn current_pending_wallet_upload_blocks_stale_detail_coverage() { + let wallet = wallet_with_id("wallet-1"); + let record_id = wallet_record_id(wallet.id.as_ref()); + let detail = cloud_state(loaded_detail( + CloudBackupInventoryAuthority::ProviderConfirmed, + CloudBackupWalletStatus::Confirmed, + &record_id, + )); + let sync_state = PersistedCloudBlobSyncState::wallet( + "namespace".into(), + wallet.id.clone(), + record_id, + PersistedCloudBlobState::Dirty(crate::database::cloud_backup::CloudBlobDirtyState { + changed_at: 20, + }), + ); + + assert!( + CloudBackupRecoveryCoverage::from_states( + &detail, + &persisted_verified_state(), + "namespace", + &[sync_state], + ) + .needs_backup(&wallet) + ); + } +} diff --git a/rust/src/manager/cloud_backup_manager/timing.rs b/rust/src/manager/cloud_backup_manager/timing.rs new file mode 100644 index 000000000..515451b1c --- /dev/null +++ b/rust/src/manager/cloud_backup_manager/timing.rs @@ -0,0 +1,21 @@ +//! Elapsed-time logging for slow cloud and passkey calls + +use std::future::Future; +use std::time::Instant; + +use tracing::info; + +/// Awaits `operation`, then logs how long it took and whether it succeeded +/// +/// The log line is `"{label} elapsed_ms= success="`, so call sites keep +/// one searchable format for every timed step +pub(crate) async fn log_elapsed( + label: &str, + operation: impl Future>, +) -> Result { + let started_at = Instant::now(); + let result = operation.await; + info!("{label} elapsed_ms={} success={}", started_at.elapsed().as_millis(), result.is_ok()); + + result +} diff --git a/rust/src/manager/cloud_backup_manager/verify.rs b/rust/src/manager/cloud_backup_manager/verify.rs index 86562fdd1..f7c0f5393 100644 --- a/rust/src/manager/cloud_backup_manager/verify.rs +++ b/rust/src/manager/cloud_backup_manager/verify.rs @@ -52,12 +52,11 @@ impl IntegrityDowngrade { fn apply_to(&self, current: &PersistedCloudBackupState) -> Option { match self { Self::Unverified => match current.status() { - PersistedCloudBackupStatus::Enabled => { + PersistedCloudBackupStatus::Enabled | PersistedCloudBackupStatus::Unverified => { let mut state = current.clone(); state.mark_verification_required(state.last_verification_requested_at()); Some(state) } - PersistedCloudBackupStatus::Unverified => Some(current.clone()), PersistedCloudBackupStatus::PasskeyMissing | PersistedCloudBackupStatus::Disabling | PersistedCloudBackupStatus::Disabled @@ -67,6 +66,29 @@ impl IntegrityDowngrade { } } +#[cfg(test)] +pub(crate) mod test_support { + use super::{ + DeepVerificationResult, IntegrityDowngrade, PersistedCloudBackupState, + apply_persisted_verification_result, + }; + + pub(crate) fn apply_startup_integrity_downgrade( + current: &PersistedCloudBackupState, + ) -> Option { + IntegrityDowngrade::Unverified.apply_to(current) + } + + pub(crate) fn apply_verification_result( + current: &PersistedCloudBackupState, + result: &DeepVerificationResult, + ) -> PersistedCloudBackupState { + let mut updated = current.clone(); + apply_persisted_verification_result(&mut updated, result, 30); + updated + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct CloudBackupPasskeyRepairFinalization { pub(crate) wallet_count: u32, @@ -180,46 +202,11 @@ impl RustCloudBackupManager { pub(crate) fn persist_verification_result(&self, result: &DeepVerificationResult) { let verified_at = cove_util::time::unix_timestamp_secs_or_zero(); - let persisted = - self.mutate_persisted_cloud_backup_state("persist verification state", |state| { - if matches!( - state.status(), - PersistedCloudBackupStatus::Disabled - | PersistedCloudBackupStatus::Corrupted - | PersistedCloudBackupStatus::Disabling - ) { - return false; - } - - let previous = state.clone(); - match result { - DeepVerificationResult::Verified(_) => state.mark_verified_at(verified_at), - DeepVerificationResult::NeedsAttention(report) => { - state.mark_verification_needs_attention( - verified_at, - report.wallets_verified, - PersistedWalletVerificationIssues { - missing: report.wallet_issues.missing, - download_failed: report.wallet_issues.download_failed, - invalid: report.wallet_issues.invalid, - decryption_failed: report.wallet_issues.decryption_failed, - unsupported: report.wallet_issues.unsupported, - unreadable: report.wallet_issues.unreadable, - }, - ); - } - DeepVerificationResult::PasskeyMissing(_) => state.mark_passkey_missing(), - DeepVerificationResult::UserCancelled(_) - | DeepVerificationResult::Failed(_) => { - state.mark_verification_required(state.last_verification_requested_at()); - } - DeepVerificationResult::AwaitingUploadConfirmation(_) - | DeepVerificationResult::PasskeyConfirmed(_) - | DeepVerificationResult::NotEnabled => return false, - } - - *state != previous + let persisted = self + .mutate_persisted_cloud_backup_state("persist verification state", |state| { + apply_persisted_verification_result(state, result, verified_at) }); + if let Err(error) = persisted { error!("Failed to persist verification state: {error}"); } @@ -230,13 +217,9 @@ impl RustCloudBackupManager { let persisted = self.mutate_persisted_cloud_backup_state( "mark cloud backup unverified after wallet change", |state| { - let Some(mut new_state) = IntegrityDowngrade::Unverified.apply_to(state) else { - return false; - }; - - new_state.mark_verification_required(Some(requested_at)); - *state = new_state; - true + let previous = state.clone(); + state.mark_verification_required_after_wallet_change(Some(requested_at)); + *state != previous }, ); if let Err(error) = persisted { @@ -476,12 +459,61 @@ impl RustCloudBackupManager { } } +fn apply_persisted_verification_result( + state: &mut PersistedCloudBackupState, + result: &DeepVerificationResult, + verified_at: u64, +) -> bool { + if matches!( + state.status(), + PersistedCloudBackupStatus::Disabled + | PersistedCloudBackupStatus::Corrupted + | PersistedCloudBackupStatus::Disabling + ) { + return false; + } + + let previous = state.clone(); + match result { + DeepVerificationResult::Verified(_) => state.mark_verified_at(verified_at), + DeepVerificationResult::NeedsAttention(report) => { + state.mark_verification_needs_attention( + verified_at, + report.wallets_verified, + PersistedWalletVerificationIssues { + missing: report.wallet_issues.missing, + download_failed: report.wallet_issues.download_failed, + invalid: report.wallet_issues.invalid, + decryption_failed: report.wallet_issues.decryption_failed, + unsupported: report.wallet_issues.unsupported, + unreadable: report.wallet_issues.unreadable, + }, + ); + } + + DeepVerificationResult::PasskeyMissing(_) => state.mark_passkey_missing(), + + // cancellation does not provide new evidence about backup integrity + DeepVerificationResult::UserCancelled(_) => return false, + + DeepVerificationResult::Failed(_) => { + state.mark_verification_required(state.last_verification_requested_at()); + } + + DeepVerificationResult::AwaitingUploadConfirmation(_) + | DeepVerificationResult::PasskeyConfirmed(_) + | DeepVerificationResult::NotEnabled => return false, + } + + *state != previous +} + #[cfg(test)] mod tests { use super::*; use crate::database::cloud_backup::{ PersistedBackupSyncState, PersistedBackupVerificationState, PersistedConfiguredCloudBackup, - PersistedPasskeyState, + PersistedPasskeyState, PersistedVerificationRequirement, }; fn configured_state( @@ -519,6 +551,7 @@ mod tests { configured_state( PersistedPasskeyState::Available, PersistedBackupVerificationState::Required { + reason: PersistedVerificationRequirement::IntegrityIssue, last_verified_at: Some(21), requested_at: None, dismissed_at: None, diff --git a/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs b/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs index 7830c3ed9..edb508897 100644 --- a/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs +++ b/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs @@ -36,13 +36,6 @@ pub(crate) enum PasskeyAuthPolicy { DiscoverOnly, } -enum StoredPasskeyAuthOutcome { - Authenticated(AuthenticatedPasskey), - UserCancelled, - Failed(PasskeyError), - NoCredentialFound, -} - /// Authenticates backup passkeys against the PRF salt from a master-key backup pub(crate) struct PasskeyAuthenticator { keychain: CloudBackupKeychain, @@ -65,8 +58,9 @@ impl PasskeyAuthenticator { match policy { PasskeyAuthPolicy::StoredOnly => { - self.authenticate_stored_only(prf_salt, &retrier).await + self.authenticate_by_stored_credential(prf_salt, &retrier).await } + PasskeyAuthPolicy::DiscoverOnly => { self.authenticate_by_discovery(prf_salt, &retrier).await } @@ -77,35 +71,6 @@ impl PasskeyAuthenticator { } } - async fn authenticate_stored_only( - &self, - prf_salt: &[u8; 32], - retrier: &PlatformAuthorizationRetrier, - ) -> Result { - // try the known credential first so normal restores do not show an account picker - let stored_outcome = self.authenticate_by_stored_credential(prf_salt, retrier).await?; - match stored_outcome { - StoredPasskeyAuthOutcome::Authenticated(authenticated) => { - Ok(PasskeyAuthOutcome::Authenticated(authenticated)) - } - - StoredPasskeyAuthOutcome::UserCancelled => Ok(PasskeyAuthOutcome::UserCancelled), - - StoredPasskeyAuthOutcome::NoCredentialFound => { - Ok(PasskeyAuthOutcome::NoCredentialFound) - } - - StoredPasskeyAuthOutcome::Failed(error) => { - if matches!(error, PasskeyError::PrfUnsupportedProvider) { - return Err(CloudBackupError::UnsupportedPasskeyProvider); - } - - info!("Stored credential auth failed ({error})"); - Ok(PasskeyAuthOutcome::NoCredentialFound) - } - } - } - async fn authenticate_stored_then_discover( &self, prf_salt: &[u8; 32], @@ -114,24 +79,13 @@ impl PasskeyAuthenticator { // try the known credential first so normal restores do not show an account picker let stored_outcome = self.authenticate_by_stored_credential(prf_salt, retrier).await?; match stored_outcome { - StoredPasskeyAuthOutcome::Authenticated(authenticated) => { - Ok(PasskeyAuthOutcome::Authenticated(authenticated)) + outcome + @ (PasskeyAuthOutcome::Authenticated(_) | PasskeyAuthOutcome::UserCancelled) => { + Ok(outcome) } - StoredPasskeyAuthOutcome::UserCancelled => Ok(PasskeyAuthOutcome::UserCancelled), - // stored-then-discover falls back when the stored credential is missing - StoredPasskeyAuthOutcome::NoCredentialFound => { - info!("Trying discovery after stored credential auth failed"); - self.authenticate_by_discovery(prf_salt, retrier).await - } - - StoredPasskeyAuthOutcome::Failed(error) => { - if matches!(error, PasskeyError::PrfUnsupportedProvider) { - return Err(CloudBackupError::UnsupportedPasskeyProvider); - } - - info!("Stored credential auth failed ({error})"); + PasskeyAuthOutcome::NoCredentialFound => { info!("Trying discovery after stored credential auth failed"); self.authenticate_by_discovery(prf_salt, retrier).await } @@ -142,24 +96,23 @@ impl PasskeyAuthenticator { &self, prf_salt: &[u8; 32], retrier: &PlatformAuthorizationRetrier, - ) -> Result { + ) -> Result { let Some(credential_id) = self.keychain.load_credential_id() else { - return Ok(StoredPasskeyAuthOutcome::NoCredentialFound); + return Ok(PasskeyAuthOutcome::NoCredentialFound); }; let auth_result = retrier.authenticate(&self.passkey, &credential_id, *prf_salt).await; let prf_output = match auth_result { Ok(prf_output) => prf_output, - Err(PasskeyError::UserCancelled) => return Ok(StoredPasskeyAuthOutcome::UserCancelled), - Err(error) => return Ok(StoredPasskeyAuthOutcome::Failed(error)), + Err(error) => return map_authentication_error(error), }; let prf_key: [u8; 32] = prf_output .try_into() .map_err(|_| CloudBackupError::Internal("PRF output is not 32 bytes".into()))?; - Ok(StoredPasskeyAuthOutcome::Authenticated(AuthenticatedPasskey { + Ok(PasskeyAuthOutcome::Authenticated(AuthenticatedPasskey { prf_key, credential_id, credential_recovered: false, @@ -175,7 +128,7 @@ impl PasskeyAuthenticator { let discovered = match discovered_result { Ok(discovered) => discovered, - Err(error) => return map_discovery_error(error), + Err(error) => return map_authentication_error(error), }; let prf_key: [u8; 32] = discovered @@ -208,7 +161,7 @@ impl VerificationSession { } } -fn map_discovery_error(error: PasskeyError) -> Result { +fn map_authentication_error(error: PasskeyError) -> Result { match error { PasskeyError::UserCancelled => Ok(PasskeyAuthOutcome::UserCancelled), PasskeyError::NoCredentialFound => Ok(PasskeyAuthOutcome::NoCredentialFound), @@ -219,36 +172,160 @@ fn map_discovery_error(error: PasskeyError) -> Result( - &self, - wallet_ids: I, - ) -> Result<(), CloudBackupError> - where - I: IntoIterator, - { - let namespace_id = self.current_namespace_id()?; - let changed_at = cove_util::time::unix_timestamp_secs_or_zero(); - - for wallet_id in wallet_ids { - let record_id = wallet_record_id(wallet_id.as_ref()); - let sync_state = PersistedCloudBlobSyncState::wallet( - namespace_id.clone(), - wallet_id, - record_id, - PersistedCloudBlobState::Dirty(CloudBlobDirtyState { changed_at }), - ); - - Database::global().cloud_blob_sync_states.set(&sync_state).map_err(|source| { - CloudBackupError::internal_context("persist dirty cloud backup state", source) - })?; - } - - self.refresh_sync_health(); - - Ok(()) - } - pub(crate) fn handle_wallet_metadata_update( &self, before: &WalletMetadata, diff --git a/rust/src/manager/cloud_backup_manager/wallets/passkey/authorization_retry.rs b/rust/src/manager/cloud_backup_manager/wallets/passkey/authorization_retry.rs index 807d0f1ac..4ce1ead28 100644 --- a/rust/src/manager/cloud_backup_manager/wallets/passkey/authorization_retry.rs +++ b/rust/src/manager/cloud_backup_manager/wallets/passkey/authorization_retry.rs @@ -1,4 +1,6 @@ use std::future::Future; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use backon::{BackoffBuilder as _, ExponentialBuilder, Retryable as _}; @@ -75,19 +77,31 @@ pub(crate) fn is_pre_presentation_platform_authorization_failure(error: &Passkey pub(crate) struct PlatformAuthorizationRetrier { policy: PlatformAuthorizationRetryPolicy, deadline: Instant, + /// Set by the owning operation to stop retries and report the user cancelled + cancellation: Arc, #[cfg(test)] jitter_seed: Option, } impl PlatformAuthorizationRetrier { + /// A retrier nothing can cancel pub(crate) fn new() -> Self { - Self::from_policy(PlatformAuthorizationRetryPolicy::for_current_platform()) + Self::with_cancellation(Arc::default()) } - fn from_policy(policy: PlatformAuthorizationRetryPolicy) -> Self { + /// A retrier that stops with `UserCancelled` once `cancellation` is set + pub(crate) fn with_cancellation(cancellation: Arc) -> Self { + Self::from_policy(PlatformAuthorizationRetryPolicy::for_current_platform(), cancellation) + } + + fn from_policy( + policy: PlatformAuthorizationRetryPolicy, + cancellation: Arc, + ) -> Self { Self { policy, deadline: Instant::now() + policy.config().total_delay, + cancellation, #[cfg(test)] jitter_seed: None, } @@ -95,11 +109,15 @@ impl PlatformAuthorizationRetrier { #[cfg(test)] fn for_test(policy: PlatformAuthorizationRetryPolicy, jitter_seed: u64) -> Self { - let mut retrier = Self::from_policy(policy); + let mut retrier = Self::from_policy(policy, Arc::default()); retrier.jitter_seed = Some(jitter_seed); retrier } + fn cancelled(&self) -> bool { + self.cancellation.load(Ordering::Acquire) + } + fn retry_backoff(&self, total_delay: Duration) -> impl backon::Backoff { let config = self.policy.config(); let mut builder = ExponentialBuilder::default() @@ -121,17 +139,34 @@ impl PlatformAuthorizationRetrier { async fn retry( &self, - operation: Operation, + mut operation: Operation, ) -> Result where Operation: FnMut() -> OperationFuture, OperationFuture: Future>, { + if self.cancelled() { + return Err(PasskeyError::UserCancelled); + } + let available_delay = self.deadline.saturating_duration_since(Instant::now()); let deadline = self.deadline; let policy = self.policy; - operation + // each attempt rechecks cancellation so a retry never presents a new prompt after cancel + let attempt = || { + let attempt = operation(); + + async move { + if self.cancelled() { + return Err(PasskeyError::UserCancelled); + } + + attempt.await + } + }; + + attempt .retry(self.retry_backoff(available_delay)) .when(move |error| policy.retries(error)) .adjust(move |_error, delay| { @@ -144,6 +179,7 @@ impl PlatformAuthorizationRetrier { ); }) .await + .map_err(|error| if self.cancelled() { PasskeyError::UserCancelled } else { error }) } pub(crate) async fn discover( @@ -219,8 +255,7 @@ fn random_challenge() -> Vec { #[cfg(test)] mod tests { - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::AtomicUsize; use super::*; diff --git a/rust/src/manager/cloud_backup_manager/wallets/passkey/namespace_matcher.rs b/rust/src/manager/cloud_backup_manager/wallets/passkey/namespace_matcher.rs index dd659ea50..8798ff8d6 100644 --- a/rust/src/manager/cloud_backup_manager/wallets/passkey/namespace_matcher.rs +++ b/rust/src/manager/cloud_backup_manager/wallets/passkey/namespace_matcher.rs @@ -1,18 +1,25 @@ use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; use cove_cspp::backup_data::{ EncryptedMasterKeyBackup, MASTER_KEY_RECORD_ID, MasterKeyBackupVersion, }; use cove_device::cloud_storage::{CloudBackupUploadStatus, CloudStorageClient, CloudStorageError}; use cove_device::passkey::{PasskeyAccess, PasskeyError}; -use sha2::{Digest as _, Sha256}; +use futures::stream::{self, TryStreamExt as _}; use tracing::{info, warn}; use super::authorization_retry::{ PlatformAuthorizationRetrier, is_pre_presentation_platform_authorization_failure, }; use super::prf_output_to_key; -use crate::manager::cloud_backup_manager::{CloudBackupError, master_key_wrapper_revision_hash}; +use crate::manager::cloud_backup_manager::timing::log_elapsed; +use crate::manager::cloud_backup_manager::{ + CLOUD_BACKUP_IO_CONCURRENCY, CloudBackupError, CloudBackupPasskeyHint, + master_key_wrapper_revision_hash, +}; pub(crate) struct NamespaceMatch { pub(crate) namespace_id: String, @@ -32,9 +39,41 @@ pub(crate) enum NamespaceMatchOutcome { pub(crate) enum NamespaceMatchSnapshotOutcome { Matched(Vec), UserDeclined, + /// The user cancelled after matching one or more namespaces + Cancelled(Vec), + /// The restore operation was cancelled while this snapshot was in flight + OperationCancelled, + /// Targeted authentication failed after the system prompt was shown + /// + /// Matching stops so the user is not prompted again, and the namespaces matched + /// earlier in this snapshot stay restorable. The caller owns the matches from + /// earlier snapshots, so it decides whether the failure is fatal + AuthenticationFailed { + matches: Vec, + error: PasskeyError, + }, Continue, } +impl NamespaceMatchSnapshotOutcome { + /// Ends matching on a terminal authentication failure, which is only an error + /// when no namespace matched before it + pub(crate) fn authentication_failure_result( + matches: Vec, + error: PasskeyError, + ) -> Result, CloudBackupError> { + if matches.is_empty() { + return Err(CloudBackupError::passkey(error)); + } + + warn!( + "Passkey authentication failed after {} namespace match(es), keeping them: {error}", + matches.len() + ); + Ok(matches) + } +} + pub(crate) struct NamespacePasskeyMatcher { cloud: CloudStorageClient, passkey: PasskeyAccess, @@ -54,6 +93,39 @@ struct NamespacePasskeyCandidate { encrypted: EncryptedMasterKeyBackup, } +struct LoadedNamespaceWrapper { + encrypted: EncryptedMasterKeyBackup, + revision: WrapperRevisionDigest, +} + +enum NamespaceWrapperLoad { + Loaded(Box), + Missing, + /// The wrapper could not be downloaded or decoded, already logged where it failed + Failed, +} + +enum NamespaceCandidateLoad { + Uploaded { namespace_id: String, wrapper: NamespaceWrapperLoad }, + Pending, + Missing, + UploadStateFailed(CloudStorageError), +} + +/// Cloud data loaded once for one namespace inspection +pub(crate) struct NamespacePasskeyCandidateSnapshot { + candidates: Vec, + has_supported_candidate: bool, + candidate_outcomes: Vec, + best_passkey_hint: Option, +} + +impl NamespacePasskeyCandidateSnapshot { + pub(crate) fn best_passkey_hint(&self) -> Option { + self.best_passkey_hint.clone() + } +} + enum CredentialSelection { NotAttempted, NoCredentialFound, @@ -86,8 +158,10 @@ pub(crate) struct NamespacePasskeyMatchSession { authorization_retrier: PlatformAuthorizationRetrier, credential_selection: CredentialSelection, attempted_candidates: HashSet, - supported_candidates: HashSet, + saw_supported_candidate: bool, candidate_outcomes: Vec, + /// Never set for sessions that no restore operation can cancel + cancellation: Arc, } impl NamespacePasskeyMatcher { @@ -97,30 +171,78 @@ impl NamespacePasskeyMatcher { } pub(crate) fn start_session(&self) -> NamespacePasskeyMatchSession { + self.start_session_with_cancellation(Arc::default()) + } + + pub(crate) fn start_session_with_cancellation( + &self, + cancellation: Arc, + ) -> NamespacePasskeyMatchSession { NamespacePasskeyMatchSession { cloud: self.cloud.clone(), passkey: self.passkey.clone(), - authorization_retrier: PlatformAuthorizationRetrier::new(), + authorization_retrier: PlatformAuthorizationRetrier::with_cancellation( + cancellation.clone(), + ), credential_selection: CredentialSelection::NotAttempted, attempted_candidates: HashSet::new(), - supported_candidates: HashSet::new(), + saw_supported_candidate: false, candidate_outcomes: Vec::new(), + cancellation, } } + /// Loads cloud wrappers once and derives both display hints and candidates + pub(crate) async fn inspect_namespaces( + &self, + namespaces: &[String], + ) -> Result { + let never_cancelled = AtomicBool::new(false); + load_candidate_snapshot(&self.cloud, namespaces, &never_cancelled).await + } + /// Downloads candidate wrappers and tries the selected passkey against each PRF salt pub(crate) async fn match_namespaces( &self, namespaces: &[String], ) -> Result { + self.match_namespaces_with_hint(namespaces).await.map(|(outcome, _)| outcome) + } + + pub(crate) async fn match_namespaces_with_hint( + &self, + namespaces: &[String], + ) -> Result<(NamespaceMatchOutcome, Option), CloudBackupError> { let mut session = self.start_session(); - match session.match_snapshot(namespaces).await? { + let snapshot = self.inspect_namespaces(namespaces).await?; + let passkey_hint = snapshot.best_passkey_hint(); + let outcome = match session.match_inspection(namespaces.len(), snapshot).await? { NamespaceMatchSnapshotOutcome::Matched(matches) => { - Ok(NamespaceMatchOutcome::Matched(matches)) + NamespaceMatchOutcome::Matched(matches) } - NamespaceMatchSnapshotOutcome::UserDeclined => Ok(NamespaceMatchOutcome::UserDeclined), - NamespaceMatchSnapshotOutcome::Continue => Ok(session.finish()), - } + NamespaceMatchSnapshotOutcome::UserDeclined => NamespaceMatchOutcome::UserDeclined, + NamespaceMatchSnapshotOutcome::Cancelled(matches) => { + NamespaceMatchOutcome::Matched(matches) + } + NamespaceMatchSnapshotOutcome::OperationCancelled => { + NamespaceMatchOutcome::Inconclusive + } + NamespaceMatchSnapshotOutcome::AuthenticationFailed { matches, error } => { + NamespaceMatchOutcome::Matched( + NamespaceMatchSnapshotOutcome::authentication_failure_result(matches, error)?, + ) + } + NamespaceMatchSnapshotOutcome::Continue => session.finish(), + }; + + Ok((outcome, passkey_hint)) + } + + pub(crate) async fn passkey_hint_for_namespaces( + &self, + namespaces: &[String], + ) -> Option { + self.inspect_namespaces(namespaces).await.ok()?.best_passkey_hint() } } @@ -130,14 +252,39 @@ impl NamespacePasskeyMatchSession { } pub(crate) fn saw_supported_candidate(&self) -> bool { - !self.supported_candidates.is_empty() + self.saw_supported_candidate } pub(crate) async fn match_snapshot( &mut self, namespaces: &[String], ) -> Result { - let mut candidates = self.download_candidates(namespaces).await?; + if cancellation_requested(&self.cancellation) { + return Ok(NamespaceMatchSnapshotOutcome::OperationCancelled); + } + + let snapshot_result = + load_candidate_snapshot(&self.cloud, namespaces, &self.cancellation).await; + if cancellation_requested(&self.cancellation) { + return Ok(NamespaceMatchSnapshotOutcome::OperationCancelled); + } + let snapshot = snapshot_result?; + + self.match_inspection(namespaces.len(), snapshot).await + } + + async fn match_inspection( + &mut self, + namespace_count: usize, + snapshot: NamespacePasskeyCandidateSnapshot, + ) -> Result { + if cancellation_requested(&self.cancellation) { + return Ok(NamespaceMatchSnapshotOutcome::OperationCancelled); + } + self.candidate_outcomes.extend(snapshot.candidate_outcomes); + self.saw_supported_candidate |= snapshot.has_supported_candidate; + + let mut candidates = snapshot.candidates; candidates.sort_by(|left, right| { right .registration_timestamp() @@ -151,7 +298,7 @@ impl NamespacePasskeyMatchSession { .count(); info!( "Passkey candidate refresh namespace_count={} usable_count={} new_or_changed_count={} attempted_count={}", - namespaces.len(), + namespace_count, candidates.len(), new_candidate_count, self.attempted_candidates.len() @@ -159,23 +306,36 @@ impl NamespacePasskeyMatchSession { let mut matches = Vec::new(); for candidate in candidates { + if cancellation_requested(&self.cancellation) { + return Ok(NamespaceMatchSnapshotOutcome::OperationCancelled); + } if self.attempted_candidates.contains(&candidate.identity) { continue; } let (credential_id, prf_output) = match &self.credential_selection { CredentialSelection::Selected(credential_id) => { - let auth = self - .authorization_retrier - .authenticate(&self.passkey, credential_id, candidate.encrypted.prf_salt) - .await; + let auth = log_elapsed( + "Passkey targeted authentication", + self.authorization_retrier.authenticate( + &self.passkey, + credential_id, + candidate.encrypted.prf_salt, + ), + ) + .await; + + if cancellation_requested(&self.cancellation) { + return Ok(NamespaceMatchSnapshotOutcome::OperationCancelled); + } + let prf_output = match auth { Ok(prf_output) => prf_output, Err(PasskeyError::UserCancelled) => { return Ok(if matches.is_empty() { NamespaceMatchSnapshotOutcome::UserDeclined } else { - NamespaceMatchSnapshotOutcome::Matched(matches) + NamespaceMatchSnapshotOutcome::Cancelled(matches) }); } Err(PasskeyError::PrfUnsupportedProvider) => { @@ -187,6 +347,10 @@ impl NamespacePasskeyMatchSession { ); if !is_pre_presentation_platform_authorization_failure(&error) { self.attempted_candidates.insert(candidate.identity.clone()); + return Ok(NamespaceMatchSnapshotOutcome::AuthenticationFailed { + matches, + error, + }); } self.candidate_outcomes.push(NamespaceCandidateOutcome::Inconclusive); continue; @@ -196,13 +360,27 @@ impl NamespacePasskeyMatchSession { (credential_id.clone(), prf_output) } CredentialSelection::NotAttempted => { - let discovery = self - .authorization_retrier - .discover(&self.passkey, candidate.encrypted.prf_salt) - .await; + let started_at = Instant::now(); + let discovery = log_elapsed( + "Passkey discovery authentication", + self.authorization_retrier + .discover(&self.passkey, candidate.encrypted.prf_salt), + ) + .await; + + if cancellation_requested(&self.cancellation) { + return Ok(NamespaceMatchSnapshotOutcome::OperationCancelled); + } + let discovered = match discovery { Ok(discovered) => discovered, Err(PasskeyError::UserCancelled) => { + // the platform reports a cancellation and an authenticator failure + // the same way, so record what the attempt looked like + info!( + "Passkey discovery cancelled elapsed_ms={} underlying_error=none", + started_at.elapsed().as_millis() + ); return Ok(NamespaceMatchSnapshotOutcome::UserDeclined); } Err(PasskeyError::NoCredentialFound) => { @@ -218,10 +396,7 @@ impl NamespacePasskeyMatchSession { Err(error) => return Err(CloudBackupError::passkey(error)), }; - info!( - "Passkey discovery selected credential fingerprint={}", - credential_id_fingerprint(&discovered.credential_id) - ); + info!("Passkey discovery selected a credential"); self.credential_selection = CredentialSelection::Selected(discovered.credential_id.clone()); @@ -233,6 +408,10 @@ impl NamespacePasskeyMatchSession { } }; + if cancellation_requested(&self.cancellation) { + return Ok(NamespaceMatchSnapshotOutcome::OperationCancelled); + } + self.attempted_candidates.insert(candidate.identity.clone()); let prf_key = prf_output_to_key(prf_output)?; if let Ok(master_key) = @@ -266,7 +445,7 @@ impl NamespacePasskeyMatchSession { if self.candidate_outcomes.contains(&NamespaceCandidateOutcome::Inconclusive) { return NamespaceMatchOutcome::Inconclusive; } - if self.supported_candidates.is_empty() + if !self.saw_supported_candidate && self.candidate_outcomes.contains(&NamespaceCandidateOutcome::UnsupportedVersion) { return NamespaceMatchOutcome::UnsupportedVersions; @@ -274,100 +453,190 @@ impl NamespacePasskeyMatchSession { NamespaceMatchOutcome::NoMatch } +} - async fn download_candidates( - &mut self, - namespaces: &[String], - ) -> Result, CloudBackupError> { - let mut seen_namespaces = HashSet::new(); - let mut candidates = Vec::with_capacity(namespaces.len()); +async fn load_candidate_snapshot( + cloud: &CloudStorageClient, + namespaces: &[String], + cancellation: &AtomicBool, +) -> Result { + let mut seen_namespaces = HashSet::new(); + let unique_namespaces = namespaces + .iter() + .filter(|namespace| seen_namespaces.insert((*namespace).clone())) + .cloned() + .collect::>(); + let mut loads = + stream::iter(unique_namespaces.into_iter().enumerate().map(|(index, namespace_id)| { + let cloud = cloud.clone(); + + Ok(async move { + Ok::<_, CloudBackupError>(( + index, + load_namespace_candidate(&cloud, namespace_id, cancellation).await?, + )) + }) + })) + .try_buffer_unordered(CLOUD_BACKUP_IO_CONCURRENCY) + .try_collect::>() + .await?; + loads.sort_by_key(|(index, _)| *index); + + let mut snapshot = NamespacePasskeyCandidateSnapshot { + candidates: Vec::with_capacity(loads.len()), + has_supported_candidate: false, + candidate_outcomes: Vec::new(), + best_passkey_hint: None, + }; + + for (_, load) in loads { + snapshot.add_load(load); + } - for namespace in namespaces { - if !seen_namespaces.insert(namespace) { - continue; + Ok(snapshot) +} + +async fn load_namespace_candidate( + cloud: &CloudStorageClient, + namespace_id: String, + cancellation: &AtomicBool, +) -> Result { + if cancellation_requested(cancellation) { + return Err(CloudBackupError::Cancelled); + } + + let upload_state = log_elapsed( + "Passkey candidate upload-state read", + cloud.is_backup_uploaded(namespace_id.clone(), MASTER_KEY_RECORD_ID.to_string()), + ) + .await; + if cancellation_requested(cancellation) { + return Err(CloudBackupError::Cancelled); + } + + let upload_state = match upload_state { + Ok(upload_state) => upload_state, + Err(error @ CloudStorageError::AuthorizationRequired(_)) => return Err(error.into()), + Err(error) => return Ok(NamespaceCandidateLoad::UploadStateFailed(error)), + }; + + match upload_state { + CloudBackupUploadStatus::Pending => Ok(NamespaceCandidateLoad::Pending), + CloudBackupUploadStatus::NotFound => Ok(NamespaceCandidateLoad::Missing), + CloudBackupUploadStatus::Uploaded => { + if cancellation_requested(cancellation) { + return Err(CloudBackupError::Cancelled); } - match self - .cloud - .is_backup_uploaded(namespace.clone(), MASTER_KEY_RECORD_ID.to_string()) - .await - { - Ok(CloudBackupUploadStatus::Uploaded) => { - info!("Passkey candidate wrapper upload_state=uploaded") - } - Ok(CloudBackupUploadStatus::Pending) => { - info!("Passkey candidate wrapper upload_state=pending"); - self.candidate_outcomes.push(NamespaceCandidateOutcome::PendingUpload); - continue; - } - Ok(CloudBackupUploadStatus::NotFound) => { - info!("Ignoring stale cloud backup namespace with no master key wrapper"); - self.candidate_outcomes.push(NamespaceCandidateOutcome::Missing); - continue; - } - Err(error @ CloudStorageError::AuthorizationRequired(_)) => { - return Err(error.into()); - } - Err(error) => { - warn!("Failed to read passkey candidate upload state: {error}"); - self.candidate_outcomes.push(NamespaceCandidateOutcome::Inconclusive); - continue; - } + let wrapper = log_elapsed( + "Passkey candidate wrapper read", + cloud.download_master_key_backup(namespace_id.clone()), + ) + .await; + if cancellation_requested(cancellation) { + return Err(CloudBackupError::Cancelled); } - let master_json = match self.cloud.download_master_key_backup(namespace.clone()).await { - Ok(master_json) => master_json, - Err(CloudStorageError::NotFound(_)) => { - info!("Ignoring stale cloud backup namespace with no master key wrapper"); - self.candidate_outcomes.push(NamespaceCandidateOutcome::Missing); - continue; + let wrapper = match wrapper { + Ok(master_json) => { + match serde_json::from_slice::(&master_json) { + Ok(encrypted) => { + NamespaceWrapperLoad::Loaded(Box::new(LoadedNamespaceWrapper { + encrypted, + revision: WrapperRevisionDigest(master_key_wrapper_revision_hash( + &master_json, + )), + })) + } + Err(error) => { + warn!("Failed to deserialize cloud backup master key: {error}"); + NamespaceWrapperLoad::Failed + } + } } + Err(CloudStorageError::NotFound(_)) => NamespaceWrapperLoad::Missing, Err(error @ CloudStorageError::AuthorizationRequired(_)) => { return Err(error.into()); } Err(error) => { warn!("Failed to download cloud backup master key: {error}"); - self.candidate_outcomes.push(NamespaceCandidateOutcome::Inconclusive); - continue; + NamespaceWrapperLoad::Failed } }; - let encrypted = match serde_json::from_slice::(&master_json) { - Ok(encrypted) => encrypted, - Err(error) => { - warn!("Failed to deserialize cloud backup master key: {error}"); - self.candidate_outcomes.push(NamespaceCandidateOutcome::Inconclusive); - continue; - } - }; + Ok(NamespaceCandidateLoad::Uploaded { namespace_id, wrapper }) + } + } +} - match encrypted.backup_version() { - Ok(MasterKeyBackupVersion::V1) => {} - Err(_) => { - self.candidate_outcomes.push(NamespaceCandidateOutcome::UnsupportedVersion); - continue; - } +fn cancellation_requested(cancellation: &AtomicBool) -> bool { + cancellation.load(Ordering::Acquire) +} + +impl NamespacePasskeyCandidateSnapshot { + fn add_load(&mut self, load: NamespaceCandidateLoad) { + match load { + NamespaceCandidateLoad::Uploaded { namespace_id, wrapper } => { + info!("Passkey candidate wrapper upload_state=uploaded"); + self.add_uploaded_candidate(namespace_id, wrapper); + } + NamespaceCandidateLoad::Pending => { + info!("Passkey candidate wrapper upload_state=pending"); + self.candidate_outcomes.push(NamespaceCandidateOutcome::PendingUpload); } - if encrypted.remote_metadata.normalized_master_key(namespace).is_err() { + NamespaceCandidateLoad::Missing => { + info!("Ignoring stale cloud backup namespace with no master key wrapper"); + self.candidate_outcomes.push(NamespaceCandidateOutcome::Missing); + } + NamespaceCandidateLoad::UploadStateFailed(error) => { + warn!("Failed to read passkey candidate upload state: {error}"); self.candidate_outcomes.push(NamespaceCandidateOutcome::Inconclusive); - continue; } - - let identity = CandidateRevisionIdentity { - namespace_id: namespace.clone(), - wrapper_revision: WrapperRevisionDigest(master_key_wrapper_revision_hash( - &master_json, - )), - }; - self.supported_candidates.insert(identity.clone()); - candidates.push(NamespacePasskeyCandidate { identity, encrypted }); } + } - Ok(candidates) + fn add_passkey_hint(&mut self, encrypted: &EncryptedMasterKeyBackup, namespace_id: &str) { + let Ok(Some(hint)) = + CloudBackupPasskeyHint::from_master_key_wrapper(encrypted, namespace_id) + else { + return; + }; + + if self.best_passkey_hint.as_ref().is_none_or(|current| hint.is_newer_than(current)) { + self.best_passkey_hint = Some(hint); + } } -} -fn credential_id_fingerprint(credential_id: &[u8]) -> String { - let digest = Sha256::digest(credential_id); - format!("{} len={}", hex::encode(&digest[..6]), credential_id.len()) + fn add_uploaded_candidate(&mut self, namespace_id: String, wrapper: NamespaceWrapperLoad) { + let loaded_wrapper = match wrapper { + NamespaceWrapperLoad::Loaded(wrapper) => { + // unsupported wrapper versions still name the passkey that created them + self.add_passkey_hint(&wrapper.encrypted, &namespace_id); + wrapper + } + NamespaceWrapperLoad::Missing => { + info!("Ignoring stale cloud backup namespace with no master key wrapper"); + self.candidate_outcomes.push(NamespaceCandidateOutcome::Missing); + return; + } + NamespaceWrapperLoad::Failed => { + self.candidate_outcomes.push(NamespaceCandidateOutcome::Inconclusive); + return; + } + }; + + let LoadedNamespaceWrapper { encrypted, revision } = *loaded_wrapper; + if !matches!(encrypted.backup_version(), Ok(MasterKeyBackupVersion::V1)) { + self.candidate_outcomes.push(NamespaceCandidateOutcome::UnsupportedVersion); + return; + } + if encrypted.remote_metadata.normalized_master_key(&namespace_id).is_err() { + self.candidate_outcomes.push(NamespaceCandidateOutcome::Inconclusive); + return; + } + + let identity = CandidateRevisionIdentity { namespace_id, wrapper_revision: revision }; + self.has_supported_candidate = true; + self.candidates.push(NamespacePasskeyCandidate { identity, encrypted }); + } } diff --git a/rust/src/manager/cloud_backup_manager/wallets/restore.rs b/rust/src/manager/cloud_backup_manager/wallets/restore.rs index 152e06cfe..1a73c8f80 100644 --- a/rust/src/manager/cloud_backup_manager/wallets/restore.rs +++ b/rust/src/manager/cloud_backup_manager/wallets/restore.rs @@ -10,7 +10,9 @@ use zeroize::Zeroizing; use super::payload::{convert_cloud_secret, descriptor_pair_from_cloud}; use super::{DownloadedWalletBackup, RemoteWalletBackupSummary, decode_cloud_labels_jsonl}; -use crate::backup::import::{LabelRestoreBehavior, LabelRestoreWarning, restore_wallet_labels}; +use crate::backup::import::{ + CloudRestoreError, LabelRestoreBehavior, LabelRestoreWarning, restore_wallet_labels, +}; use crate::backup::model::{WalletBackup, WalletSecret}; use crate::manager::cloud_backup_manager::error::CloudBackupInternalError; use crate::manager::cloud_backup_manager::{CloudBackupError, LocalWalletSecret}; @@ -223,6 +225,18 @@ impl WalletRestoreSession { } } +/// Keep a local-data conflict typed so the reader learns their data was kept unchanged +fn cloud_restore_error(context: &str, error: CloudRestoreError) -> CloudBackupError { + match error { + CloudRestoreError::LocalConflict(conflict) => { + CloudBackupError::LocalWalletConflict(conflict) + } + CloudRestoreError::Backup(error) => { + CloudBackupError::Internal(CloudBackupInternalError::context(context, error)) + } + } +} + impl DownloadedWalletBackup { fn duplicate_key(&self) -> Result { let backup_model = WalletBackup { @@ -258,8 +272,7 @@ impl DownloadedWalletBackup { })?; crate::backup::import::restore_cloud_mnemonic_wallet(&self.metadata, mnemonic) - .map_err(|(error, _)| error) - .map_err_prefix("restore mnemonic wallet", CloudBackupInternalError::from)?; + .map_err(|error| cloud_restore_error("restore mnemonic wallet", error))?; } LocalWalletSecret::Xprv(value) => { let xpriv = WalletXprv::parse(value.as_str()).map_err_prefix( @@ -267,20 +280,16 @@ impl DownloadedWalletBackup { CloudBackupInternalError::from, )?; - crate::backup::import::restore_cloud_xpriv_wallet(&self.metadata, xpriv) - .map_err(|(error, _)| error) - .map_err_prefix( - "restore extended-private-key wallet", - CloudBackupInternalError::from, - )?; + crate::backup::import::restore_cloud_xpriv_wallet(&self.metadata, xpriv).map_err( + |error| cloud_restore_error("restore extended-private-key wallet", error), + )?; } _ => { crate::backup::import::restore_cloud_descriptor_wallet( &self.metadata, &backup_model, ) - .map_err(|(error, _)| error) - .map_err_prefix("restore descriptor wallet", CloudBackupInternalError::from)?; + .map_err(|error| cloud_restore_error("restore descriptor wallet", error))?; } } diff --git a/rust/src/manager/cloud_backup_manager/wallets/upload.rs b/rust/src/manager/cloud_backup_manager/wallets/upload.rs index a504cc2dc..e86b8f7e0 100644 --- a/rust/src/manager/cloud_backup_manager/wallets/upload.rs +++ b/rust/src/manager/cloud_backup_manager/wallets/upload.rs @@ -606,6 +606,7 @@ impl RustCloudBackupManager { | CloudBackupError::PasskeyMismatch | CloudBackupError::NoBackupFound | CloudBackupError::PasskeyDiscoveryCancelled + | CloudBackupError::LocalWalletConflict(_) | CloudBackupError::Cancelled => false, } } diff --git a/rust/src/manager/key_teleport_manager.rs b/rust/src/manager/key_teleport_manager.rs index 79a720a88..4d397d920 100644 --- a/rust/src/manager/key_teleport_manager.rs +++ b/rust/src/manager/key_teleport_manager.rs @@ -26,6 +26,14 @@ use receive::ReceiveWorkflow; use receive_session::ReceiveSessionStore; pub(crate) use send::is_send_eligible_wallet_id; +/// Deletes the persisted receive session and its private key under the session store lock +/// +/// Deleting outside the lock would let a concurrent load that upgrades a legacy session write +/// the old private key back after the delete +pub(crate) fn delete_persisted_receive_session() -> Result<(), KeyTeleportAlert> { + ReceiveSessionStore.delete() +} + type Message = KeyTeleportManagerReconcileMessage; type Action = KeyTeleportManagerAction; type Reconciler = dyn KeyTeleportManagerReconciler; diff --git a/rust/src/manager/onboarding_manager.rs b/rust/src/manager/onboarding_manager.rs index 0557801fb..3bee69a66 100644 --- a/rust/src/manager/onboarding_manager.rs +++ b/rust/src/manager/onboarding_manager.rs @@ -1,4 +1,4 @@ -use std::{fmt, sync::Arc, time::Duration}; +use std::{fmt, sync::Arc}; use cove_device::cloud_storage::{CloudStorage, CloudStorageError}; use cove_util::ResultExt as _; @@ -526,28 +526,6 @@ impl RustOnboardingManager { Self::apply_restore_event(&state, &reconciler, internal_event); } }); - - let state = self.state.clone(); - let reconciler = self.reconciler.sender().clone(); - cove_tokio::task::spawn(async move { - tokio::time::sleep(Duration::from_secs(120)).await; - - if !Self::is_restore_attempt_current(&state, attempt_id) { - return; - } - - CLOUD_BACKUP_MANAGER.cancel_restore_and_wait().await; - - Self::apply_restore_event( - &state, - &reconciler, - InternalEvent::RestoreFailed { attempt_id, message: "Restore timed out".into() }, - ); - }); - } - - fn is_restore_attempt_current(state: &Arc>, attempt_id: u64) -> bool { - state.read().flow.is_restore_attempt_current(attempt_id) } fn apply_restore_event( @@ -1650,30 +1628,6 @@ mod tests { assert!(matches!(flow, FlowState::Welcome { error_message: None })); } - #[test] - fn timeout_failure_enters_restore_failed_with_timeout_message() { - let mut flow = FlowState::Restoring { - origin: RestoreOrigin::Welcome, - attempt_id: 3, - flow: CloudBackupRestoreFlow::Finding, - }; - let mut discovery = CloudRestoreDiscovery::BackupFound(None); - - flow.apply_event( - InternalEvent::RestoreFailed { attempt_id: 3, message: "Restore timed out".into() }, - &mut discovery, - true, - ); - - assert!(matches!( - flow, - FlowState::RestoreFailed { - origin: RestoreOrigin::Welcome, - message, - } if message == "Restore timed out" - )); - } - #[test] fn explicit_restore_without_backup_goes_to_restore_unavailable() { let mut flow = FlowState::StorageChoice { error_message: None }; diff --git a/rust/src/manager/wallet_manager/payjoin.rs b/rust/src/manager/wallet_manager/payjoin.rs index 8dbda1ab4..fe4f0732f 100644 --- a/rust/src/manager/wallet_manager/payjoin.rs +++ b/rust/src/manager/wallet_manager/payjoin.rs @@ -963,7 +963,18 @@ mod tests { ScriptBuf, Transaction, TxOut, psbt::Output as PsbtOutput, transaction::Version, }; - fn new_test_persister() -> (PayjoinSessionPersister, WalletDataDb, tempfile::TempDir) { + // in-memory storage skips the process-global lifecycle coordinator, so these + // persister tests cannot race with wipe-phase tests in the same cargo test process + fn new_test_persister() -> (PayjoinSessionPersister, WalletDataDb) { + let db = WalletDataDb::new_in_memory(WalletId::preview_new_random()) + .expect("in-memory payjoin test db"); + (PayjoinSessionPersister::new(db.clone()), db) + } + + // persistent storage goes through begin_persistence_operation; callers must + // hold global_state_test_lock so wipe-phase tests cannot observe CoordinatorBusy + fn new_persistent_test_persister() -> (PayjoinSessionPersister, WalletDataDb, tempfile::TempDir) + { let (db, tmp) = test_support::new_test_wallet_data_db(WalletId::preview_new_random()); (PayjoinSessionPersister::new(db.clone()), db, tmp) } @@ -1025,7 +1036,7 @@ mod tests { let _guard = crate::test_support::global_state_test_lock().lock().await; crate::test_support::ensure_tokio_runtime(); - let (persister, db, _tmp) = new_test_persister(); + let (persister, db, _tmp) = new_persistent_test_persister(); persister.create_session(&test_fallback_tx()).expect("payjoin session is persisted"); let wallet_id = db.id.clone(); let actor = cove_tokio::task::spawn_actor(terminal_test_actor(persister)); @@ -1059,7 +1070,7 @@ mod tests { let _guard = crate::test_support::global_state_test_lock().lock().await; crate::test_support::ensure_tokio_runtime(); - let (persister, db, _tmp) = new_test_persister(); + let (persister, db, _tmp) = new_persistent_test_persister(); let fallback = test_fallback_tx(); let proposal = BdkTransaction { version: Version::TWO, @@ -1094,7 +1105,7 @@ mod tests { let _guard = crate::test_support::global_state_test_lock().lock().await; crate::test_support::ensure_tokio_runtime(); - let (persister, db, _tmp) = new_test_persister(); + let (persister, db, _tmp) = new_persistent_test_persister(); let actor = cove_tokio::task::spawn_actor(terminal_test_actor(persister.clone())); let (authority, preparation) = crate::wallet_lifecycle::test_support::begin_wallet_deletion(db.id.clone()); @@ -1117,7 +1128,7 @@ mod tests { #[test] fn test_close_session_is_idempotent() { - let (persister, _db, _tmp) = new_test_persister(); + let (persister, _db) = new_test_persister(); let mut actor = PayjoinActor { addr: WeakAddr::default(), wallet_addr: WeakAddr::default(), @@ -1188,7 +1199,7 @@ mod tests { #[test] fn save_event_requires_a_session_record() { - let (persister, _db, _tmp) = new_test_persister(); + let (persister, _db) = new_test_persister(); let result = persister.save_event(PayjoinSessionEvent::PostedOriginalPsbt()); @@ -1197,7 +1208,7 @@ mod tests { #[test] fn events_round_trip_in_order() { - let (persister, _db, _tmp) = new_test_persister(); + let (persister, _db) = new_test_persister(); persister.create_session(&test_fallback_tx()).unwrap(); persister.save_event(PayjoinSessionEvent::PostedOriginalPsbt()).unwrap(); @@ -1215,7 +1226,7 @@ mod tests { #[test] fn create_session_rejects_when_session_exists() { - let (persister, _db, _tmp) = new_test_persister(); + let (persister, _db) = new_test_persister(); persister.create_session(&test_fallback_tx()).unwrap(); persister.save_event(PayjoinSessionEvent::PostedOriginalPsbt()).unwrap(); @@ -1227,7 +1238,7 @@ mod tests { #[test] fn close_keeps_the_session_record() { - let (persister, _db, _tmp) = new_test_persister(); + let (persister, _db) = new_test_persister(); persister.create_session(&test_fallback_tx()).unwrap(); persister.save_event(PayjoinSessionEvent::PostedOriginalPsbt()).unwrap(); @@ -1238,7 +1249,7 @@ mod tests { #[test] fn resume_with_no_record_is_none() { - let (_persister, db, _tmp) = new_test_persister(); + let (_persister, db) = new_test_persister(); let resumption = resume_session(db, WeakAddr::default()); @@ -1247,7 +1258,7 @@ mod tests { #[test] fn resume_with_unreplayable_log_broadcasts_stored_fallback() { - let (persister, db, _tmp) = new_test_persister(); + let (persister, db) = new_test_persister(); let tx = test_fallback_tx(); persister.create_session(&tx).unwrap(); // a log that does not start with a Created event cannot be replayed @@ -1263,7 +1274,7 @@ mod tests { #[test] fn save_event_rejected_after_set_pending_fallback() { - let (persister, _db, _tmp) = new_test_persister(); + let (persister, _db) = new_test_persister(); persister.create_session(&test_fallback_tx()).unwrap(); persister.set_pending_fallback().unwrap(); @@ -1274,7 +1285,7 @@ mod tests { #[test] fn resume_prioritises_pending_fallback_over_event_log() { - let (_persister, db, _tmp) = new_test_persister(); + let (_persister, db) = new_test_persister(); let tx = test_fallback_tx(); let session = PayjoinSenderSession { events: vec!["irrelevant_event".to_string()], @@ -1294,7 +1305,7 @@ mod tests { #[test] fn set_pending_fallback_rejects_overwrite_of_proposal() { - let (persister, _db, _tmp) = new_test_persister(); + let (persister, _db) = new_test_persister(); let tx = empty_transaction(); persister.create_session(&test_fallback_tx()).unwrap(); persister.set_pending_proposal(&tx).unwrap(); @@ -1306,7 +1317,7 @@ mod tests { #[test] fn set_pending_proposal_rejects_overwrite_with_different_tx() { - let (persister, _db, _tmp) = new_test_persister(); + let (persister, _db) = new_test_persister(); let tx_a = empty_transaction(); let mut tx_b = empty_transaction(); tx_b.version = Version::ONE; @@ -1320,7 +1331,7 @@ mod tests { #[test] fn set_pending_proposal_is_idempotent() { - let (persister, db, _tmp) = new_test_persister(); + let (persister, db) = new_test_persister(); let tx = empty_transaction(); persister.create_session(&test_fallback_tx()).unwrap(); persister.set_pending_proposal(&tx).unwrap(); @@ -1337,7 +1348,7 @@ mod tests { #[test] fn resume_with_proposal_marker_returns_broadcast_stored_proposal() { - let (_persister, db, _tmp) = new_test_persister(); + let (_persister, db) = new_test_persister(); let tx = empty_transaction(); let session = PayjoinSenderSession { events: vec![], @@ -1361,7 +1372,7 @@ mod tests { #[test] fn resume_with_corrupt_proposal_retains_record_and_reports_error() { - let (_persister, db, _tmp) = new_test_persister(); + let (_persister, db) = new_test_persister(); let session = PayjoinSenderSession { events: vec![], fallback_tx: consensus::serialize(&test_fallback_tx()).into(), @@ -1387,7 +1398,7 @@ mod tests { #[test] fn resume_with_corrupt_fallback_clears_the_record() { - let (_persister, db, _tmp) = new_test_persister(); + let (_persister, db) = new_test_persister(); let session = PayjoinSenderSession { events: vec![], fallback_tx: vec![0xff].into(), @@ -1411,7 +1422,7 @@ mod tests { #[test] fn resume_with_corrupt_committed_fallback_retains_the_record() { - let (_persister, db, _tmp) = new_test_persister(); + let (_persister, db) = new_test_persister(); let session = PayjoinSenderSession { events: vec![], fallback_tx: vec![0xff].into(), diff --git a/rust/src/manager/wallet_manager/wallet_admin.rs b/rust/src/manager/wallet_manager/wallet_admin.rs index da9c5e5e3..c3f1e80b2 100644 --- a/rust/src/manager/wallet_manager/wallet_admin.rs +++ b/rust/src/manager/wallet_manager/wallet_admin.rs @@ -10,8 +10,6 @@ use crate::{ }; use act_zero::call; use cove_util::result_ext::ResultExt as _; -use tap::TapFallible as _; -use tracing::error; use super::{Error, RustWalletManager}; @@ -54,17 +52,7 @@ impl RustWalletManager { fn finish_delete_wallet(&self, wallet_id: crate::wallet::metadata::WalletId) { let database = Database::global(); - Updater::send_update(Update::ClearCachedWalletManager(wallet_id.clone())); - - // unselect the wallet in the database - match database.global_config.selected_wallet() { - Some(selected_wallet_id) if selected_wallet_id == wallet_id => { - let _ = database.global_config.clear_selected_wallet().tap_err(|error| { - error!("Unable to clear selected wallet: {error}"); - }); - } - _ => (), - } + Updater::send_update(Update::ClearCachedWalletManager(wallet_id)); // check if other wallets exist and select the first one, or go to new wallet flow let remaining_wallets = database.wallets().all().unwrap_or_default(); diff --git a/rust/src/test_support.rs b/rust/src/test_support.rs index c6f1a583e..865e14800 100644 --- a/rust/src/test_support.rs +++ b/rust/src/test_support.rs @@ -1,11 +1,45 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; +use std::str::FromStr as _; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; use cove_device::keychain::{Keychain, KeychainAccess, KeychainError}; use parking_lot::Mutex; +use crate::wallet::fingerprint::Fingerprint; +use crate::wallet::metadata::{WalletId, WalletMetadata, WalletType}; + static FAIL_KEYCHAIN_DELETES: AtomicBool = AtomicBool::new(false); +type AfterSaveHook = Arc; + +pub(crate) const WALLET_MNEMONIC_KEY_SUFFIX: &str = "::wallet_mnemonic"; +pub(crate) const WALLET_MNEMONIC_CRYPTOR_KEY_SUFFIX: &str = + "::wallet_mnemonic_encryption_key_and_nonce"; +pub(crate) const WALLET_XPUB_KEY_SUFFIX: &str = "::wallet_xpub"; + +pub(crate) const WALLET_KEYCHAIN_KEY_SUFFIXES: [&str; 6] = [ + WALLET_MNEMONIC_KEY_SUFFIX, + WALLET_MNEMONIC_CRYPTOR_KEY_SUFFIX, + WALLET_XPUB_KEY_SUFFIX, + "::wallet_public_descriptor", + "::tap_signer_backup", + "::wallet_tap_signer_encryption_key_and_nonce_key_name", +]; + +/// The raw stored wallet keychain values keyed by suffix +/// +/// Comparing raw values makes a rewrite of an adopted item visible even when the +/// decrypted value would still compare equal +pub(crate) fn raw_wallet_keychain_entries( + wallet_id: &WalletId, +) -> BTreeMap<&'static str, Option> { + let keychain = shared_mock_keychain(); + + WALLET_KEYCHAIN_KEY_SUFFIXES + .iter() + .map(|suffix| (*suffix, keychain.get_entry(&format!("{wallet_id}{suffix}")))) + .collect() +} /// In-memory keychain shared by every test module /// @@ -13,13 +47,26 @@ static FAIL_KEYCHAIN_DELETES: AtomicBool = AtomicBool::new(false); /// module's init wins that race is nondeterministic. Sharing one clonable /// instance keeps entry inspection and failure injection working no matter /// which module installs it -#[derive(Debug, Default, Clone)] +#[derive(Default, Clone)] pub(crate) struct MockKeychain { entries: Arc>>, fail_save_at: Arc>>, fail_delete_at: Arc>>, save_count: Arc>, delete_count: Arc>, + after_save: Arc>>, +} + +impl std::fmt::Debug for MockKeychain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MockKeychain") + .field("entries", &self.entries) + .field("fail_save_at", &self.fail_save_at) + .field("fail_delete_at", &self.fail_delete_at) + .field("save_count", &self.save_count) + .field("delete_count", &self.delete_count) + .finish_non_exhaustive() + } } impl MockKeychain { @@ -29,6 +76,7 @@ impl MockKeychain { *self.fail_delete_at.lock() = None; *self.save_count.lock() = 0; *self.delete_count.lock() = 0; + *self.after_save.lock() = None; } pub(crate) fn set_entries(&self, entries: Vec<(&str, &str)>) { @@ -49,17 +97,29 @@ impl MockKeychain { *self.delete_count.lock() = 0; *self.fail_delete_at.lock() = Some(delete_attempt); } + + pub(crate) fn set_after_save(&self, hook: impl Fn(&str) + Send + Sync + 'static) { + *self.after_save.lock() = Some(Arc::new(hook)); + } } impl KeychainAccess for MockKeychain { fn save(&self, key: String, value: String) -> Result<(), KeychainError> { - let mut save_count = self.save_count.lock(); - *save_count += 1; - if Some(*save_count) == *self.fail_save_at.lock() { - return Err(KeychainError::Save); + { + let mut save_count = self.save_count.lock(); + *save_count += 1; + if Some(*save_count) == *self.fail_save_at.lock() { + return Err(KeychainError::Save); + } + } + + self.entries.lock().insert(key.clone(), value); + + let hook = self.after_save.lock().clone(); + if let Some(hook) = hook { + hook(&key); } - self.entries.lock().insert(key, value); Ok(()) } @@ -82,19 +142,24 @@ impl KeychainAccess for MockKeychain { } fn delete_all_wallet_items(&self) -> Result<(), KeychainError> { - let suffixes = [ - "::wallet_mnemonic", - "::wallet_mnemonic_encryption_key_and_nonce", - "::wallet_xpub", - "::wallet_public_descriptor", - "::tap_signer_backup", - "::wallet_tap_signer_encryption_key_and_nonce_key_name", - ]; - self.entries.lock().retain(|key, _| !suffixes.iter().any(|suffix| key.ends_with(suffix))); + self.entries.lock().retain(|key, _| { + !WALLET_KEYCHAIN_KEY_SUFFIXES.iter().any(|suffix| key.ends_with(suffix)) + }); Ok(()) } } +pub(crate) fn hot_wallet_metadata(name: &str) -> WalletMetadata { + let mut metadata = WalletMetadata::preview_new(); + metadata.name = name.to_string(); + metadata.wallet_type = WalletType::Hot; + metadata.master_fingerprint = Some(Arc::new(Fingerprint::from( + bdk_wallet::bitcoin::bip32::Fingerprint::from_str("817e7be0").unwrap(), + ))); + + metadata +} + /// The single [`MockKeychain`] instance behind the process-global keychain pub(crate) fn shared_mock_keychain() -> &'static MockKeychain { static KEYCHAIN: OnceLock = OnceLock::new(); diff --git a/rust/src/wallet/deletion.rs b/rust/src/wallet/deletion.rs index 264790143..2a9894eff 100644 --- a/rust/src/wallet/deletion.rs +++ b/rust/src/wallet/deletion.rs @@ -95,6 +95,10 @@ impl PreparedFullWipe { self.recovery_cleanup.delete_all_wallet_items() } + pub(crate) fn delete_key_teleport_receive_session(&self) -> Result<(), String> { + self.recovery_cleanup.delete_key_teleport_receive_session() + } + pub(crate) fn purge_orphan_wallet_artifacts(&self) -> std::io::Result<()> { self.recovery_cleanup.purge_orphan_wallet_artifacts() } @@ -154,6 +158,15 @@ impl RecoveryCleanup { Keychain::global().delete_all_wallet_items().map_err_str(std::convert::identity) } + /// Remove the persisted KeyTeleport receive session and its private key + /// + /// The session is not a wallet keychain item, so the wallet sweep leaves it + /// behind and the next receive start would resume it after a wipe + pub(crate) fn delete_key_teleport_receive_session(&self) -> Result<(), String> { + crate::manager::key_teleport_manager::delete_persisted_receive_session() + .map_err_str(std::convert::identity) + } + pub(crate) fn purge_orphan_wallet_artifacts(&self) -> std::io::Result<()> { crate::app::purge_orphan_wallet_artifacts(self) } @@ -179,6 +192,10 @@ pub enum WalletDeletionStage { WalletData, /// Parent-directory durability synchronization DirectorySync, + /// Unsigned transaction rows that belong to the wallet + UnsignedTransactions, + /// Selected-wallet references in global config + WalletSelection, /// Exact durable metadata rows Metadata, } @@ -289,8 +306,19 @@ fn delete_registered_wallet( failure(wallet_id, WalletDeletionStage::DirectorySync, source.to_string()) })?; + let database = Database::global(); + database.unsigned_transactions.delete_by_wallet_id(wallet_id).map_err(|source| { + failure(wallet_id, WalletDeletionStage::UnsignedTransactions, source.to_string()) + })?; + + // main and decoy selections are only rewritten on a mode switch, so a stale id + // would re-select the deleted wallet the next time the mode changes + database.global_config.forget_wallet(wallet_id).map_err(|source| { + failure(wallet_id, WalletDeletionStage::WalletSelection, source.to_string()) + })?; + for location in &target.locations { - let removed = Database::global() + let removed = database .wallets .remove_prepared_wallet_metadata(location.network, location.wallet_mode, wallet_id) .map_err(|source| { diff --git a/rust/src/wallet_lifecycle/tests.rs b/rust/src/wallet_lifecycle/tests.rs index 5c3536fd5..c380c8b04 100644 --- a/rust/src/wallet_lifecycle/tests.rs +++ b/rust/src/wallet_lifecycle/tests.rs @@ -244,6 +244,8 @@ fn same_wallet_construction_returns_typed_closing_state_without_waiting() { #[tokio::test] async fn retryable_ordinary_close_is_retried_without_a_new_construction() { + // the actor close path reaches storage; the real bootstrap would block the runtime + crate::database::test_support::init_test_database(); let coordinator: &'static WalletLifecycleCoordinator = Box::leak(Box::new(WalletLifecycleCoordinator::default())); let (wallet_id, registration_id, manager, registration) = register_preview_wallet(coordinator); @@ -609,34 +611,23 @@ fn unrecoverable_ordinary_close_requires_process_restart() { drop(manager); } -#[test] -fn destructive_preparation_keeps_normal_close_persistence_available() { +#[tokio::test] +async fn destructive_preparation_keeps_normal_close_persistence_available() { + // the actor close path reaches storage; the real bootstrap would block the runtime + crate::database::test_support::init_test_database(); let coordinator: &'static WalletLifecycleCoordinator = Box::leak(Box::new(WalletLifecycleCoordinator::default())); let (wallet_id, registration_id, manager, registration) = register_preview_wallet(coordinator); mark_ordinary_closing(coordinator, registration_id); - let (result_sender, result_receiver) = std::sync::mpsc::sync_channel(1); - let deletion_wallet_id = wallet_id.clone(); - std::thread::spawn(move || { - let result = cove_tokio::try_block_on(coordinator.prepare_wallet_deletion( - deletion_wallet_id, - ShutdownDeadlineTier::Initial, - None, - )) - .expect("runtime bridge is available"); - result_sender.send(result.is_ok()).expect("send preparation result"); - }); - - let reservation_started = (0..100).any(|_| { - if coordinator.data.lock().pending_preparation.is_some() { - return true; - } + let mut preparation = std::pin::pin!(coordinator.prepare_wallet_deletion_owned( + wallet_id.clone(), + ShutdownDeadlineTier::Initial, + None, + )); - std::thread::sleep(std::time::Duration::from_millis(1)); - false - }); - assert!(reservation_started, "destructive preparation reserves ownership"); + assert!(matches!(futures::poll!(preparation.as_mut()), std::task::Poll::Pending)); + assert!(coordinator.data.lock().pending_preparation.is_some()); assert_eq!(coordinator.data.lock().phase, CoordinatorPhase::Available); assert!(matches!( coordinator.reserve_preparation( @@ -657,11 +648,7 @@ fn destructive_preparation_keeps_normal_close_persistence_available() { drop(persistence); coordinator.finish_ordinary_close(registration_id, OrdinaryCloseOutcome::Retryable); - assert!( - result_receiver - .recv_timeout(std::time::Duration::from_secs(2)) - .expect("destructive preparation finishes after ordinary close") - ); + preparation.await.expect("destructive preparation finishes after ordinary close"); std::mem::forget(registration); drop(manager); diff --git a/rust/xtask/Cargo.toml b/rust/xtask/Cargo.toml index e345b9f98..3cee3b2de 100644 --- a/rust/xtask/Cargo.toml +++ b/rust/xtask/Cargo.toml @@ -11,6 +11,8 @@ xshell = "0.2.7" colored = "3.1.1" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +reqwest = { workspace = true, features = ["blocking", "rustls"] } +jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] } # for util sign-psbt bdk_wallet = { version = "3.0.0", features = ["keys-bip39"] } diff --git a/rust/xtask/src/android.rs b/rust/xtask/src/android.rs index 36702203d..8b6c5cc6d 100644 --- a/rust/xtask/src/android.rs +++ b/rust/xtask/src/android.rs @@ -17,6 +17,10 @@ use std::{ }; use xshell::{cmd, Shell}; +mod google_play; + +pub use google_play::{release_android, upload_google_play}; + // Android build constants const ANDROID_TARGETS: &[&str] = &["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]; @@ -560,6 +564,11 @@ impl AndroidReleaseSigning { } } +pub(crate) fn ensure_store_release_signing() -> Result<()> { + AndroidReleaseSigning::resolve()?; + Ok(()) +} + pub fn bundle_android(verbose: bool) -> Result<()> { let sh = Shell::new()?; diff --git a/rust/xtask/src/android/google_play.rs b/rust/xtask/src/android/google_play.rs new file mode 100644 index 000000000..22c33f6da --- /dev/null +++ b/rust/xtask/src/android/google_play.rs @@ -0,0 +1,159 @@ +use super::{build_android, bundle_android, AndroidBuildTargets, BuildProfile}; +use crate::common::{ + command_exists, ensure_rust_directory, normalize_required_arg, print_info, print_success, + resolve_readable_file, +}; +use crate::version::BuildNumberFile; +use color_eyre::eyre::{bail, Context, Result}; +use std::fs; +use xshell::{cmd, Shell}; + +const PLAY_PACKAGE_NAME: &str = "org.bitcoinppl.cove"; +const PLAY_AAB_PATH: &str = + "../android/app/build/outputs/bundle/storeRelease/app-store-release.aab"; +const JSON_KEY_PATH_ENV: &str = "GOOGLE_PLAY_JSON_KEY_PATH"; +const JSON_KEY_PATH_HINT: &str = + "Set GOOGLE_PLAY_JSON_KEY_PATH to a readable Google Play service account JSON file."; + +/// Canonical path to a readable Google Play service account key, resolved once fastlane is installed +struct GooglePlayCredentials(String); + +impl GooglePlayCredentials { + fn resolve(json_key_path: Option<&str>) -> Result { + if !command_exists("fastlane") { + bail!("Install fastlane before uploading to Google Play (brew install fastlane)."); + } + + resolve_json_key_path(json_key_path).map(Self) + } +} + +fn resolve_json_key_path(value: Option<&str>) -> Result { + let path = normalize_required_arg(JSON_KEY_PATH_ENV, value).wrap_err(JSON_KEY_PATH_HINT)?; + + resolve_readable_file(JSON_KEY_PATH_ENV, &path).wrap_err(JSON_KEY_PATH_HINT) +} + +pub fn upload_google_play(json_key_path: Option<&str>, verbose: bool) -> Result<()> { + let sh = Shell::new()?; + ensure_rust_directory(&sh)?; + let credentials = GooglePlayCredentials::resolve(json_key_path)?; + + upload_with_credentials(&sh, &credentials, verbose) +} + +pub fn release_android(json_key_path: Option<&str>, verbose: bool) -> Result<()> { + let sh = Shell::new()?; + ensure_rust_directory(&sh)?; + + // fail before bumping if Play credentials or store signing are missing + let credentials = GooglePlayCredentials::resolve(json_key_path)?; + super::ensure_store_release_signing()?; + + BuildNumberFile::AndroidGradle.bump_for_release(&sh, || { + build_android(BuildProfile::from_str("release-speed"), AndroidBuildTargets::All, verbose)?; + bundle_android(verbose) + })?; + + // Google may have accepted the bundle; keep versionCode if supply fails + upload_with_credentials(&sh, &credentials, verbose) +} + +fn upload_with_credentials( + sh: &Shell, + credentials: &GooglePlayCredentials, + verbose: bool, +) -> Result<()> { + if !sh.path_exists(PLAY_AAB_PATH) { + bail!("Signed bundle not found. Run just bundle-android first."); + } + + let aab = fs::canonicalize(PLAY_AAB_PATH) + .wrap_err_with(|| format!("Failed to resolve signed bundle at {PLAY_AAB_PATH}"))? + .to_string_lossy() + .into_owned(); + let json_key = &credentials.0; + + print_info("Uploading Android bundle to Google Play internal testing..."); + + let cmd = cmd!(sh, "fastlane").args([ + "supply", + "--json_key", + json_key.as_str(), + "--package_name", + PLAY_PACKAGE_NAME, + "--aab", + aab.as_str(), + "--track", + "internal", + "--release_status", + "completed", + "--skip_upload_apk", + "true", + "--skip_upload_metadata", + "true", + "--skip_upload_changelogs", + "true", + "--skip_upload_images", + "true", + "--skip_upload_screenshots", + "true", + ]); + + if verbose { + cmd.run().wrap_err("Failed to upload Android bundle to Google Play")?; + } else { + cmd.quiet().run().wrap_err("Failed to upload Android bundle to Google Play")?; + } + + print_success("Uploaded Android bundle to Google Play internal testing"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::resolve_json_key_path; + use std::fs; + + #[test] + fn rejects_missing_google_play_json_key_path() { + let error = resolve_json_key_path(None).unwrap_err().to_string(); + + assert!(error.contains("GOOGLE_PLAY_JSON_KEY_PATH")); + } + + #[test] + fn rejects_blank_google_play_json_key_path() { + let error = resolve_json_key_path(Some(" ")).unwrap_err().to_string(); + + assert!(error.contains("GOOGLE_PLAY_JSON_KEY_PATH")); + } + + #[test] + fn rejects_missing_google_play_json_key_file() { + let error = + resolve_json_key_path(Some("/tmp/cove-missing-play-key.json")).unwrap_err().to_string(); + + assert!(error.contains("GOOGLE_PLAY_JSON_KEY_PATH")); + } + + #[test] + fn rejects_google_play_json_key_directory() { + let temp_dir = tempfile::tempdir().unwrap(); + let error = resolve_json_key_path(temp_dir.path().to_str()).unwrap_err().to_string(); + + assert!(error.contains("GOOGLE_PLAY_JSON_KEY_PATH")); + } + + #[test] + fn resolves_readable_google_play_json_key() { + let temp_dir = tempfile::tempdir().unwrap(); + let path = temp_dir.path().join("play.json"); + fs::write(&path, "{}").unwrap(); + + let resolved = resolve_json_key_path(path.to_str()).unwrap(); + + assert_eq!(resolved, fs::canonicalize(&path).unwrap().to_string_lossy()); + } +} diff --git a/rust/xtask/src/android_device.rs b/rust/xtask/src/android_device.rs index fa2fbfd8d..458cb596d 100644 --- a/rust/xtask/src/android_device.rs +++ b/rust/xtask/src/android_device.rs @@ -36,20 +36,21 @@ impl AndroidDevice { /// - an exact adb serial pub(crate) fn select(device: Option<&str>) -> Result { let devices = list_connected()?; - let selector = AndroidDeviceSelector::from_arg(device)?; + let selector = AndroidDeviceSelector::from_arg(device); selector.resolve(&devices) } pub(crate) fn select_many(device_args: &[String]) -> Result> { let devices = list_connected()?; let selectors = if device_args.is_empty() { - vec![AndroidDeviceSelector::from_arg(None)?] + vec![AndroidDeviceSelector::from_arg(None)] } else { device_args .iter() .map(|device| AndroidDeviceSelector::from_arg(Some(device))) - .collect::>>()? + .collect::>() }; + let mut selected = Vec::with_capacity(selectors.len()); for selector in selectors { @@ -180,16 +181,16 @@ enum AndroidDeviceSelector { } impl AndroidDeviceSelector { - fn from_arg(device: Option<&str>) -> Result { + fn from_arg(device: Option<&str>) -> Self { let Some(device) = device.map(str::trim).filter(|value| !value.is_empty()) else { print_info("No Android device specified; defaulting to alias 'main'"); - return Ok(Self::Main); + return Self::Main; }; match device.to_ascii_lowercase().as_str() { - "main" => Ok(Self::Main), - "sim" => Ok(Self::Sim), - _ => Ok(Self::Serial(device.to_string())), + "main" => Self::Main, + "sim" => Self::Sim, + _ => Self::Serial(device.to_string()), } } @@ -464,20 +465,11 @@ mod tests { #[test] fn android_device_selector_parses_aliases_and_serials() { + assert_eq!(AndroidDeviceSelector::from_arg(None), AndroidDeviceSelector::Main); + assert_eq!(AndroidDeviceSelector::from_arg(Some("main")), AndroidDeviceSelector::Main); + assert_eq!(AndroidDeviceSelector::from_arg(Some("SIM")), AndroidDeviceSelector::Sim); assert_eq!( - AndroidDeviceSelector::from_arg(None).expect("default"), - AndroidDeviceSelector::Main - ); - assert_eq!( - AndroidDeviceSelector::from_arg(Some("main")).expect("main"), - AndroidDeviceSelector::Main - ); - assert_eq!( - AndroidDeviceSelector::from_arg(Some("SIM")).expect("sim"), - AndroidDeviceSelector::Sim - ); - assert_eq!( - AndroidDeviceSelector::from_arg(Some("emulator-5554")).expect("serial"), + AndroidDeviceSelector::from_arg(Some("emulator-5554")), AndroidDeviceSelector::Serial("emulator-5554".to_string()) ); } diff --git a/rust/xtask/src/common.rs b/rust/xtask/src/common.rs index 0ac476811..5103413a4 100644 --- a/rust/xtask/src/common.rs +++ b/rust/xtask/src/common.rs @@ -1,10 +1,15 @@ -use color_eyre::{eyre::Context, Result}; +use color_eyre::{ + eyre::{bail, ensure, Context}, + Result, +}; use colored::Colorize; +use reqwest::{blocking::Client, redirect}; use serde::Deserialize; use std::{ fs, path::{Path, PathBuf}, process::Command, + time::Duration, }; use xshell::{cmd, Shell}; @@ -62,6 +67,43 @@ pub fn cargo_target_dir(sh: &Shell) -> Result { Ok(metadata.target_directory) } +/// Returns the trimmed value of a required argument, failing when it is missing or blank +pub fn normalize_required_arg(name: &str, value: Option<&str>) -> Result { + let value = value.unwrap_or_default().trim(); + + if value.is_empty() { + bail!("{name} must be set"); + } + + Ok(value.to_string()) +} + +/// Resolves a path argument to the canonical path of an existing, readable file +pub fn resolve_readable_file(name: &str, path: &str) -> Result { + let file = Path::new(path); + ensure!(file.exists(), "{name} does not exist: {path}"); + ensure!(file.is_file(), "{name} is not a file: {path}"); + fs::File::open(file).wrap_err_with(|| format!("{name} is not readable: {path}"))?; + + fs::canonicalize(file) + .map(|resolved| resolved.to_string_lossy().into_owned()) + .wrap_err_with(|| format!("Failed to resolve {name}: {path}")) +} + +/// Builds a blocking HTTP client with release-tooling timeouts that never follows redirects +/// +/// Redirects stay unfollowed so bearer tokens never leave the requested origin and +/// associated-domain checks see the response Apple would, since Apple rejects redirected +/// apple-app-site-association files +pub fn http_client_without_redirects() -> Result { + Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .redirect(redirect::Policy::none()) + .build() + .wrap_err("Failed to build HTTP client") +} + /// Parse build flags and return individual arguments /// Takes a build flag string like "--release" or "--profile release-smaller" /// and returns a Vec of individual arguments ready to be passed to cargo diff --git a/rust/xtask/src/ios.rs b/rust/xtask/src/ios.rs index 9138485f9..71e106262 100644 --- a/rust/xtask/src/ios.rs +++ b/rust/xtask/src/ios.rs @@ -1,12 +1,15 @@ use crate::common::{ - cargo_target_dir, command_exists, print_error, print_info, print_success, print_warning, + cargo_target_dir, command_exists, http_client_without_redirects, normalize_required_arg, + print_error, print_info, print_success, print_warning, resolve_readable_file, trim_generated_trailing_whitespace, }; +use crate::version::BuildNumberFile; use color_eyre::{ eyre::{eyre, Context}, Result, }; use colored::Colorize; +use reqwest::StatusCode; use serde::Deserialize; use std::collections::HashSet; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -18,6 +21,10 @@ use std::{ use std::{fs::Permissions, os::unix::fs::PermissionsExt}; use xshell::{cmd, Shell}; +mod testflight; + +use testflight::{BuildIdentity, Distribution}; + // iOS build constants const IOS_TARGET_DEVICE: &str = "aarch64-apple-ios"; const IOS_TARGET_SIMULATOR: &str = "aarch64-apple-ios-sim"; @@ -601,52 +608,52 @@ pub fn run_ios_ui_tests(options: IosUiOptions, verbose: bool) -> Result<()> { pub fn testflight(options: TestflightUploadOptions, verbose: bool) -> Result<()> { let sh = Shell::new()?; - validate_testflight_credentials(&sh, &options)?; + let credentials = TestflightApiCredentials::from_options(&options)?; + let distribution = credentials.prepare_distribution()?; // fail before bumping/building if Apple has not associated this TestFlight app id - validate_testflight_associated_domain(&sh)?; - let project_snapshot = crate::version::snapshot_ios_project(&sh)?; + validate_testflight_associated_domain()?; - let result = (|| { - crate::version::bump_ios_build_number(&sh)?; + let archive = BuildNumberFile::IosProject.bump_for_release(&sh, || { build_ios(IosBuildType::Custom("release-speed"), true, false, verbose)?; - upload_testflight_inner(options, verbose, false) - })(); - - if let Err(error) = result { - if let Some(snapshot) = project_snapshot { - if let Err(restore_error) = crate::version::restore_ios_project(&sh, &snapshot) { - return Err(error).wrap_err(format!( - "Failed to restore iOS build number after TestFlight failure: {restore_error:#}" - )); - } - - print_error("TestFlight failed; restored iOS build number"); - } else { - print_error("TestFlight failed"); - } + archive_testflight(&credentials, verbose, false) + })?; - return Err(error); - } + // keep the bumped build number from here on: App Store Connect can accept an upload even when + // xcodebuild reports failure, and reusing an accepted number would fail the next upload + archive.upload(&credentials, verbose).wrap_err( + "TestFlight upload failed; kept the bumped build number. Retry with `just upload-testflight`", + )?; - Ok(()) + finish_testflight_distribution(&distribution, &archive.identity) } -fn validate_testflight_credentials(sh: &Shell, options: &TestflightUploadOptions) -> Result<()> { - let _ = TestflightApiCredentials::from_options(sh, options)?; +pub fn upload_testflight(options: TestflightUploadOptions, verbose: bool) -> Result<()> { + let credentials = TestflightApiCredentials::from_options(&options)?; + let distribution = credentials.prepare_distribution()?; + let archive = archive_testflight(&credentials, verbose, true)?; + archive.upload(&credentials, verbose)?; - Ok(()) + finish_testflight_distribution(&distribution, &archive.identity) } -pub fn upload_testflight(options: TestflightUploadOptions, verbose: bool) -> Result<()> { - upload_testflight_inner(options, verbose, true) +fn finish_testflight_distribution( + distribution: &Distribution, + identity: &BuildIdentity, +) -> Result<()> { + distribution.distribute(identity).wrap_err_with(|| { + format!( + "Uploaded TestFlight {} ({}), but distribution did not finish. Kept the build number; finish export compliance, the test description, and me-only group assignment in App Store Connect", + identity.version, identity.build_number + ) + }) } -fn upload_testflight_inner( - options: TestflightUploadOptions, +fn archive_testflight( + api_credentials: &TestflightApiCredentials, verbose: bool, validate_associated_domain: bool, -) -> Result<()> { +) -> Result { let sh = Shell::new()?; if !command_exists("xcodebuild") { @@ -654,9 +661,8 @@ fn upload_testflight_inner( color_eyre::eyre::bail!("xcodebuild command not found"); } - let api_credentials = TestflightApiCredentials::from_options(&sh, &options)?; if validate_associated_domain { - validate_testflight_associated_domain(&sh)?; + validate_testflight_associated_domain()?; } sh.change_dir("../ios"); @@ -686,46 +692,74 @@ fn upload_testflight_inner( .env("PATH", &xcode_path); run_xcodebuild(archive_cmd, verbose, "Failed to archive iOS app")?; validate_testflight_archive_entitlements(&sh, &archive_path)?; + let identity = testflight_archive_identity(&sh, &archive_path)?; print_success(&format!("Created archive at {archive_path}")); - print_info("Uploading iOS archive to App Store Connect..."); - let export_cmd = cmd!( - sh, - "xcodebuild -exportArchive -archivePath {archive_path} -exportPath {export_path} -exportOptionsPlist {export_options_path} -allowProvisioningUpdates -authenticationKeyPath {api_key_path} -authenticationKeyID {api_key_id} -authenticationKeyIssuerID {api_issuer_id}" - ) - .env("PATH", &xcode_path); - run_xcodebuild(export_cmd, verbose, "Failed to upload iOS archive to App Store Connect")?; - print_success("Uploaded iOS archive to App Store Connect"); + Ok(TestflightArchive { archive_path, export_path, export_options_path, identity }) +} - Ok(()) +/// A signed TestFlight archive ready for upload +struct TestflightArchive { + archive_path: String, + export_path: String, + export_options_path: String, + identity: BuildIdentity, } -fn validate_testflight_associated_domain(sh: &Shell) -> Result<()> { - if !command_exists("curl") { - color_eyre::eyre::bail!("curl not found; needed to verify TestFlight passkey domain"); +impl TestflightArchive { + fn upload(&self, api_credentials: &TestflightApiCredentials, verbose: bool) -> Result<()> { + let sh = Shell::new()?; + let Self { archive_path, export_path, export_options_path, .. } = self; + let api_key_path = &api_credentials.api_key_path; + let api_key_id = &api_credentials.api_key_id; + let api_issuer_id = &api_credentials.api_issuer_id; + let xcode_path = xcode_distribution_path(); + + print_info("Uploading iOS archive to App Store Connect..."); + let export_cmd = cmd!( + sh, + "xcodebuild -exportArchive -archivePath {archive_path} -exportPath {export_path} -exportOptionsPlist {export_options_path} -allowProvisioningUpdates -authenticationKeyPath {api_key_path} -authenticationKeyID {api_key_id} -authenticationKeyIssuerID {api_issuer_id}" + ) + .env("PATH", &xcode_path); + run_xcodebuild(export_cmd, verbose, "Failed to upload iOS archive to App Store Connect")?; + print_success("Uploaded iOS archive to App Store Connect"); + + Ok(()) } +} + +fn testflight_archive_identity(sh: &Shell, archive_path: &str) -> Result { + let plist = format!("{archive_path}/Products/Applications/{IOS_APP_NAME}.app/Info.plist"); + let version = + cmd!(sh, "/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' {plist}") + .read() + .wrap_err("Failed to read the archived app version")?; + let build_number = cmd!(sh, "/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' {plist}") + .read() + .wrap_err("Failed to read the archived app build number")?; + Ok(BuildIdentity { version, build_number }) +} + +fn validate_testflight_associated_domain() -> Result<()> { + let client = http_client_without_redirects()?; let app_identifier = testflight_app_identifier(); + for url in testflight_aasa_urls() { - let output = cmd!(sh, "curl -LfsS {url}") - .quiet() - .ignore_status() - .output() + let response = client + .get(&url) + .send() .wrap_err_with(|| format!("Failed to fetch associated-domain file from {url}"))?; + let status = response.status(); - if !output.status.success() { - let stderr = - String::from_utf8(output.stderr).unwrap_or_else(|_| "".into()); - color_eyre::eyre::bail!( - "failed to fetch associated-domain file from {url}: {}", - non_empty_output(&stderr, "") - ); - } - - let body = String::from_utf8(output.stdout) + // decode strictly instead of lossily so a corrupt file fails instead of passing + let body = response + .bytes() + .wrap_err_with(|| format!("Failed to read associated-domain file from {url}"))?; + let body = String::from_utf8(body.to_vec()) .wrap_err_with(|| format!("Associated-domain file from {url} was not valid UTF-8"))?; - ensure_aasa_webcredentials_app(&body, &app_identifier) - .wrap_err_with(|| format!("Invalid associated-domain file at {url}"))?; + + validate_aasa_response(&url, status, &body, &app_identifier)?; } print_success(&format!( @@ -736,6 +770,25 @@ fn validate_testflight_associated_domain(sh: &Shell) -> Result<()> { Ok(()) } +fn validate_aasa_response( + url: &str, + status: StatusCode, + body: &str, + app_identifier: &str, +) -> Result<()> { + if status != StatusCode::OK { + color_eyre::eyre::bail!( + "associated-domain file from {url} returned HTTP status {status}; expected HTTP 200" + ); + } + + ensure_aasa_webcredentials_app(body, app_identifier).wrap_err_with(|| { + format!("Invalid associated-domain file at {url} with HTTP status {status}") + })?; + + Ok(()) +} + fn testflight_app_identifier() -> String { format!("{IOS_TEAM_ID}.{IOS_BUNDLE_ID}") } @@ -815,18 +868,22 @@ struct TestflightApiCredentials { } impl TestflightApiCredentials { - fn from_options(sh: &Shell, options: &TestflightUploadOptions) -> Result { - let api_key_path = normalize_required_arg("ASC_API_KEY_PATH", &options.api_key_path)?; - let api_key_id = normalize_required_arg("ASC_API_KEY_ID", &options.api_key_id)?; - let api_issuer_id = normalize_required_arg("ASC_API_ISSUER_ID", &options.api_issuer_id)?; + fn prepare_distribution(&self) -> Result { + Distribution::prepare( + &self.api_key_path, + &self.api_key_id, + &self.api_issuer_id, + IOS_BUNDLE_ID, + ) + } - if !sh.path_exists(&api_key_path) { - color_eyre::eyre::bail!("ASC_API_KEY_PATH does not exist: {api_key_path}"); - } - let api_key_path = std::fs::canonicalize(&api_key_path) - .wrap_err_with(|| format!("Failed to resolve ASC_API_KEY_PATH: {api_key_path}"))? - .to_string_lossy() - .into_owned(); + fn from_options(options: &TestflightUploadOptions) -> Result { + let api_key_path = + normalize_required_arg("ASC_API_KEY_PATH", options.api_key_path.as_deref())?; + let api_key_id = normalize_required_arg("ASC_API_KEY_ID", options.api_key_id.as_deref())?; + let api_issuer_id = + normalize_required_arg("ASC_API_ISSUER_ID", options.api_issuer_id.as_deref())?; + let api_key_path = resolve_readable_file("ASC_API_KEY_PATH", &api_key_path)?; let normalized_api_key = normalize_testflight_api_key(&api_key_path)?; let api_key_file = @@ -907,17 +964,6 @@ fn set_secret_file_permissions(_path: &str) -> Result<()> { Ok(()) } -fn normalize_required_arg(name: &str, value: &Option) -> Result { - let value = value.as_deref().unwrap_or_default(); - let value = value.trim(); - - if value.is_empty() { - color_eyre::eyre::bail!("{name} must be set"); - } - - Ok(value.to_string()) -} - fn xcode_distribution_path() -> String { // keep Apple's rsync ahead of Homebrew rsync for Xcode IPA packaging const SYSTEM_PATH_PREFIX: &str = "/usr/bin:/bin:/usr/sbin:/sbin"; @@ -1354,8 +1400,7 @@ fn launch_ios_device_app(sh: &Shell, device_identifier: &str) -> Result<()> { } if SystemTime::now() >= locked_retry_deadline { - let message = - "Failed because iPhone wasn't unlocked. Unlock the iPhone, then run `just ri` again"; + let message = "Failed because iPhone wasn't unlocked. Unlock the iPhone, then run `just ri` again"; print_error(&message.red().bold().to_string()); color_eyre::eyre::bail!("{message}"); } @@ -1679,16 +1724,59 @@ fn available_device_context(devices: &[ResolvedDevice]) -> String { #[cfg(test)] mod tests { + #[cfg(target_os = "macos")] + mod archive_identity { + use crate::ios::testflight_archive_identity; + use xshell::Shell; + + #[test] + fn reads_uploaded_identity_from_archive_not_project_settings() { + let archive = tempfile::tempdir().unwrap(); + let app = archive.path().join("Products/Applications/Cove.app"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write( + app.join("Info.plist"), + r#" + +CFBundleShortVersionString1.4.0 +CFBundleVersion203 +"#, + ) + .unwrap(); + + let identity = testflight_archive_identity( + &Shell::new().unwrap(), + archive.path().to_str().unwrap(), + ) + .unwrap(); + + assert_eq!(identity.version, "1.4.0"); + assert_eq!(identity.build_number, "203"); + } + + #[test] + fn rejects_archive_without_build_metadata_before_upload() { + let archive = tempfile::tempdir().unwrap(); + let result = testflight_archive_identity( + &Shell::new().unwrap(), + archive.path().to_str().unwrap(), + ); + + assert!(result.is_err()); + } + } + use super::{ default_build_slot_from_cwd, derived_data_dir_name_for_slot, device_selector_from_target_value, devicectl_device_connection_can_refresh, devicectl_device_is_available_ios, ensure_aasa_webcredentials_app, looks_like_ios_udid, normalize_pem_text, resolve_device_name_or_alias, sanitize_build_slot, - simulator_line_matches_device, simulator_state_from_line, DeviceSelector, - DevicectlConnectionProperties, DevicectlDevice, DevicectlDeviceProperties, + simulator_line_matches_device, simulator_state_from_line, validate_aasa_response, + DeviceSelector, DevicectlConnectionProperties, DevicectlDevice, DevicectlDeviceProperties, DevicectlHardwareProperties, DevicectlPairingState, DevicectlTunnelState, IOS_DEVICE_DERIVED_DATA_SUFFIX, IOS_SIMULATOR_DERIVED_DATA_SUFFIX, }; + use reqwest::StatusCode; use std::path::Path; #[test] @@ -1760,7 +1848,7 @@ mod tests { fn device_alias_env_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } const VALID_PEM: &str = "\ @@ -1958,6 +2046,81 @@ ABC123 assert!(ensure_aasa_webcredentials_app(body, "Q8UP8C53Y8.org.bitcoinppl.cove").is_err()); } + + #[test] + fn aasa_response_accepts_http_200_with_testflight_app_identifier() { + let body = r#"{ + "webcredentials": { + "apps": ["Q8UP8C53Y8.org.bitcoinppl.cove"] + } + }"#; + + assert!(validate_aasa_response( + "https://example.com/.well-known/apple-app-site-association", + StatusCode::OK, + body, + "Q8UP8C53Y8.org.bitcoinppl.cove", + ) + .is_ok()); + } + + #[test] + fn aasa_response_rejects_redirect_status() { + let body = r#"{ + "webcredentials": { + "apps": ["Q8UP8C53Y8.org.bitcoinppl.cove"] + } + }"#; + + let error = validate_aasa_response( + "https://example.com/.well-known/apple-app-site-association", + StatusCode::MOVED_PERMANENTLY, + body, + "Q8UP8C53Y8.org.bitcoinppl.cove", + ) + .expect_err("redirect response should be rejected"); + let message = format!("{error:#}"); + + assert!(message.contains("https://example.com/.well-known/apple-app-site-association")); + assert!(message.contains("301")); + } + + #[test] + fn aasa_response_rejects_malformed_json() { + let url = "https://example.com/.well-known/apple-app-site-association"; + let error = validate_aasa_response( + url, + StatusCode::OK, + "not json", + "Q8UP8C53Y8.org.bitcoinppl.cove", + ) + .expect_err("malformed JSON should be rejected"); + let message = format!("{error:#}"); + + assert!(message.contains(url)); + assert!(message.contains("HTTP status 200")); + assert!(message.contains("failed to parse apple-app-site-association JSON")); + } + + #[test] + fn aasa_response_rejects_missing_testflight_app_identifier() { + let url = "https://example.com/.well-known/apple-app-site-association"; + let body = r#"{ + "webcredentials": { + "apps": ["Q8UP8C53Y8.org.bitcoinppl.other"] + } + }"#; + + let error = + validate_aasa_response(url, StatusCode::OK, body, "Q8UP8C53Y8.org.bitcoinppl.cove") + .expect_err("missing TestFlight app identifier should be rejected"); + let message = format!("{error:#}"); + + assert!(message.contains(url)); + assert!(message.contains("HTTP status 200")); + assert!(message.contains("webcredentials.apps` does not include")); + } + #[test] fn derived_data_dir_name_includes_sanitized_slot_and_target() { assert_eq!( diff --git a/rust/xtask/src/ios/testflight.rs b/rust/xtask/src/ios/testflight.rs new file mode 100644 index 000000000..8839d1a8c --- /dev/null +++ b/rust/xtask/src/ios/testflight.rs @@ -0,0 +1,476 @@ +use crate::common::{http_client_without_redirects, print_info, print_success}; +use color_eyre::eyre::{bail, ensure, eyre, Context, Result}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use reqwest::blocking::{Client, Response}; +use reqwest::{Method, StatusCode, Url}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const API_URL: &str = "https://api.appstoreconnect.apple.com/"; +const GROUP_NAME: &str = "me-only"; +const PROCESSING_TIMEOUT: Duration = Duration::from_secs(30 * 60); +const POLL_INTERVAL: Duration = Duration::from_secs(15); + +/// The version and build number read from the uploaded archive +pub(crate) struct BuildIdentity { + /// The app marketing version + pub(crate) version: String, + /// The app build number + pub(crate) build_number: String, +} + +/// A validated destination and a snapshot of the previous build's test notes +pub(crate) struct Distribution { + api: Api, + app_id: String, + group: Resource, + notes: Vec, +} + +impl Distribution { + /// Validate the destination and save the latest valid iOS build's descriptions before upload + pub(crate) fn prepare( + key_path: &str, + key_id: &str, + issuer_id: &str, + bundle_id: &str, + ) -> Result { + let key = + std::fs::read(key_path).wrap_err("Failed to read App Store Connect signing key")?; + + let api = Api::new(&key, key_id, issuer_id)?; + Self::prepare_with_api(api, bundle_id) + } + + fn prepare_with_api(api: Api, bundle_id: &str) -> Result { + let apps: Vec> = api.list("v1/apps", &[("filter[bundleId]", bundle_id)])?; + let mut apps = apps.into_iter().filter(|app| app.attributes.bundle_id == bundle_id); + let app = apps.next().ok_or_else(|| eyre!("App not found: {bundle_id}"))?; + + ensure!(apps.next().is_none(), "Multiple apps match {bundle_id}"); + + let groups: Vec> = + api.list("v1/betaGroups", &[("filter[app]", &app.id), ("filter[name]", GROUP_NAME)])?; + + let group = exact_group(groups)?; + let builds: Page = api + .request( + Method::GET, + api.url( + "v1/builds", + &[ + ("filter[app]", &app.id), + ("filter[preReleaseVersion.platform]", "IOS"), + ("filter[processingState]", "VALID"), + ("sort", "-uploadedDate"), + ("limit", "1"), + ], + )?, + None, + )? + .json() + .wrap_err("Invalid previous-build response")?; + + let previous = builds.data.first().ok_or_else(|| { + eyre!("No previous valid iOS build from which to copy test descriptions") + })?; + + ensure!( + previous.attributes.processing_state == ProcessingState::Valid, + "Previous build is not valid" + ); + + let notes: Vec> = + api.list(&format!("v1/builds/{}/betaBuildLocalizations", previous.id), &[])?; + + let notes = notes + .into_iter() + .filter_map(|note| note.attributes.into_description()) + .collect::>(); + + ensure!( + !notes.is_empty(), + "Previous iOS build {} has no test description to copy", + previous.attributes.version + ); + + print_info(&format!( + "Will copy test descriptions from iOS build {} to {GROUP_NAME}", + previous.attributes.version + )); + + Ok(Self { api, app_id: app.id, group, notes }) + } + + /// Wait for this exact build, copy all saved test descriptions, and add it to me-only + pub(crate) fn distribute(&self, identity: &BuildIdentity) -> Result<()> { + self.distribute_with_timeout(identity, PROCESSING_TIMEOUT, POLL_INTERVAL) + } + + fn distribute_with_timeout( + &self, + identity: &BuildIdentity, + timeout: Duration, + interval: Duration, + ) -> Result<()> { + print_info("Waiting for App Store Connect to process the uploaded build..."); + let deadline = Instant::now() + timeout; + + loop { + let builds: Vec> = self.api.list( + "v1/builds", + &[ + ("filter[app]", &self.app_id), + ("filter[preReleaseVersion.platform]", "IOS"), + ("filter[preReleaseVersion.version]", &identity.version), + ("filter[version]", &identity.build_number), + ], + )?; + + ensure!(builds.len() <= 1, "Multiple builds match the uploaded archive"); + + if let Some(build) = builds.first() { + ensure!( + build.attributes.version == identity.build_number, + "App Store Connect returned a different build number" + ); + + match build.attributes.processing_state { + ProcessingState::Valid => return self.assign(build), + ProcessingState::Processing => {} + ProcessingState::Failed | ProcessingState::Invalid => { + bail!( + "App Store Connect rejected build {}: {:?}", + identity.build_number, + build.attributes.processing_state + ); + } + } + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + ensure!( + !remaining.is_zero(), + "Timed out waiting for TestFlight build {} ({})", + identity.version, + identity.build_number + ); + + std::thread::sleep(interval.min(remaining)); + } + } + + fn assign(&self, build: &Resource) -> Result<()> { + if build.attributes.uses_non_exempt_encryption != Some(false) { + let path = format!("v1/builds/{}", build.id); + let body = json!({"data": { + "type": "builds", "id": build.id, + "attributes": {"usesNonExemptEncryption": false} + }}); + + self.api.request(Method::PATCH, self.api.url(&path, &[])?, Some(&body))?; + print_info("Set Uses Non-Exempt Encryption to No"); + } + + let existing: Vec> = + self.api.list(&format!("v1/builds/{}/betaBuildLocalizations", build.id), &[])?; + + // write descriptions before group membership so testers receive the intended notes + for note in &self.notes { + let current = existing.iter().find(|entry| entry.attributes.locale == note.locale); + let (method, path, body) = localization_request(&build.id, note, current); + self.api.request(method, self.api.url(&path, &[])?, Some(&body))?; + } + + // the API permits only one relationship filter; build IDs already identify the app + let groups: Vec> = + self.api.list("v1/betaGroups", &[("filter[builds]", &build.id)])?; + + if !groups.iter().any(|group| group.id == self.group.id) { + let path = format!("v1/betaGroups/{}/relationships/builds", self.group.id); + let body = json!({"data": [{"type": "builds", "id": build.id}]}); + self.api.request(Method::POST, self.api.url(&path, &[])?, Some(&body))?; + } + + if !self.group.attributes.is_internal_group { + self.submit_for_external_testing(build)?; + } + + print_success(&format!( + "Copied test descriptions and added build {} to {GROUP_NAME}", + build.attributes.version + )); + Ok(()) + } + + fn submit_for_external_testing(&self, build: &Resource) -> Result<()> { + let path = format!("v1/builds/{}/buildBetaDetail", build.id); + let detail: BuildBetaDetailResponse = self + .api + .request(Method::GET, self.api.url(&path, &[])?, None)? + .json() + .wrap_err("Invalid TestFlight beta status response")?; + + match detail.data.attributes.external_build_state { + ExternalBetaState::ReadyForBetaSubmission => { + let body = json!({"data": { + "type": "betaAppReviewSubmissions", + "relationships": {"build": {"data": {"type": "builds", "id": build.id}}} + }}); + + self.api.request( + Method::POST, + self.api.url("v1/betaAppReviewSubmissions", &[])?, + Some(&body), + )?; + + print_info("Submitted the build for TestFlight beta review"); + } + + ExternalBetaState::WaitingForBetaReview | ExternalBetaState::InBetaReview => { + print_info("The build is waiting for TestFlight beta review"); + } + + ExternalBetaState::BetaApproved + | ExternalBetaState::ReadyForBetaTesting + | ExternalBetaState::InBetaTesting => {} + state => bail!("TestFlight cannot start external testing in state {state:?}"), + } + + Ok(()) + } +} + +fn exact_group(groups: Vec>) -> Result> { + let mut matches = groups.into_iter().filter(|group| group.attributes.name == GROUP_NAME); + let group = + matches.next().ok_or_else(|| eyre!("TestFlight group {GROUP_NAME} was not found"))?; + + ensure!(matches.next().is_none(), "Multiple TestFlight groups are named {GROUP_NAME}"); + Ok(group) +} + +fn localization_request( + build_id: &str, + note: &TestDescription, + current: Option<&Resource>, +) -> (Method, String, Value) { + if let Some(current) = current { + return ( + Method::PATCH, + format!("v1/betaBuildLocalizations/{}", current.id), + json!({ + "data": {"type": "betaBuildLocalizations", "id": current.id, "attributes": {"whatsNew": note.whats_new}} + }), + ); + } + + ( + Method::POST, + "v1/betaBuildLocalizations".to_owned(), + json!({ + "data": {"type": "betaBuildLocalizations", "attributes": note, + "relationships": {"build": {"data": {"type": "builds", "id": build_id}}}} + }), + ) +} + +struct Api { + client: Client, + base_url: Url, + key: EncodingKey, + key_id: String, + issuer_id: String, +} + +impl Api { + fn new(key: &[u8], key_id: &str, issuer_id: &str) -> Result { + let client = http_client_without_redirects()?; + + Ok(Self { + client, + base_url: Url::parse(API_URL)?, + key: EncodingKey::from_ec_pem(key).wrap_err("Invalid App Store Connect ES256 key")?, + key_id: key_id.to_owned(), + issuer_id: issuer_id.to_owned(), + }) + } + + fn url(&self, path: &str, query: &[(&str, &str)]) -> Result { + let mut url = self.base_url.join(path)?; + ensure!( + url.origin() == self.base_url.origin() + && url.username().is_empty() + && url.password().is_none(), + "App Store Connect returned an unsafe pagination URL" + ); + + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query.iter().copied()); + } + + Ok(url) + } + + fn list( + &self, + path: &str, + query: &[(&str, &str)], + ) -> Result>> { + let mut url = self.url(path, query)?; + let mut seen = HashSet::new(); + let mut data = Vec::new(); + + loop { + ensure!(seen.insert(url.to_string()), "App Store Connect repeated a pagination URL"); + let page: Page = self + .request(Method::GET, url, None)? + .json() + .wrap_err("Invalid App Store Connect response")?; + + data.extend(page.data); + let Some(next) = page.links.next else { return Ok(data) }; + url = self.url(&next, &[])?; + } + } + + fn request(&self, method: Method, url: Url, body: Option<&Value>) -> Result { + for attempt in 0..3 { + let issued_at = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(self.key_id.clone()); + let claims = json!({"iss": self.issuer_id, "iat": issued_at, "exp": issued_at + 600, "aud": "appstoreconnect-v1"}); + let token = jsonwebtoken::encode(&header, &claims, &self.key) + .wrap_err("Failed to sign App Store Connect request")?; + + let mut request = self.client.request(method.clone(), url.clone()).bearer_auth(token); + + if let Some(body) = body { + request = request.json(body); + } + + let response = request.send().wrap_err("App Store Connect request failed")?; + let status = response.status(); + + if status.is_success() { + return Ok(response); + } + + if method == Method::GET + && attempt < 2 + && (status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) + { + std::thread::sleep(Duration::from_secs(2)); + continue; + } + + let errors = response.text().unwrap_or_default(); + bail!("App Store Connect {method} {} failed ({status}): {errors}", url.path()); + } + + unreachable!("the last request attempt always returns") + } +} + +#[derive(Deserialize)] +struct Page { + data: Vec>, + #[serde(default)] + links: Links, +} + +#[derive(Default, Deserialize)] +struct Links { + next: Option, +} + +#[derive(Deserialize)] +struct Resource { + id: String, + attributes: T, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct App { + bundle_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Group { + name: String, + is_internal_group: bool, +} + +#[derive(Deserialize)] +struct BuildBetaDetailResponse { + data: Resource, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct BuildBetaDetail { + external_build_state: ExternalBetaState, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +enum ExternalBetaState { + Processing, + ProcessingException, + MissingExportCompliance, + ReadyForBetaTesting, + InBetaTesting, + Expired, + ReadyForBetaSubmission, + InExportComplianceReview, + WaitingForBetaReview, + InBetaReview, + BetaRejected, + BetaApproved, + NotApplicable, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Build { + version: String, + processing_state: ProcessingState, + uses_non_exempt_encryption: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +enum ProcessingState { + Processing, + Valid, + Failed, + Invalid, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Localization { + locale: String, + whats_new: Option, +} + +impl Localization { + fn into_description(self) -> Option { + let whats_new = self.whats_new.filter(|text| !text.trim().is_empty())?; + Some(TestDescription { locale: self.locale, whats_new }) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct TestDescription { + locale: String, + whats_new: String, +} + +#[cfg(test)] +mod tests; diff --git a/rust/xtask/src/ios/testflight/tests.rs b/rust/xtask/src/ios/testflight/tests.rs new file mode 100644 index 000000000..d87c6b5d9 --- /dev/null +++ b/rust/xtask/src/ios/testflight/tests.rs @@ -0,0 +1,322 @@ +use super::{exact_group, Api, BuildIdentity, Distribution, Group, Localization, Resource}; +use reqwest::Url; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +// disposable test key, unrelated to App Store Connect credentials +const TEST_KEY: &str = "-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgiEMKw30PfmMEwjn3 +j0ygowH8+9FglyHR6PVLIHVq41ahRANCAAQ7vkWoz3MlWICekj51h5HziO7hSgeW +sje31mu8zpQ44ib5q2hHTcTG/T8A4GKUvCILYf92wwyPkMtQn+GYSHhZ +-----END PRIVATE KEY-----"; + +struct Request { + method: String, + url: Url, + body: Value, +} + +struct Server { + url: Url, + handle: JoinHandle>, +} + +impl Server { + fn new(responses: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); + listener.set_nonblocking(true).unwrap(); + let handle = std::thread::spawn(move || { + let mut requests = Vec::new(); + + for response in responses { + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "Mock API request did not arrive"); + std::thread::sleep(Duration::from_millis(5)); + } + + Err(error) => panic!("{error}"), + } + }; + + // macOS accepted sockets inherit the listener's non-blocking mode, so a read + // before the request bytes arrive would fail with WouldBlock + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut bytes = Vec::new(); + let header_end = loop { + let mut buffer = [0; 1024]; + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&buffer[..count]); + + if let Some(index) = bytes.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + break index + 4; + } + }; + let headers = String::from_utf8(bytes[..header_end].to_vec()).unwrap(); + let length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + + while bytes.len() < header_end + length { + let mut buffer = [0; 1024]; + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&buffer[..count]); + } + + assert!(headers.to_lowercase().contains("authorization: bearer ")); + let mut line = headers.lines().next().unwrap().split_whitespace(); + let method = line.next().unwrap().to_owned(); + let url = Url::parse(&format!("http://localhost{}", line.next().unwrap())).unwrap(); + let body = if length == 0 { + Value::Null + } else { + serde_json::from_slice(&bytes[header_end..header_end + length]).unwrap() + }; + requests.push(Request { method, url, body }); + + let body = response.to_string(); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + } + + requests + }); + + Self { url, handle } + } + + fn api(&self) -> Api { + let mut api = Api::new(TEST_KEY.as_bytes(), "TESTKEY", "test-issuer").unwrap(); + api.base_url = self.url.clone(); + api.client = reqwest::blocking::Client::builder().no_proxy().build().unwrap(); + api + } +} + +fn page(data: Value) -> Value { + json!({"data": data, "links": {"next": null}}) +} + +fn build(state: &str) -> Value { + page(json!([{"id": "new-build", "attributes": { + "version": "203", "processingState": state, "usesNonExemptEncryption": false + }}])) +} + +fn identity() -> BuildIdentity { + BuildIdentity { version: "1.4.0".to_owned(), build_number: "203".to_owned() } +} + +fn target_group(id: &str, internal: bool) -> Resource { + Resource { + id: id.to_owned(), + attributes: Group { name: "me-only".to_owned(), is_internal_group: internal }, + } +} + +fn beta_detail(state: &str) -> Value { + json!({"data": {"id": "detail", "attributes": {"externalBuildState": state}}}) +} + +#[test] +fn copies_snapshot_for_each_locale_before_adding_exact_build_to_group() { + let description = "Fix backup\nKeep this exact text 🪙"; + let server = Server::new(vec![ + page(json!([{"id": "app", "attributes": {"bundleId": "org.bitcoinppl.cove"}}])), + page( + json!([{"id": "private-group", "attributes": {"name": "me-only", "isInternalGroup": false}}]), + ), + page( + json!([{"id": "old-build", "attributes": {"version": "202", "processingState": "VALID"}}]), + ), + page(json!([ + {"id": "old-en", "attributes": {"locale": "en-US", "whatsNew": description}}, + {"id": "old-fr", "attributes": {"locale": "fr-FR", "whatsNew": "Correction"}}, + {"id": "old-de", "attributes": {"locale": "de-DE", "whatsNew": null}}, + {"id": "old-es", "attributes": {"locale": "es-ES", "whatsNew": ""}} + ])), + build("PROCESSING"), + build("VALID"), + page(json!([{"id": "new-en", "attributes": {"locale": "en-US", "whatsNew": "stale"}}])), + json!({}), + json!({}), + page(json!([])), + json!({}), + beta_detail("READY_FOR_BETA_SUBMISSION"), + json!({}), + ]); + let distribution = Distribution::prepare_with_api(server.api(), "org.bitcoinppl.cove").unwrap(); + distribution + .distribute_with_timeout(&identity(), Duration::from_secs(2), Duration::ZERO) + .unwrap(); + let requests = server.handle.join().unwrap(); + let previous_query = requests[2].url.query_pairs().collect::>(); + assert_eq!(previous_query["filter[preReleaseVersion.platform]"], "IOS"); + assert_eq!(previous_query["sort"], "-uploadedDate"); + assert_eq!(previous_query["filter[processingState]"], "VALID"); + assert_eq!(requests[3].url.path(), "/v1/builds/old-build/betaBuildLocalizations"); + + for request in &requests[4..6] { + let query = request.url.query_pairs().collect::>(); + assert_eq!(query["filter[app]"], "app"); + assert_eq!(query["filter[preReleaseVersion.platform]"], "IOS"); + assert_eq!(query["filter[preReleaseVersion.version]"], "1.4.0"); + assert_eq!(query["filter[version]"], "203"); + } + + assert_eq!(requests[7].method, "PATCH"); + assert_eq!(requests[7].url.path(), "/v1/betaBuildLocalizations/new-en"); + assert_eq!(requests[7].body["data"]["attributes"]["whatsNew"], description); + assert_eq!(requests[8].method, "POST"); + assert_eq!(requests[8].body["data"]["attributes"]["locale"], "fr-FR"); + assert_eq!(requests[8].body["data"]["relationships"]["build"]["data"]["id"], "new-build"); + assert_eq!(requests[9].url.path(), "/v1/betaGroups"); + let group_query = requests[9].url.query_pairs().collect::>(); + assert_eq!(group_query.len(), 1); + assert_eq!(group_query["filter[builds]"], "new-build"); + assert_eq!(requests[10].url.path(), "/v1/betaGroups/private-group/relationships/builds"); + assert_eq!(requests[10].body, json!({"data": [{"type": "builds", "id": "new-build"}]})); + assert_eq!(requests[11].url.path(), "/v1/builds/new-build/buildBetaDetail"); + assert_eq!(requests[12].method, "POST"); + assert_eq!(requests[12].url.path(), "/v1/betaAppReviewSubmissions"); + assert_eq!(requests[12].body["data"]["relationships"]["build"]["data"]["id"], "new-build"); +} + +#[test] +fn external_review_is_not_submitted_again() { + for state in ["WAITING_FOR_BETA_REVIEW", "IN_BETA_REVIEW", "BETA_APPROVED", "IN_BETA_TESTING"] { + let server = Server::new(vec![ + build("VALID"), + page(json!([])), + page(json!([ + {"id": "group", "attributes": {}} + ])), + beta_detail(state), + ]); + let distribution = Distribution { + api: server.api(), + app_id: "app".to_owned(), + group: target_group("group", false), + notes: vec![], + }; + + distribution.distribute_with_timeout(&identity(), Duration::ZERO, Duration::ZERO).unwrap(); + assert!(server.handle.join().unwrap().iter().all(|request| request.method == "GET")); + } +} + +#[test] +fn rejects_failed_invalid_unknown_and_timed_out_builds_without_writes() { + for state in ["FAILED", "INVALID", "NEW_UNKNOWN_STATE", "PROCESSING"] { + let server = Server::new(vec![build(state)]); + let distribution = Distribution { + api: server.api(), + app_id: "app".to_owned(), + group: target_group("group", true), + notes: vec![], + }; + assert!(distribution + .distribute_with_timeout(&identity(), Duration::ZERO, Duration::ZERO) + .is_err()); + let requests = server.handle.join().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "GET"); + } +} + +#[test] +fn existing_membership_is_not_added_again() { + let server = Server::new(vec![ + build("VALID"), + page(json!([])), + page(json!([ + {"id": "group", "attributes": {}} + ])), + ]); + let distribution = Distribution { + api: server.api(), + app_id: "app".to_owned(), + group: target_group("group", true), + notes: vec![], + }; + distribution.distribute_with_timeout(&identity(), Duration::ZERO, Duration::ZERO).unwrap(); + assert!(server.handle.join().unwrap().iter().all(|request| request.method == "GET")); +} + +#[test] +fn sets_missing_compliance_before_group_assignment() { + let mut pending_compliance = build("VALID"); + pending_compliance["data"][0]["attributes"]["usesNonExemptEncryption"] = Value::Null; + let server = Server::new(vec![ + pending_compliance, + json!({}), + page(json!([])), + page(json!([])), + json!({}), + ]); + let distribution = Distribution { + api: server.api(), + app_id: "app".to_owned(), + group: target_group("group", true), + notes: vec![], + }; + + distribution.distribute_with_timeout(&identity(), Duration::ZERO, Duration::ZERO).unwrap(); + let requests = server.handle.join().unwrap(); + assert_eq!(requests[1].method, "PATCH"); + assert_eq!(requests[1].url.path(), "/v1/builds/new-build"); + assert_eq!( + requests[1].body, + json!({"data": { + "type": "builds", "id": "new-build", "attributes": {"usesNonExemptEncryption": false} + }}) + ); + assert_eq!(requests[4].url.path(), "/v1/betaGroups/group/relationships/builds"); +} + +#[test] +fn requires_one_exact_group() { + let group = |name: &str| Resource { + id: "id".to_owned(), + attributes: Group { name: name.to_owned(), is_internal_group: true }, + }; + assert!(exact_group(vec![]).is_err()); + assert!(exact_group(vec![group("other")]).is_err()); + assert!(exact_group(vec![group("me-only"), group("me-only")]).is_err()); + assert_eq!(exact_group(vec![group("me-only")]).unwrap().id, "id"); +} + +#[test] +fn pagination_cannot_send_credentials_to_another_origin() { + let api = Api::new(TEST_KEY.as_bytes(), "TESTKEY", "issuer").unwrap(); + assert!(api.url("https://example.com/v1/apps", &[]).is_err()); + assert!(api.url("http://api.appstoreconnect.apple.com/v1/apps", &[]).is_err()); + assert!(api.url("https://user@api.appstoreconnect.apple.com/v1/apps", &[]).is_err()); + assert!(api.url("https://api.appstoreconnect.apple.com/v1/apps?cursor=next", &[]).is_ok()); +} + +#[test] +fn empty_localizations_do_not_create_invalid_description_requests() { + for whats_new in [None, Some(String::new()), Some(" \n".to_owned())] { + let note = Localization { locale: "en-US".to_owned(), whats_new }; + assert!(note.into_description().is_none()); + } + + let note = + Localization { locale: "en-US".to_owned(), whats_new: Some(" Exact text\n".to_owned()) }; + assert_eq!(note.into_description().unwrap().whats_new, " Exact text\n"); +} diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index f9e9d3374..1d20447f7 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -74,6 +74,22 @@ enum Commands { #[command(name = "bundle-android")] BundleAndroid, + /// Bump, build signed artifacts, and upload to Google Play internal testing + #[command(name = "release-android")] + ReleaseAndroid { + /// Google Play service account JSON key file path + #[arg(long, env = "GOOGLE_PLAY_JSON_KEY_PATH")] + json_key_path: Option, + }, + + /// Upload the existing signed Android bundle to Google Play internal testing without bumping + #[command(name = "upload-google-play")] + UploadGooglePlay { + /// Google Play service account JSON key file path + #[arg(long, env = "GOOGLE_PLAY_JSON_KEY_PATH")] + json_key_path: Option, + }, + /// Download Android screenshots into _scratch and delete them from the device #[command(name = "download-android-screenshots")] DownloadAndroidScreenshots, @@ -205,7 +221,7 @@ enum Commands { foreground: bool, }, - /// Bump iOS build, build release artifacts, and upload to TestFlight + /// Bump and upload to TestFlight, copy previous test notes, and add to me-only #[command(name = "testflight")] Testflight { /// App Store Connect API key file path @@ -221,7 +237,7 @@ enum Commands { api_issuer_id: Option, }, - /// Archive and upload the iOS app to TestFlight + /// Upload to TestFlight without bumping, copy previous test notes, and add to me-only #[command(name = "upload-testflight")] UploadTestflight { /// App Store Connect API key file path @@ -349,6 +365,14 @@ fn main() -> Result<()> { Commands::BundleAndroid => android::bundle_android(cli.verbose), + Commands::ReleaseAndroid { json_key_path } => { + android::release_android(json_key_path.as_deref(), cli.verbose) + } + + Commands::UploadGooglePlay { json_key_path } => { + android::upload_google_play(json_key_path.as_deref(), cli.verbose) + } + Commands::DownloadAndroidScreenshots => android::download_android_screenshots(), Commands::AndroidStayAwake { command } => android::run_with_stay_awake(&command), diff --git a/rust/xtask/src/util.rs b/rust/xtask/src/util.rs index 999ff1888..8d3c22924 100644 --- a/rust/xtask/src/util.rs +++ b/rust/xtask/src/util.rs @@ -55,7 +55,7 @@ pub fn sign_psbt( let external_desc = format!("wpkh({}/84'/{}'/0'/0/*)", xprv, coin_type); let internal_desc = format!("wpkh({}/84'/{}'/0'/1/*)", xprv, coin_type); - let wallet = Wallet::create(external_desc.clone(), internal_desc.clone()) + let wallet = Wallet::create(external_desc.clone(), internal_desc) .network(network) .create_wallet_no_persist()?; diff --git a/rust/xtask/src/version.rs b/rust/xtask/src/version.rs index c369aafa2..102178c1a 100644 --- a/rust/xtask/src/version.rs +++ b/rust/xtask/src/version.rs @@ -1,11 +1,14 @@ -use crate::common::{ensure_rust_directory, print_success, print_warning}; -use color_eyre::{eyre::ContextCompat, Result}; +use crate::common::{ensure_rust_directory, print_error, print_success, print_warning}; +use color_eyre::{ + eyre::{Context, ContextCompat}, + Result, +}; use colored::Colorize; use xshell::{cmd, Shell}; // Version file paths const CARGO_TOML_PATH: &str = "Cargo.toml"; -pub(crate) const IOS_PROJECT_PATH: &str = "../ios/Cove.xcodeproj/project.pbxproj"; +const IOS_PROJECT_PATH: &str = "../ios/Cove.xcodeproj/project.pbxproj"; const ANDROID_GRADLE_PATH: &str = "../android/app/build.gradle.kts"; pub fn bump_version(bump_type: String, targets_opt: Option) -> Result<()> { @@ -19,9 +22,10 @@ pub fn bump_version(bump_type: String, targets_opt: Option) -> Result<() let targets_str = targets_opt .as_ref() .filter(|s| !s.is_empty()) - .map(|s| s.as_str()) + .map(String::as_str) .unwrap_or_else(|| if is_build_bump { "ios,android" } else { "rust,ios,android" }); - let targets: Vec<&str> = targets_str.split(',').map(|s| s.trim()).collect(); + + let targets: Vec<&str> = targets_str.split(',').map(str::trim).collect(); // validate targets let valid_targets = @@ -196,19 +200,90 @@ fn prepare_android(sh: &Shell, current_version: &str, new_version: &str) -> Resu Ok(Some(new_gradle)) } -pub(crate) fn snapshot_ios_project(sh: &Shell) -> Result> { - if !sh.path_exists(IOS_PROJECT_PATH) { - return Ok(None); +/// Platform version file whose build number a store release consumes +#[derive(Debug, Clone, Copy)] +pub(crate) enum BuildNumberFile { + IosProject, + AndroidGradle, +} + +impl BuildNumberFile { + fn path(self) -> &'static str { + match self { + Self::IosProject => IOS_PROJECT_PATH, + Self::AndroidGradle => ANDROID_GRADLE_PATH, + } } - Ok(sh.read_file(IOS_PROJECT_PATH).map(Some)?) -} + fn build_number_label(self) -> &'static str { + match self { + Self::IosProject => "iOS build number", + Self::AndroidGradle => "Android versionCode", + } + } + + fn release_label(self) -> &'static str { + match self { + Self::IosProject => "TestFlight", + Self::AndroidGradle => "Google Play release", + } + } + + fn bump(self, sh: &Shell) -> Result<()> { + match self { + Self::IosProject => bump_ios_build_number(sh), + Self::AndroidGradle => bump_android_build_number(sh), + } + } -pub(crate) fn restore_ios_project(sh: &Shell, snapshot: &str) -> Result<()> { - Ok(sh.write_file(IOS_PROJECT_PATH, snapshot)?) + fn snapshot(self, sh: &Shell) -> Result> { + if !sh.path_exists(self.path()) { + return Ok(None); + } + + Ok(sh.read_file(self.path()).map(Some)?) + } + + fn restore(self, sh: &Shell, snapshot: &str) -> Result<()> { + Ok(sh.write_file(self.path(), snapshot)?) + } + + /// Bumps the build number, then runs the release steps that happen before the store may have + /// accepted the build, restoring the original file if the bump or any of those steps fail + /// + /// Steps that run once the store may hold the build, such as distribution or an upload whose + /// failure can still mean acceptance, belong after this call so the consumed build number is + /// kept + pub(crate) fn bump_for_release( + self, + sh: &Shell, + before_store_accepts: impl FnOnce() -> Result, + ) -> Result { + let snapshot = self.snapshot(sh)?; + let error = match self.bump(sh).and_then(|()| before_store_accepts()) { + Ok(value) => return Ok(value), + Err(error) => error, + }; + + let release = self.release_label(); + let build_number = self.build_number_label(); + let Some(snapshot) = snapshot else { + print_error(&format!("{release} failed")); + return Err(error); + }; + + if let Err(restore_error) = self.restore(sh, &snapshot) { + return Err(error).wrap_err(format!( + "Failed to restore {build_number} after {release} failure: {restore_error:#}" + )); + } + + print_error(&format!("{release} failed; restored {build_number}")); + Err(error) + } } -pub(crate) fn bump_ios_build_number(sh: &Shell) -> Result<()> { +fn bump_ios_build_number(sh: &Shell) -> Result<()> { if !sh.path_exists(IOS_PROJECT_PATH) { print_warning(&format!("iOS project file not found at {}", IOS_PROJECT_PATH)); return Ok(()); @@ -335,10 +410,8 @@ fn replace_u32_values( #[cfg(test)] mod tests { - use super::{ - bump_ios_build_number, increment_and_replace_ios, prepare_android, prepare_ios, - restore_ios_project, snapshot_ios_project, IOS_PROJECT_PATH, - }; + use super::{increment_and_replace_ios, prepare_android, prepare_ios, BuildNumberFile}; + use color_eyre::eyre::eyre; use xshell::Shell; #[test] @@ -407,17 +480,14 @@ CURRENT_PROJECT_VERSION = 76; } #[test] - fn restores_ios_project_snapshot_after_failed_testflight_flow() { - let unique_id = - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let temp_dir = std::env::temp_dir() - .join(format!("cove-xtask-version-test-{}-{unique_id}", std::process::id())); - let rust_dir = temp_dir.join("rust"); - let ios_project_dir = temp_dir.join("ios/Cove.xcodeproj"); + fn restores_ios_build_number_when_testflight_fails_before_store_accepts() { + let temp_dir = tempfile::tempdir().unwrap(); + let rust_dir = temp_dir.path().join("rust"); + let ios_project_dir = temp_dir.path().join("ios/Cove.xcodeproj"); std::fs::create_dir_all(&rust_dir).unwrap(); std::fs::create_dir_all(&ios_project_dir).unwrap(); - let project_path = temp_dir.join("ios/Cove.xcodeproj/project.pbxproj"); + let project_path = ios_project_dir.join("project.pbxproj"); let original = "\ CURRENT_PROJECT_VERSION = 89; CURRENT_PROJECT_VERSION = 89; @@ -427,13 +497,53 @@ CURRENT_PROJECT_VERSION = 89; let sh = Shell::new().unwrap(); sh.change_dir(&rust_dir); - let snapshot = snapshot_ios_project(&sh).unwrap().unwrap(); - bump_ios_build_number(&sh).unwrap(); - restore_ios_project(&sh, &snapshot).unwrap(); + let result = BuildNumberFile::IosProject.bump_for_release(&sh, || { + assert!(std::fs::read_to_string(&project_path).unwrap().contains("= 90;")); + Err::<(), _>(eyre!("upload failed")) + }); + assert!(result.is_err()); assert_eq!(std::fs::read_to_string(&project_path).unwrap(), original); - assert!(sh.path_exists(IOS_PROJECT_PATH)); + } + + #[test] + fn keeps_android_version_code_once_release_steps_succeed() { + let temp_dir = tempfile::tempdir().unwrap(); + let rust_dir = temp_dir.path().join("rust"); + let android_app_dir = temp_dir.path().join("android/app"); + std::fs::create_dir_all(&rust_dir).unwrap(); + std::fs::create_dir_all(&android_app_dir).unwrap(); + + let gradle_path = android_app_dir.join("build.gradle.kts"); + std::fs::write(&gradle_path, "versionCode = 28\nversionName = \"1.4.0\"\n").unwrap(); + + let sh = Shell::new().unwrap(); + sh.change_dir(&rust_dir); + + BuildNumberFile::AndroidGradle.bump_for_release(&sh, || Ok(())).unwrap(); + + assert!(std::fs::read_to_string(&gradle_path).unwrap().contains("versionCode = 29\n")); + } + + #[test] + fn restores_android_version_code_when_bundle_fails() { + let temp_dir = tempfile::tempdir().unwrap(); + let rust_dir = temp_dir.path().join("rust"); + let android_app_dir = temp_dir.path().join("android/app"); + std::fs::create_dir_all(&rust_dir).unwrap(); + std::fs::create_dir_all(&android_app_dir).unwrap(); + + let gradle_path = android_app_dir.join("build.gradle.kts"); + let original = "versionCode = 28\nversionName = \"1.4.0\"\n"; + std::fs::write(&gradle_path, original).unwrap(); + + let sh = Shell::new().unwrap(); + sh.change_dir(&rust_dir); + + let result = BuildNumberFile::AndroidGradle + .bump_for_release(&sh, || Err::<(), _>(eyre!("bundle failed"))); - std::fs::remove_dir_all(&temp_dir).unwrap(); + assert!(result.is_err()); + assert_eq!(std::fs::read_to_string(&gradle_path).unwrap(), original); } }