diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index f81ad074e..db0798228 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -1,8 +1,9 @@ package dev.obiente.nextcloudnative import android.content.Context -import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer import dev.obiente.nextcloudnative.app.AccountPrivateMemoryCleanup +import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer +import dev.obiente.nextcloudnative.app.DynamicNativeMemoryAccountLifecycle import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.durableMutationAccountScope import dev.obiente.nextcloudnative.app.removeAndroidHomeWorkspaceAccountPreferences @@ -43,7 +44,14 @@ internal class AndroidAccountOwnedStateCleanup( cacheIdentity, clearPreviewAccount, listOf( - { fenceAndroidDynamicApiStateForRemoval(cacheIdentity, dynamicApiState.coalescer, dynamicApiState.cache) }, + { + fenceAndroidDynamicApiStateForRemoval( + cacheIdentity, + dynamicApiState.coalescer, + dynamicApiState.cache, + session.accountId.storageKey, + ) + }, { dynamicDiscoveryCache.retireAccount(session.accountId.storageKey, cacheIdentity) }, { removeSupportAccount(accountIdentity) }, { @@ -83,8 +91,15 @@ internal class AndroidAccountOwnedStateCleanup( clearPreviewAccount, listOf( { - previewCacheIdentity?.let { identity -> - fenceAndroidDynamicApiStateForRemoval(identity, dynamicApiState.coalescer, dynamicApiState.cache) + if (previewCacheIdentity == null) { + DynamicNativeMemoryAccountLifecycle.retireAccount(session.accountId.storageKey) + } else { + fenceAndroidDynamicApiStateForRemoval( + previewCacheIdentity, + dynamicApiState.coalescer, + dynamicApiState.cache, + session.accountId.storageKey, + ) } }, { dynamicDiscoveryCache.retireAccount(session.accountId.storageKey, previewCacheIdentity) }, @@ -126,8 +141,15 @@ internal class AndroidAccountOwnedStateCleanup( clearPreviewAccount, listOf( { - previewCacheIdentity?.let { identity -> - fenceAndroidDynamicApiStateForRemoval(identity, dynamicApiState.coalescer, dynamicApiState.cache) + if (previewCacheIdentity == null) { + DynamicNativeMemoryAccountLifecycle.retireAccount(accountStorageKey) + } else { + fenceAndroidDynamicApiStateForRemoval( + previewCacheIdentity, + dynamicApiState.coalescer, + dynamicApiState.cache, + accountStorageKey, + ) } }, { dynamicDiscoveryCache.retireAccount(accountStorageKey, previewCacheIdentity) }, @@ -156,20 +178,28 @@ internal class AndroidAccountOwnedStateCleanup( ), ) } + } internal suspend fun clearAndroidDynamicApiState( accountIdentity: String, coalescer: DynamicApiRequestCoalescer, cache: DynamicApiResponseCache, -) = coalescer.fenceAccount(accountIdentity) { cache.invalidateAccount(accountIdentity) } + accountStorageKey: String? = null, + retireMemoryAccount: (String) -> Unit = DynamicNativeMemoryAccountLifecycle::retireAccount, +) = coalescer.fenceAccount(accountIdentity) { + accountStorageKey?.let(retireMemoryAccount) + cache.invalidateAccount(accountIdentity) +} internal suspend fun fenceAndroidDynamicApiStateForRemoval( accountIdentity: String, coalescer: DynamicApiRequestCoalescer, cache: DynamicApiResponseCache, + accountStorageKey: String? = null, + retireMemoryAccount: (String) -> Unit = DynamicNativeMemoryAccountLifecycle::retireAccount, ) = withContext(NonCancellable) { - clearAndroidDynamicApiState(accountIdentity, coalescer, cache) + clearAndroidDynamicApiState(accountIdentity, coalescer, cache, accountStorageKey, retireMemoryAccount) } internal suspend fun runAndroidAccountOwnedStateCleanups( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivation.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivation.kt new file mode 100644 index 000000000..0c59fec7e --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivation.kt @@ -0,0 +1,16 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer +import dev.obiente.nextcloudnative.app.DynamicNativeMemoryAccountLifecycle +import dev.obiente.nextcloudnative.app.NextcloudApiResponse +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal class AndroidDynamicAccountActivation( + private val coalescer: DynamicApiRequestCoalescer, + private val activateMemory: (String) -> Unit = DynamicNativeMemoryAccountLifecycle::activateAccount, +) { + suspend fun afterCredentialSave(persistedSession: NextcloudSession) { + activateMemory(persistedSession.accountId.storageKey) + coalescer.activateAccount(NextcloudDocumentIds.cacheAccountId(persistedSession)) + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 261e9b25a..3af08d9f5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -14,7 +14,6 @@ import android.provider.Settings import android.util.Base64 import android.util.Log import dev.obiente.nextcloudnative.app.AcquiredOpenApiContract -import dev.obiente.nextcloudnative.app.AccountPrivateMemoryLifecycle import dev.obiente.nextcloudnative.app.AcquiredOpenApiContractSourceKind import dev.obiente.nextcloudnative.app.AcquiredContractKind import dev.obiente.nextcloudnative.app.DeckAttachment @@ -464,6 +463,7 @@ internal class AndroidNextcloudServices( diagnostics = supportDiagnostics, client = httpClient, ) + private val dynamicAccountActivation = AndroidDynamicAccountActivation(dynamicApiRequestCoalescer) private val accountCredentials = AndroidAccountCredentialController( context = appContext, preferences = preferences, @@ -482,8 +482,7 @@ internal class AndroidNextcloudServices( retryQueuedUploadsCleanup = accountOwnedStateCleanup::retry, retryQueuedUploadsCleanupWithoutCredentials = accountOwnedStateCleanup::retryWithoutCredentials, activatePersistedAccount = { session -> - dynamicApiRequestCoalescer.activateAccount(NextcloudDocumentIds.cacheAccountId(session)) - AccountPrivateMemoryLifecycle.activateAccount(session.accountId.storageKey) + dynamicAccountActivation.afterCredentialSave(session) dynamicDiscoveryCache.activateAccount(session.accountId.storageKey) }, ) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivationTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivationTest.kt new file mode 100644 index 000000000..fabd2f591 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivationTest.kt @@ -0,0 +1,34 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer +import dev.obiente.nextcloudnative.app.NextcloudApiResponse +import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.runBlocking + +class AndroidDynamicAccountActivationTest { + @Test + fun currentCredentialSaveReopensBothDynamicCaches() = runBlocking { + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val cacheAccountId = NextcloudDocumentIds.cacheAccountId(session) + val coalescer = DynamicApiRequestCoalescer() + coalescer.fenceAccount(cacheAccountId) {} + var activatedMemoryAccount: String? = null + val activation = AndroidDynamicAccountActivation( + coalescer = coalescer, + activateMemory = { activatedMemoryAccount = it }, + ) + + activation.afterCredentialSave(session) + + assertEquals(session.accountId.storageKey, activatedMemoryAccount) + assertEquals( + 200, + coalescer.execute(cacheAccountId, "GET /status", load = { + NextcloudApiResponse(200, byteArrayOf(), null, null) + }).status, + ) + } + +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt index 341f76c41..50a630fcd 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt @@ -3,6 +3,7 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer import dev.obiente.nextcloudnative.app.NextcloudApiCachePolicy import dev.obiente.nextcloudnative.app.NextcloudApiResponse +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.contracts.CachedDynamicApiResponse import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache import java.nio.file.Files @@ -16,6 +17,7 @@ import kotlinx.coroutines.supervisorScope import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails +import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertSame @@ -103,6 +105,8 @@ class AndroidDynamicApiCachePolicyTest { val requestIdentity = "GET /dashboard/widgets" val cache = DynamicApiResponseCache(root) val coalescer = DynamicApiRequestCoalescer() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + var retiredStorageKey: String? = null val started = CompletableDeferred() val release = CompletableDeferred() val response = CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null) @@ -116,11 +120,18 @@ class AndroidDynamicApiCachePolicyTest { } started.await() - clearAndroidDynamicApiState(accountId, coalescer, cache) + clearAndroidDynamicApiState( + accountId, + coalescer, + cache, + session.accountId.storageKey, + { retiredStorageKey = it }, + ) release.complete(Unit) assertFails { read.await() } assertNull(cache.load(accountId, requestIdentity, 1_024)) + assertEquals(session.accountId.storageKey, retiredStorageKey) } finally { root.deleteRecursively() } @@ -156,6 +167,37 @@ class AndroidDynamicApiCachePolicyTest { } } + @Test + fun `Android memory retirement survives a rejected disk cache purge`(): Unit = runBlocking { + val root = Files.createTempDirectory("android-dynamic-cache-rejected-cleanup-").toFile() + try { + val accountId = "e".repeat(64) + val accountDirectory = root.resolve(accountId).apply { mkdirs() } + accountDirectory.resolve("unsafe-entry").mkdir() + val coalescer = DynamicApiRequestCoalescer() + val cache = DynamicApiResponseCache(root) + val accountStorageKey = "f".repeat(64) + var retiredStorageKey: String? = null + + assertFailsWith { + clearAndroidDynamicApiState( + accountId, + coalescer, + cache, + accountStorageKey, + { retiredStorageKey = it }, + ) + } + + assertEquals(accountStorageKey, retiredStorageKey) + assertFailsWith { + coalescer.execute(accountId, "GET /dashboard/widgets", load = { error("must remain fenced") }) + } + } finally { + root.deleteRecursively() + } + } + @Test fun `second Android service cannot commit a GET that crossed account removal`() = runBlocking { supervisorScope { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt index 8282725f1..de5477279 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt @@ -17,8 +17,8 @@ class AndroidDynamicDiscoveryCacheRetirementTest { val removedCacheId = "1".repeat(64) val retainedStorageKey = "b".repeat(64) val retainedCacheId = "2".repeat(64) - val removedProducer = DynamicNativeMemoryCacheProducer(removedStorageKey, 0L) - val retainedProducer = DynamicNativeMemoryCacheProducer(retainedStorageKey, 0L) + val removedProducer = producerForTest(removedStorageKey, 0L) + val retainedProducer = producerForTest(retainedStorageKey, 0L) try { cache.save(removedStorageKey, removedCacheId, "deck", "removed", removedProducer) cache.save(retainedStorageKey, retainedCacheId, "deck", "retained", retainedProducer) @@ -34,7 +34,7 @@ class AndroidDynamicDiscoveryCacheRetirementTest { cache.save( removedStorageKey, removedCacheId, "deck", "current", - DynamicNativeMemoryCacheProducer(removedStorageKey, 1L), + producerForTest(removedStorageKey, 1L), ) assertEquals("current", cache.load(removedStorageKey, removedCacheId, "deck")) } finally { @@ -49,11 +49,11 @@ class AndroidDynamicDiscoveryCacheRetirementTest { try { cache.save( "a".repeat(64), "1".repeat(64), "deck", "first", - DynamicNativeMemoryCacheProducer("a".repeat(64), 0L), + producerForTest("a".repeat(64), 0L), ) cache.save( "b".repeat(64), "2".repeat(64), "talk", "second", - DynamicNativeMemoryCacheProducer("b".repeat(64), 0L), + producerForTest("b".repeat(64), 0L), ) cache.retireAccount("a".repeat(64), null) @@ -63,4 +63,9 @@ class AndroidDynamicDiscoveryCacheRetirementTest { root.deleteRecursively() } } + + private fun producerForTest(accountStorageKey: String, incarnation: Long): DynamicNativeMemoryCacheProducer = + DynamicNativeMemoryCacheProducer::class.java + .getDeclaredConstructor(String::class.java, java.lang.Long.TYPE) + .newInstance(accountStorageKey, incarnation) } diff --git a/changes/unreleased/dynamic-memory-account-retirement.md b/changes/unreleased/dynamic-memory-account-retirement.md new file mode 100644 index 000000000..6f1c43632 --- /dev/null +++ b/changes/unreleased/dynamic-memory-account-retirement.md @@ -0,0 +1,7 @@ +category: fix +issue: 172 +pull: none +platforms: android, desktop +user-facing: yes + +Account removal clears dynamic app memory and rejects late responses even after the account is added again. Pending cleanup from older desktop versions no longer prevents unrelated accounts from loading. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index ed786333c..671cd17fd 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,5 +1,5 @@ androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt|851 -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4230 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4229 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|995 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 @@ -25,7 +25,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1883 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12348 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12332 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1717 @@ -53,7 +53,7 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.k ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|884 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|883 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt|808 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6236 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6216 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2759 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1085 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt index 6a4bbb87a..32efe56c6 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt @@ -186,7 +186,7 @@ internal class DynamicNativeMemoryCache( } } -data class DynamicNativeMemoryCacheProducer( +class DynamicNativeMemoryCacheProducer internal constructor( val accountStorageKey: String, val incarnation: Long, ) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt index cb2430141..5d12cf8b5 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt @@ -20,22 +20,24 @@ class AccountPrivateMemoryCleanupTest { AccountPrivateMemoryLifecycle.activateAccount(retainedKey) val removedPreview = PreviewCacheKey(removedKey, "core", 1L, "etag", 64, 64) val retainedPreview = PreviewCacheKey(retainedKey, "core", 2L, "etag", 64, 64) + val removedDynamicKey = dynamicKey(removed) + val retainedDynamicKey = dynamicKey(retained) val removedPhotoState = PhotoTimelineUiStateRepository.stateFor(removed) val retainedPhotoState = PhotoTimelineUiStateRepository.stateFor(retained) val removedProducer = sharedAccountPrivateMemoryGate.producer(removedKey) val retainedProducer = sharedAccountPrivateMemoryGate.producer(retainedKey) - val removedDynamicProducer = sharedDynamicNativeMemoryCache.producer(removed) - val retainedDynamicProducer = sharedDynamicNativeMemoryCache.producer(retained) + val removedDynamicProducer = requireNotNull(sharedDynamicNativeMemoryCache.producer(removedDynamicKey)) + val retainedDynamicProducer = requireNotNull(sharedDynamicNativeMemoryCache.producer(retainedDynamicKey)) try { PreviewMemoryCache.put(removedPreview, byteArrayOf(1), removedProducer) PreviewMemoryCache.put(retainedPreview, byteArrayOf(2), retainedProducer) sharedNextcloudNotesCache.storeDetail(removed, note(1L, "Removed"), removedProducer) sharedNextcloudNotesCache.storeDetail(retained, note(2L, "Retained"), retainedProducer) sharedDynamicNativeMemoryCache.storeScreen( - dynamicKey(removed), dynamicSnapshot(1), removedDynamicProducer, + removedDynamicKey, dynamicSnapshot(1), removedDynamicProducer, ) sharedDynamicNativeMemoryCache.storeScreen( - dynamicKey(retained), dynamicSnapshot(2), retainedDynamicProducer, + retainedDynamicKey, dynamicSnapshot(2), retainedDynamicProducer, ) sharedDashboardStatusMemoryCache.store( removed, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L, removedProducer, @@ -76,8 +78,8 @@ class AccountPrivateMemoryCleanupTest { assertContentEquals(byteArrayOf(2), PreviewMemoryCache.get(retainedPreview)) assertNull(sharedNextcloudNotesCache.detail(removed, 1L)) assertEquals("Retained", sharedNextcloudNotesCache.detail(retained, 2L)?.title) - assertNull(sharedDynamicNativeMemoryCache.screen(dynamicKey(removed))) - assertNotNull(sharedDynamicNativeMemoryCache.screen(dynamicKey(retained))) + assertNull(sharedDynamicNativeMemoryCache.screen(removedDynamicKey)) + assertNotNull(sharedDynamicNativeMemoryCache.screen(retainedDynamicKey)) assertNull(sharedDashboardStatusMemoryCache.get(removed, 1L)) assertNotNull(sharedDashboardStatusMemoryCache.get(retained, 1L)) assertNull(ContactsWorkspaceMemoryCache.get(removed, "removed")) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt index 7c6762b25..f678e55ab 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt @@ -84,6 +84,32 @@ internal fun virtualFileCachePreferenceKey(prefix: String, accountId: String): S return desktopAccountPreferenceKey(prefix, accountId) } +internal fun desktopVirtualFileProviderLocation( + preferences: Preferences, + accountId: String, + userHome: File = File(System.getProperty("user.home")), +): VirtualFileProviderLocation { + val stored = preferences.get(virtualFileProviderRootPreferenceKey(accountId), null) + ?.takeIf { path -> path.length <= Preferences.MAX_VALUE_LENGTH } + ?.let(::File) + ?.absoluteFile + ?.normalize() + val folderName = stored?.name?.takeIf(String::isValidVirtualFileProviderFolderName) + val parent = stored?.parentFile + return if (folderName != null && parent != null) { + VirtualFileProviderLocation(parent.absolutePath, folderName) + } else { + VirtualFileProviderLocation(userHome.absolutePath, "Nextcloud Native") + } +} + +internal fun desktopLinuxVirtualFileMountPoint( + preferences: Preferences, + accountId: String, +): File = desktopVirtualFileProviderLocation(preferences, accountId).let { location -> + File(location.parentPath, location.folderName).absoluteFile.normalize() +} + private fun desktopAccountPreferenceKey(prefix: String, accountId: String): String { require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) return "$prefix$accountId".also { key -> check(key.length <= Preferences.MAX_KEY_LENGTH) } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 9de08d2fe..410e9464a 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -11,17 +11,24 @@ import kotlinx.coroutines.withContext internal const val DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE = "This account has cleanup state written by a newer app version." +internal const val DESKTOP_PENDING_CLEANUP_STATE_MESSAGE = + "Previous account cleanup must finish before this account can be added again." -internal fun unknownCleanupStateRejection() = - VirtualFileStorageActionResult.Rejected(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE) +internal enum class DesktopAccountActivationBlock(val message: String) { + PendingCleanup(DESKTOP_PENDING_CLEANUP_STATE_MESSAGE), + UnknownJournalData(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE), +} + +internal fun desktopAccountCleanupStateRejection(block: DesktopAccountActivationBlock) = + VirtualFileStorageActionResult.Rejected(block.message) -internal fun requireDesktopAccountActivationAllowed(blockedByUnknownCleanup: Boolean) { - check(!blockedByUnknownCleanup) { DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE } +internal fun requireDesktopAccountActivationAllowed(block: DesktopAccountActivationBlock?) { + check(block == null) { block?.message.orEmpty() } } internal fun DesktopAccountSyncPairCleanupJournal.requireAccountActivationAllowed(record: NextcloudAccountRecord) = requireDesktopAccountActivationAllowed( - blocksAccountActivation(desktopFileCacheAccountId(record), record.id.storageKey), + accountActivationBlock(desktopFileCacheAccountId(record), record.id.storageKey), ) internal fun loadDesktopSessionAfterCleanupGate( @@ -105,25 +112,54 @@ internal class DesktopAccountSyncPairCleanupJournal( preferences.flush() } - fun blocksAccountActivation(accountId: String, accountStorageKey: String? = null): Boolean { + fun blocksAccountActivation(accountId: String, accountStorageKey: String? = null): Boolean = + accountActivationBlock(accountId, accountStorageKey) != null + + fun accountActivationBlock( + accountId: String, + accountStorageKey: String? = null, + ): DesktopAccountActivationBlock? { validateDesktopSyncPairCleanupAccountId(accountId) require(accountStorageKey == null || accountStorageKey.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { "The desktop account storage cleanup identity is invalid." } - val blocked = if (accountStorageKey == null) { - val encoded = preferences.get(cleanupKey(accountId), null) - encoded != null && decode(accountId, encoded).phase == DesktopAccountSyncPairCleanupPhase.Unknown - } else { - pending().any { cleanup -> - cleanup.phase == DesktopAccountSyncPairCleanupPhase.Unknown && - (cleanup.accountStorageKey == null || cleanup.matchesAccountActivation(accountId, accountStorageKey)) + var malformedEntryFound = false + var matchingEntryFound = false + preferences.keys() + .asSequence() + .filter { key -> key.startsWith(KEY_PREFIX) } + .forEach { key -> + val storedAccountId = key.removePrefix(KEY_PREFIX) + val cleanup = runCatching { + validateDesktopSyncPairCleanupAccountId(storedAccountId) + decode(storedAccountId, preferences.get(key, null)) + }.getOrNull() + if (cleanup == null) { + malformedEntryFound = true + } else if ( + cleanup.phase == DesktopAccountSyncPairCleanupPhase.Unknown && + (cleanup.accountStorageKey == null || + accountStorageKey != null && cleanup.matchesAccountActivation(accountId, accountStorageKey) || + accountStorageKey == null && cleanup.accountId == accountId) + ) { + malformedEntryFound = true + } else if ( + cleanup.phase != DesktopAccountSyncPairCleanupPhase.Unknown && + (cleanup.accountId == accountId || + accountStorageKey != null && cleanup.accountStorageKey == accountStorageKey) + ) { + matchingEntryFound = true + } } + if (malformedEntryFound) recordMalformedOnce() + return when { + malformedEntryFound -> DesktopAccountActivationBlock.UnknownJournalData + matchingEntryFound -> DesktopAccountActivationBlock.PendingCleanup + else -> null } - if (blocked) recordMalformedOnce() - return blocked } - fun blocksAllAccountActivation(): Boolean { + fun blocksAllAccountActivation(): DesktopAccountActivationBlock? { val blocked = preferences.keys().asSequence() .filter { key -> key.startsWith(KEY_PREFIX) } .any { key -> @@ -136,7 +172,7 @@ internal class DesktopAccountSyncPairCleanupJournal( cleanup.phase == DesktopAccountSyncPairCleanupPhase.Unknown && cleanup.accountStorageKey == null } if (blocked) recordMalformedOnce() - return blocked + return DesktopAccountActivationBlock.UnknownJournalData.takeIf { blocked } } fun pending(): List { @@ -188,6 +224,9 @@ internal class DesktopAccountSyncPairCleanupJournal( require(legacyAccountScopeDigest == null || legacyAccountScopeDigest.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { "The desktop legacy workspace cleanup identity is invalid." } + require(accountStorageKey == null || durableMutationAccountScope != null) { + "The desktop account cleanup requires its durable mutation identity." + } val key = cleanupKey(accountId) val current = preferences.get(key, null)?.let { decode(accountId, it) } check(current == null || current.phase != DesktopAccountSyncPairCleanupPhase.Unknown) { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index b32a2cddc..73dde109d 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -132,32 +132,6 @@ private const val VIRTUAL_FILE_OVERFLOW_PREFERENCE_VERSION = "v2" private fun isLinuxDesktop(): Boolean = System.getProperty("os.name").orEmpty().lowercase().contains("linux") -private fun desktopVirtualFileProviderLocation( - preferences: Preferences, - accountId: String, - userHome: File = File(System.getProperty("user.home")), -): VirtualFileProviderLocation { - val stored = preferences.get(virtualFileProviderRootPreferenceKey(accountId), null) - ?.takeIf { path -> path.length <= Preferences.MAX_VALUE_LENGTH } - ?.let(::File) - ?.absoluteFile - ?.normalize() - val folderName = stored?.name?.takeIf(String::isValidVirtualFileProviderFolderName) - val parent = stored?.parentFile - return if (folderName != null && parent != null) { - VirtualFileProviderLocation(parent.absolutePath, folderName) - } else { - VirtualFileProviderLocation(userHome.absolutePath, "Nextcloud Native") - } -} - -private fun desktopLinuxVirtualFileMountPoint( - preferences: Preferences, - accountId: String, -): File = desktopVirtualFileProviderLocation(preferences, accountId).let { location -> - File(location.parentPath, location.folderName).absoluteFile.normalize() -} - private data class DesktopVirtualFileCacheTiers( val configuration: VirtualFileCacheTierConfiguration, val primaryIdentity: String?, @@ -826,7 +800,7 @@ class DesktopNextcloudServices( accountId: String, cache: DesktopVirtualRangeCache, ) { - if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId, session.accountId.storageKey)) return if (sessionClearing) return if (synchronized(virtualFileProviderLock) { accountId in virtualFileCacheTierMutations }) return if (cache.hasUnavailableRetainedOverflowRecords(accountId, relativePath)) return @@ -1345,7 +1319,7 @@ class DesktopNextcloudServices( if (!isLinuxDesktop()) return session ?: return val accountId = desktopFileCacheAccountId(session) - if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId, session.accountId.storageKey)) return val cache = virtualRangeCache(accountId) val kept = cache.loadFolderRetention(accountId).rules.filter { rule -> rule.retention == VirtualFolderRetention.KeepOnDevice @@ -1681,7 +1655,9 @@ class DesktopNextcloudServices( } val accountId = desktopFileCacheAccountId(session) val cacheProducer = fileReadCache.producer(accountId) - if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return unknownCleanupStateRejection() + accountSyncPairCleanupJournal.accountActivationBlock(accountId, session.accountId.storageKey)?.let { + return desktopAccountCleanupStateRejection(it) + } runCatching(linuxProviderCleanup::retry).exceptionOrNull()?.let { return VirtualFileStorageActionResult.Rejected(it.message ?: "The earlier Linux mount is still active.") } @@ -2707,8 +2683,8 @@ class DesktopNextcloudServices( ?: return@syncRun FileSyncCenterActionResult.Rejected("Sign in before syncing folders.") val accountId = desktopFileCacheAccountId(session) diagnosticAccountId = accountId - if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) { - return@syncRun FileSyncCenterActionResult.Rejected(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE) + accountSyncPairCleanupJournal.accountActivationBlock(accountId, session.accountId.storageKey)?.let { + return@syncRun FileSyncCenterActionResult.Rejected(it.message) } val userId = runCatching { loadServerInfo(session).userId }.getOrElse { failure -> return@syncRun FileSyncCenterActionResult.Rejected( @@ -3811,7 +3787,7 @@ class DesktopNextcloudServices( ) } requireDesktopAccountActivationAllowed( - accountSyncPairCleanupJournal.blocksAccountActivation(accountId, accountStorageKey), + accountSyncPairCleanupJournal.accountActivationBlock(accountId, accountStorageKey), ) } private suspend fun retryPendingAccountSyncPairCleanups() = @@ -3844,7 +3820,9 @@ class DesktopNextcloudServices( private suspend fun removeDesktopAccountOwnedState(cleanup: DesktopAccountSyncPairCleanup) { val accountId = cleanup.accountId dynamicDiscoveryCache.retireAccount(cleanup.accountStorageKey, accountId) - clearDesktopDynamicApiState(accountId, dynamicApiRequestCoalescer, dynamicApiReadCache) + clearDesktopDynamicApiState( + accountId, dynamicApiRequestCoalescer, dynamicApiReadCache, cleanup.accountStorageKey, + ) supportIntake.removeAccount(accountId) removeDesktopPendingDynamicMutations(pendingDynamicMutationDirectory, accountId) cleanup.durableMutationAccountScope?.let(durableMutationRecovery::removeAccount) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt index 81c06452b..a76825ebe 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt @@ -17,7 +17,12 @@ internal suspend fun clearDesktopDynamicApiState( accountId: String, coalescer: DynamicApiRequestCoalescer, cache: DynamicApiResponseCache, -) = coalescer.fenceAccount(accountId) { cache.invalidateAccount(accountId) } + accountStorageKey: String? = null, + memoryCache: DynamicNativeMemoryCache = sharedDynamicNativeMemoryCache, +) = coalescer.fenceAccount(accountId) { + accountStorageKey?.let(memoryCache::retireAccount) + cache.invalidateAccount(accountId) +} internal fun desktopPendingDynamicMutationDirectory( osName: String = System.getProperty("os.name").orEmpty(), diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRecoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRecoveryTest.kt new file mode 100644 index 000000000..3fb22a6b7 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRecoveryTest.kt @@ -0,0 +1,149 @@ +package dev.obiente.nextcloudnative.app + +import java.util.UUID +import java.util.prefs.Preferences +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopAccountCleanupRecoveryTest { + @Test + fun preparedCleanupFromAnAbortedRemovalPreservesExistingPairs() = runBlocking { + val events = mutableListOf() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val memoryCache = DynamicNativeMemoryCache() + memoryCache.retireAccount(session.accountId.storageKey) + + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Prepared, + MUTATION_SCOPE, + session.accountId.storageKey, + ), + accountOwnership = { DesktopAccountOwnership.Present }, + removeSyncPairs = { events += "remove-pairs" }, + clearCleanup = { events += "clear-cleanup" }, + reactivatePresentAccount = { + events += "activate-memory" + memoryCache.activateAccount(requireNotNull(it.accountStorageKey)) + }, + ) + + assertEquals(listOf("clear-cleanup", "activate-memory"), events) + assertTrue(memoryCache.producer(session) != null) + } + + @Test + fun preparedCleanupPreservesPairsAndJournalWhenCredentialOwnershipIsUnknown() = runBlocking { + val events = mutableListOf() + + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Prepared, + ), + accountOwnership = { DesktopAccountOwnership.Unknown }, + removeSyncPairs = { events += "remove-pairs" }, + clearCleanup = { events += "clear-cleanup" }, + reactivatePresentAccount = { events += "activate-memory" }, + ) + + assertTrue(events.isEmpty()) + } + + @Test + fun futureCleanupFormatRemainsBlockedAndUntouchedWhenCredentialsAreAbsent() = runBlocking { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val futureValue = "v99|committed|future-private-state" + preferences.put("fsac.$CLEANUP_ACCOUNT_ID", futureValue) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + var ownershipChecks = 0 + val events = mutableListOf() + + try { + val cleanup = journal.pending().single() + assertEquals(DesktopAccountSyncPairCleanupPhase.Unknown, cleanup.phase) + assertTrue(journal.blocksAccountActivation(CLEANUP_ACCOUNT_ID)) + + retryDesktopAccountSyncPairCleanup( + cleanup = cleanup, + accountOwnership = { + ownershipChecks += 1 + DesktopAccountOwnership.Absent + }, + removeSyncPairs = { events += "remove-pairs" }, + clearCleanup = { events += "clear-cleanup" }, + ) + + assertEquals(0, ownershipChecks) + assertTrue(events.isEmpty()) + assertEquals(futureValue, preferences.get("fsac.$CLEANUP_ACCOUNT_ID", null)) + assertTrue(journal.blocksAccountActivation(CLEANUP_ACCOUNT_ID)) + assertTrue(journal.blocksAccountActivation("9".repeat(64), ACCOUNT_STORAGE_KEY)) + } finally { + preferences.removeNode() + } + } + + @Test + fun preparedCleanupUsesCredentialFreeOwnershipToRecover() = runBlocking { + val absentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Prepared, + ), + accountOwnership = { DesktopAccountOwnership.Absent }, + removeSyncPairs = { absentEvents += "remove-pairs" }, + clearCleanup = { absentEvents += "clear-cleanup" }, + ) + assertEquals(listOf("remove-pairs", "clear-cleanup"), absentEvents) + + val presentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Prepared, + ), + accountOwnership = { DesktopAccountOwnership.Present }, + removeSyncPairs = { presentEvents += "remove-pairs" }, + clearCleanup = { presentEvents += "clear-cleanup" }, + ) + assertEquals(listOf("clear-cleanup"), presentEvents) + } + + @Test + fun malformedCleanupRemainsFailClosedRegardlessOfCredentialOwnership() = runBlocking { + val absentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Unknown, + ), + accountOwnership = { DesktopAccountOwnership.Absent }, + removeSyncPairs = { absentEvents += "remove-pairs" }, + clearCleanup = { absentEvents += "clear-cleanup" }, + ) + assertTrue(absentEvents.isEmpty()) + + val presentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Unknown, + ), + accountOwnership = { DesktopAccountOwnership.Present }, + removeSyncPairs = { presentEvents += "remove-pairs" }, + clearCleanup = { presentEvents += "clear-cleanup" }, + ) + assertTrue(presentEvents.isEmpty()) + } + + private companion object { + const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + const val MUTATION_SCOPE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const val ACCOUNT_STORAGE_KEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt index 8c74e0738..f0611d609 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt @@ -7,7 +7,9 @@ import java.util.prefs.Preferences import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertTrue class DesktopAccountMemoryRetirementTest { @@ -109,4 +111,108 @@ class DesktopAccountMemoryRetirementTest { root.deleteRecursively() } } + + @Test + fun unknownCredentialRemovalFailureKeepsMemoryActive() = runBlocking { + var memoryRetired = false + + assertFailsWith { + removeDesktopAccountBeforeSyncPairCleanup( + accountId = ACCOUNT_ID, + prepareCleanup = { _, _, _, _ -> }, + commitCleanup = {}, + clearCleanup = {}, + accountOwnership = { DesktopAccountOwnership.Unknown }, + removeCredential = { error("credential removal outcome is unknown") }, + removeSyncPairs = {}, + retireCommittedAccount = { memoryRetired = true }, + recordCleanupFailure = {}, + ) + } + + assertFalse(memoryRetired) + } + + @Test + fun journalReportsPendingCleanupSeparatelyFromUnknownData() { + val preferences = Preferences.userRoot().node("desktop-memory-cleanup-test-${UUID.randomUUID()}") + try { + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + journal.prepare(ACCOUNT_ID, MUTATION_SCOPE, ACCOUNT_STORAGE_KEY) + + val block = journal.accountActivationBlock(ACCOUNT_ID, ACCOUNT_STORAGE_KEY) + assertEquals(DesktopAccountActivationBlock.PendingCleanup, block) + val failure = assertFailsWith { + requireDesktopAccountActivationAllowed(block) + } + assertEquals(DESKTOP_PENDING_CLEANUP_STATE_MESSAGE, failure.message) + + preferences.put("fsac.$ACCOUNT_ID", "future-phase") + val unknownBlock = journal.accountActivationBlock(ACCOUNT_ID, ACCOUNT_STORAGE_KEY) + assertEquals(DesktopAccountActivationBlock.UnknownJournalData, unknownBlock) + val unknownFailure = assertFailsWith { + requireDesktopAccountActivationAllowed(unknownBlock) + } + assertEquals(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE, unknownFailure.message) + } finally { + preferences.removeNode() + } + } + + @Test + fun v3CleanupBlocksCanonicalEquivalentAccountActivationByStorageKey() { + val preferences = Preferences.userRoot().node("desktop-memory-cleanup-test-${UUID.randomUUID()}") + val original = NextcloudSession("https://cloud.example.test", "alice", "password") + val equivalent = original.copy(serverUrl = "https://CLOUD.EXAMPLE.TEST:443/") + val originalProviderId = desktopFileCacheAccountId(original) + val equivalentProviderId = desktopFileCacheAccountId(equivalent) + try { + assertEquals(original.accountId, equivalent.accountId) + assertNotEquals(originalProviderId, equivalentProviderId) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + journal.prepare(originalProviderId, MUTATION_SCOPE, original.accountId.storageKey) + + assertTrue(journal.blocksAccountActivation(equivalentProviderId, equivalent.accountId.storageKey)) + assertFailsWith { + journal.requireAccountActivationAllowed(equivalent.accountRecord()) + } + } finally { + preferences.removeNode() + } + } + + @Test + fun legacyCleanupBlocksOnlyItsKnownAccountIdentity() { + val original = NextcloudSession("https://cloud.example.test", "alice", "password") + val unrelated = original.copy(loginName = "bob") + val originalProviderId = desktopFileCacheAccountId(original) + val unrelatedProviderId = desktopFileCacheAccountId(unrelated) + assertNotEquals(originalProviderId, unrelatedProviderId) + + listOf("prepared", "committed", "v2|prepared|$MUTATION_SCOPE", "v2|committed|$MUTATION_SCOPE").forEach { encoded -> + val preferences = Preferences.userRoot().node("desktop-memory-cleanup-test-${UUID.randomUUID()}") + try { + preferences.put("fsac.$originalProviderId", encoded) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + + assertTrue(journal.blocksAccountActivation(originalProviderId)) + assertTrue(journal.blocksAccountActivation(originalProviderId, original.accountId.storageKey)) + assertFalse(journal.blocksAccountActivation(unrelatedProviderId)) + assertFalse(journal.blocksAccountActivation(unrelatedProviderId, unrelated.accountId.storageKey)) + assertFailsWith { + journal.requireAccountActivationAllowed(original.accountRecord()) + } + journal.requireAccountActivationAllowed(unrelated.accountRecord()) + assertEquals(encoded, preferences.get("fsac.$originalProviderId", null)) + } finally { + preferences.removeNode() + } + } + } + + private companion object { + const val ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + const val ACCOUNT_STORAGE_KEY = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + const val MUTATION_SCOPE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 35409227c..3867681b7 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred @@ -63,6 +64,10 @@ class DesktopAccountOperationGuardTest { val finishPersistence = CompletableDeferred() val events = mutableListOf() val session = NextcloudSession("https://cloud.example.test", "alice", "saved-password") + val memoryCache = DynamicNativeMemoryCache() + val screenKey = dynamicScreenCacheKey(session, "mail", "messages", null, emptyMap()) + memoryCache.retireAccount(session.accountId.storageKey) + var activatedProducer: DynamicNativeMemoryCacheProducer? = null val save = async { guard.persistSessionAndActivateDynamicReads( persist = { @@ -70,12 +75,19 @@ class DesktopAccountOperationGuardTest { finishPersistence.await() session }, - activate = { events += "activate" }, + activate = { + memoryCache.activateAccount(it.accountId.storageKey) + activatedProducer = memoryCache.producer(screenKey) + events += "activate" + }, ) } persistenceEntered.await() val removal = async { - guard.serialize { events += "fence" } + guard.serialize { + memoryCache.retireAccount(session.accountId.storageKey) + events += "fence" + } } yield() @@ -83,8 +95,14 @@ class DesktopAccountOperationGuardTest { finishPersistence.complete(Unit) assertEquals(session, save.await()) removal.await() + memoryCache.storeScreen( + screenKey, + DynamicScreenSnapshot(emptyList(), emptyMap()), + requireNotNull(activatedProducer), + ) assertEquals(listOf("activate", "fence"), events) + assertNull(memoryCache.screen(screenKey)) } @Test @@ -741,6 +759,7 @@ class DesktopAccountOperationGuardTest { events += "remove-pairs" error("synthetic pair cleanup failure") }, + retireCommittedAccount = { events += "retire-memory" }, recordCleanupFailure = { events += "diagnose-cleanup" }, ) @@ -749,6 +768,7 @@ class DesktopAccountOperationGuardTest { listOf( "prepare-cleanup", "remove-credential", + "retire-memory", "commit-cleanup", "remove-pairs", "diagnose-cleanup", @@ -776,12 +796,13 @@ class DesktopAccountOperationGuardTest { events += "remove-pairs" throw CancellationException("pair cleanup owner stopped") }, + retireCommittedAccount = { events += "retire-memory" }, recordCleanupFailure = { events += "diagnose-cleanup" }, ) } assertEquals( - listOf("prepare-cleanup", "remove-credential", "commit-cleanup", "remove-pairs"), + listOf("prepare-cleanup", "remove-credential", "retire-memory", "commit-cleanup", "remove-pairs"), events, ) } @@ -802,11 +823,12 @@ class DesktopAccountOperationGuardTest { error("synthetic post-commit credential cleanup failure") }, removeSyncPairs = { events += "remove-pairs" }, + retireCommittedAccount = { events += "retire-memory" }, recordCleanupFailure = { events += "diagnose-cleanup" }, ) } - assertEquals(listOf("prepare-cleanup", "remove-credential", "commit-cleanup"), events) + assertEquals(listOf("prepare-cleanup", "remove-credential", "retire-memory", "commit-cleanup"), events) } @Test @@ -856,7 +878,7 @@ class DesktopAccountOperationGuardTest { } @Test - fun futureCleanupEntryIsPreservedWithoutHidingValidTombstonesOrBlockingNewRemoval() { + fun futureCleanupEntryFailsActivationClosedWithoutBlockingNewRemoval() { val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") val malformedAccountId = "1".repeat(64) val validAccountId = "2".repeat(64) @@ -884,8 +906,10 @@ class DesktopAccountOperationGuardTest { assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) assertEquals("v2|committed|$MUTATION_SCOPE", preferences.get("fsac.$validAccountId", null)) assertTrue(journal.blocksAccountActivation(malformedAccountId)) - assertFailsWith { requireDesktopAccountActivationAllowed(true) } - assertFalse(journal.blocksAccountActivation(validAccountId)) + assertFailsWith { + requireDesktopAccountActivationAllowed(DesktopAccountActivationBlock.UnknownJournalData) + } + assertTrue(journal.blocksAccountActivation(validAccountId)) assertEquals(1, malformedCount) journal.prepare(newAccountId) @@ -894,7 +918,7 @@ class DesktopAccountOperationGuardTest { setOf(malformedAccountId, validAccountId, newAccountId), journal.pending().mapTo(linkedSetOf(), DesktopAccountSyncPairCleanup::accountId), ) - assertFalse(journal.blocksAccountActivation(newAccountId)) + assertTrue(journal.blocksAccountActivation(newAccountId)) assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) assertFailsWith { journal.prepare(malformedAccountId) } assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) @@ -959,6 +983,33 @@ class DesktopAccountOperationGuardTest { } } + @Test + fun v3CleanupBlocksCanonicalEquivalentAccountActivationByStorageKey() { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val original = NextcloudSession("https://cloud.example.test", "alice", "password") + val equivalent = original.copy(serverUrl = "https://CLOUD.EXAMPLE.TEST:443/") + val originalProviderId = desktopFileCacheAccountId(original) + val equivalentProviderId = desktopFileCacheAccountId(equivalent) + try { + assertEquals(original.accountId, equivalent.accountId) + assertNotEquals(originalProviderId, equivalentProviderId) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + journal.prepare(originalProviderId, MUTATION_SCOPE, original.accountId.storageKey) + + assertTrue( + journal.blocksAccountActivation( + equivalentProviderId, + equivalent.accountId.storageKey, + ), + ) + assertFailsWith { + journal.requireAccountActivationAllowed(equivalent.accountRecord()) + } + } finally { + preferences.removeNode() + } + } + @Test fun futureCleanupBlocksCredentialLoadAndPrivateSessionPublication() { val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") @@ -1012,6 +1063,45 @@ class DesktopAccountOperationGuardTest { } } + @Test + fun pendingLegacyCleanupAllowsUnrelatedDesktopSessionToLoad() { + val original = NextcloudSession("https://cloud.example.test", "alice", "password") + val unrelated = original.copy(loginName = "bob") + val originalProviderId = desktopFileCacheAccountId(original) + + listOf("prepared", "committed", "v2|prepared|$MUTATION_SCOPE", "v2|committed|$MUTATION_SCOPE").forEach { encoded -> + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + try { + preferences.put("fsac.$originalProviderId", encoded) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + + var originalLoaded = false + var originalPublished = false + assertFailsWith { + loadDesktopSessionAfterCleanupGate( + original.accountRecord(), journal, + load = { originalLoaded = true; original }, + publish = { originalPublished = true }, + ) + } + assertFalse(originalLoaded) + assertFalse(originalPublished) + val publications = mutableListOf() + assertEquals( + unrelated, + loadDesktopSessionAfterCleanupGate( + unrelated.accountRecord(), journal, + load = { unrelated }, publish = { publications += it }, + ), + ) + assertEquals(listOf(unrelated), publications) + assertEquals(encoded, preferences.get("fsac.$originalProviderId", null)) + } finally { + preferences.removeNode() + } + } + } + @Test fun committedPairCleanupFailureSurvivesRestartAndBlocksReactivationUntilRetry() = runBlocking { val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") @@ -1077,101 +1167,6 @@ class DesktopAccountOperationGuardTest { } } - @Test - fun preparedCleanupFromAnAbortedRemovalPreservesExistingPairs() = runBlocking { - val events = mutableListOf() - - retryDesktopAccountSyncPairCleanup( - cleanup = DesktopAccountSyncPairCleanup( - CLEANUP_ACCOUNT_ID, - DesktopAccountSyncPairCleanupPhase.Prepared, - ), - accountOwnership = { DesktopAccountOwnership.Present }, - removeSyncPairs = { events += "remove-pairs" }, - clearCleanup = { events += "clear-cleanup" }, - ) - - assertEquals(listOf("clear-cleanup"), events) - } - - @Test - fun preparedCleanupPreservesPairsAndJournalWhenCredentialOwnershipIsUnknown() = runBlocking { - val events = mutableListOf() - - retryDesktopAccountSyncPairCleanup( - cleanup = DesktopAccountSyncPairCleanup( - CLEANUP_ACCOUNT_ID, - DesktopAccountSyncPairCleanupPhase.Prepared, - ), - accountOwnership = { DesktopAccountOwnership.Unknown }, - removeSyncPairs = { events += "remove-pairs" }, - clearCleanup = { events += "clear-cleanup" }, - ) - - assertTrue(events.isEmpty()) - } - - @Test - fun futureCleanupFormatRemainsBlockedAndUntouchedWhenCredentialsAreAbsent() = runBlocking { - val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") - val futureValue = "v99|committed|future-private-state" - preferences.put("fsac.$CLEANUP_ACCOUNT_ID", futureValue) - val journal = DesktopAccountSyncPairCleanupJournal(preferences) - var ownershipChecks = 0 - val events = mutableListOf() - - try { - val cleanup = journal.pending().single() - assertEquals(DesktopAccountSyncPairCleanupPhase.Unknown, cleanup.phase) - assertTrue(journal.blocksAccountActivation(CLEANUP_ACCOUNT_ID)) - - retryDesktopAccountSyncPairCleanup( - cleanup = cleanup, - accountOwnership = { - ownershipChecks += 1 - DesktopAccountOwnership.Absent - }, - removeSyncPairs = { events += "remove-pairs" }, - clearCleanup = { events += "clear-cleanup" }, - ) - - assertEquals(0, ownershipChecks) - assertTrue(events.isEmpty()) - assertEquals(futureValue, preferences.get("fsac.$CLEANUP_ACCOUNT_ID", null)) - assertTrue(journal.blocksAccountActivation(CLEANUP_ACCOUNT_ID)) - assertTrue(journal.blocksAccountActivation("9".repeat(64), ACCOUNT_STORAGE_KEY)) - } finally { - preferences.removeNode() - } - } - - @Test - fun preparedCleanupUsesCredentialFreeOwnershipToRecover() = runBlocking { - val absentEvents = mutableListOf() - retryDesktopAccountSyncPairCleanup( - cleanup = DesktopAccountSyncPairCleanup( - CLEANUP_ACCOUNT_ID, - DesktopAccountSyncPairCleanupPhase.Prepared, - ), - accountOwnership = { DesktopAccountOwnership.Absent }, - removeSyncPairs = { absentEvents += "remove-pairs" }, - clearCleanup = { absentEvents += "clear-cleanup" }, - ) - assertEquals(listOf("remove-pairs", "clear-cleanup"), absentEvents) - - val presentEvents = mutableListOf() - retryDesktopAccountSyncPairCleanup( - cleanup = DesktopAccountSyncPairCleanup( - CLEANUP_ACCOUNT_ID, - DesktopAccountSyncPairCleanupPhase.Prepared, - ), - accountOwnership = { DesktopAccountOwnership.Present }, - removeSyncPairs = { presentEvents += "remove-pairs" }, - clearCleanup = { presentEvents += "clear-cleanup" }, - ) - assertEquals(listOf("clear-cleanup"), presentEvents) - } - private companion object { const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" const val MUTATION_SCOPE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt index 573bfab13..c805f1049 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt @@ -131,10 +131,18 @@ class DesktopPendingDynamicMutationDirectoryTest { val requestIdentity = "GET /dashboard/widgets" val cache = DynamicApiResponseCache(root) val coalescer = DynamicApiRequestCoalescer() + val memoryCache = DynamicNativeMemoryCache() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val screenKey = dynamicScreenCacheKey(session, "dashboard", "widgets", null, emptyMap()) val started = CompletableDeferred() val release = CompletableDeferred() val response = CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null) cache.store(accountId, requestIdentity, response) + memoryCache.storeScreen( + screenKey, + DynamicScreenSnapshot(emptyList(), emptyMap()), + requireNotNull(memoryCache.producer(screenKey)), + ) val read = async { coalescer.execute(accountId, requestIdentity, load = { started.complete(Unit) @@ -144,14 +152,57 @@ class DesktopPendingDynamicMutationDirectoryTest { } started.await() - clearDesktopDynamicApiState(accountId, coalescer, cache) + clearDesktopDynamicApiState( + accountId, + coalescer, + cache, + session.accountId.storageKey, + memoryCache, + ) release.complete(Unit) assertFailsWith { read.await() } kotlin.test.assertNull(cache.load(accountId, requestIdentity, 1_024)) + kotlin.test.assertNull(memoryCache.screen(screenKey)) } finally { root.deleteRecursively() } } } + + @Test + fun `desktop memory retirement survives a rejected disk cache purge`(): Unit = runBlocking { + val root = createTempDirectory("desktop-dynamic-cache-rejected-cleanup-").toFile() + try { + val accountId = "e".repeat(64) + val accountDirectory = root.resolve(accountId).apply { mkdirs() } + accountDirectory.resolve("unsafe-entry").mkdir() + val cache = DynamicApiResponseCache(root) + val coalescer = DynamicApiRequestCoalescer() + val memoryCache = DynamicNativeMemoryCache() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val screenKey = dynamicScreenCacheKey(session, "dashboard", "widgets", null, emptyMap()) + val staleProducer = requireNotNull(memoryCache.producer(screenKey)) + memoryCache.storeScreen(screenKey, DynamicScreenSnapshot(emptyList(), emptyMap()), staleProducer) + + assertFailsWith { + clearDesktopDynamicApiState( + accountId, + coalescer, + cache, + session.accountId.storageKey, + memoryCache, + ) + } + + kotlin.test.assertNull(memoryCache.screen(screenKey)) + memoryCache.storeScreen(screenKey, DynamicScreenSnapshot(emptyList(), emptyMap()), staleProducer) + kotlin.test.assertNull(memoryCache.screen(screenKey)) + assertFailsWith { + coalescer.execute(accountId, "GET /dashboard/widgets", load = { error("must remain fenced") }) + } + } finally { + root.deleteRecursively() + } + } } diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 81309996e..b172b3931 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -488,7 +488,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DocumentPreview.kt": "a9a8743dd7a381504282cc6ddc68034425024ccc1ae51667da9734bbfc7b1a79", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DurableMutationRecoveryDialog.kt": "e720eadb477a347762cd1894285788ac9f6820972431fe0a1953d01955667bfe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt": "2b7ef2d18b4a23615686ced0b7c9c621c58dc5edd0202104d0ca55b1ebf61d81", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "ddf80ca67d954f6e063c9e88c75794fcb81cbd6d42887c45d1a04b1cefe4f2fd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019", @@ -497,7 +497,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt": "468f9bf41ea353dab6f816d333c3a6179d8d8e1f01f98f8eaf0890d5c0d381d6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt": "e0e7b54e02869eabe797c1af6c77a78b0f7eaf7cb609c3cce8d35a8a710e6511", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "2cb445b752faa33f051cfd618bfac3c9f39fc20abce3176f782fe032aef4a98c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "ad64293f0aeb944b57766c8bdee8cd56b2923b1ec441358442db883ff3376af1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "09debe6409b538c3944a63b1e2c5022dc3e0584d746ef288183bfe0983fb1f7b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633",