diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt index 2357149..7afe1b3 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt @@ -331,6 +331,11 @@ class EntryViewerTest { saved = value dismissEdit() } + override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) { + inspect(id, revision) + prepareMutation(action) + } + override fun unlockMutation(passphrase: String) = unlock(passphrase) override fun prepareMutation(action: PasswordMutation) { this.action = action state.value = @@ -354,7 +359,7 @@ class EntryViewerTest { override fun dismissMutation() { state.value = state.value.copy(mutation = null) } - override fun mutate(destination: String?, overwrite: Boolean) { + override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) { this.destination = destination dismissMutation() diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/MutationWorkflowTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/MutationWorkflowTest.kt new file mode 100644 index 0000000..2f13999 --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/MutationWorkflowTest.kt @@ -0,0 +1,228 @@ +package de.rfc1437.ironstorage + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.test.platform.app.InstrumentationRegistry +import de.rfc1437.ironstorage.core.AuthenticationState +import de.rfc1437.ironstorage.core.PasswordDestination +import de.rfc1437.ironstorage.core.PasswordKind +import de.rfc1437.ironstorage.core.PasswordMutation +import de.rfc1437.ironstorage.core.PasswordMutationPlan +import de.rfc1437.ironstorage.core.PasswordRepository +import de.rfc1437.ironstorage.core.PasswordRow +import de.rfc1437.ironstorage.core.ShellIcon +import de.rfc1437.ironstorage.features.PasswordScreen +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalTestApi::class) +class MutationWorkflowTest { + @get:Rule val compose = createAndroidComposeRule() + private fun display(fixture: Fixture) { + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { + MaterialTheme { PasswordScreen(fixture, fixture.base.authentication) } + } + } + } + + @Test fun fullSwipeOnlySelectsDeleteAndCancelNeverMutates() { + val fixture = Fixture() + display(fixture) + compose.onNodeWithText("Source").performTouchInput { swipeLeft() } + compose.waitUntil(5000) { fixture.state.value.mutation != null } + assertEquals(PasswordMutation.Delete, fixture.state.value.mutation!!.action) + assertEquals(0, fixture.committed) + compose.onNodeWithText("Cancel").performClick() + assertEquals(0, fixture.committed) + compose.onNodeWithContentDescription("Actions for Source").performClick() + compose.onNodeWithText("Delete entry").performClick() + compose.onNodeWithText("Confirm").assertIsNotEnabled() + compose.onNode( + hasText("Unlock") and hasAnyAncestor(hasTestTag("mutation-destinations")) + ).performClick() + compose.onNodeWithText("Confirm").performClick() + assertEquals(1, fixture.committed) + assertEquals("entry:source", fixture.lastId) + } + + @Test fun overwriteAndDirtyEditorDiscardRequireSeparateConsent() { + val fixture = Fixture(dirty = true, collision = true) + fixture.base.lease.value = AuthenticationState(unlocked = true) + display(fixture) + compose.onNodeWithContentDescription("Actions for Source").performClick() + compose.onNodeWithText("Copy entry").performClick() + compose.onNodeWithTag( + "mutation-destinations" + ).performScrollToNode(hasText("Target\nFolder")) + compose.onNodeWithText("Target\nFolder").performClick() + compose.onNodeWithText("Confirm").assertIsNotEnabled() + compose.onNodeWithTag( + "mutation-destinations" + ).performScrollToNode(hasText("Discard its open editors and unsaved edits")) + compose.onNodeWithText("Discard its open editors and unsaved edits").performClick() + compose.onNodeWithText("Confirm").assertIsNotEnabled() + compose.onNodeWithTag( + "mutation-destinations" + ).performScrollToNode(hasText("Allow replacing the existing entry")) + compose.onNodeWithText("Allow replacing the existing entry").performClick() + compose.onNodeWithText("Confirm").performClick() + assertEquals(1, fixture.committed) + assertTrue(fixture.overwrite) + assertTrue(fixture.discard) + assertEquals("target", fixture.destination) + } + + @Test fun foldersHaveOverflowAndTalkBackActionsWithoutGestures() { + val fixture = Fixture() + fixture.base.lease.value = AuthenticationState(unlocked = true) + display(fixture) + val node = compose.onNodeWithText("Group").fetchSemanticsNode() + val actions = node.config[SemanticsActions.CustomActions] + assertTrue(actions.any { it.label == "Move folder Group" }) + assertTrue(actions.any { it.label == "Copy folder Group" }) + compose.runOnIdle { + assertTrue(actions.single { it.label == "Delete folder Group" }.action()) + } + assertEquals("group/", fixture.state.value.mutation!!.source) + assertEquals(0, fixture.committed) + compose.onNodeWithText("Confirm").performClick() + assertEquals(1, fixture.committed) + assertEquals("directory:group", fixture.lastId) + } + + @Test fun inspectDisposableMutationWithComputerUse() { + assumeTrue(InstrumentationRegistry.getArguments().getString("mutationInspect") == "true") + val fixture = Fixture(dirty = true, collision = true) + val done = CountDownLatch(1) + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { + MaterialTheme { + Column(Modifier.fillMaxSize()) { + TextButton(onClick = { + done.countDown() + }) { Text("Finish mutation inspection") } + Text("Confirmed actions: ${fixture.committed}") + PasswordScreen(fixture, fixture.base.authentication, Modifier.weight(1f)) + } + } + } + } + compose.waitForIdle() + while (!done.await(100, TimeUnit.MILLISECONDS)) { + compose.mainClock.advanceTimeBy(100) + compose.waitForIdle() + } + } + private class Fixture( + private val dirty: Boolean = false, + private val collision: Boolean = false, + val base: EntryViewerTest.Fixture = EntryViewerTest.Fixture() + ) : PasswordRepository by base { + override val state = base.state + var committed by mutableStateOf(0) + var lastId: String? = null + var destination: String? = null + var overwrite = false + var discard = false + init { + state.value = state.value.copy( + selected = null, + rows = listOf( + PasswordRow( + "entry:source", + "source", + "Source", + "Locked entry", + PasswordKind.Entry, + "entry-r", + ShellIcon.Key + ), + PasswordRow( + "directory:group", + "group", + "Group", + "Folder", + PasswordKind.Folder, + "folder-r", + ShellIcon.Folder + ) + ) + ) + } + override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) { + lastId = id + val folder = id == "directory:group" + state.value = state.value.copy( + mutation = PasswordMutationPlan( + action, if (folder) "group/" else "source", "plan-r", + listOf( + PasswordDestination( + "target", + "Target", + "Folder", + collision, + collisionTargets = if (collision) { + listOf( + "target/source" + ) + } else { + emptyList() + } + ) + ), + dirty, dirty, if (folder) PasswordKind.Folder else PasswordKind.Entry, + if (folder) "Group" else "Source", if (folder) 2u else 1u + ) + ) + } + override fun prepareMutation(action: PasswordMutation) { + state.value.selected?.let { prepareRowMutation(it.id, it.revision, action) } + } + override fun unlockMutation(passphrase: String) { + base.lease.value = + AuthenticationState(unlocked = true) + } + override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) { + this.destination = destination + this.overwrite = overwrite + discard = discardEditor + committed += 1 + dismissMutation() + } + override fun dismissMutation() { + state.value = state.value.copy(mutation = null) + } + override fun inspect(id: String, revision: String) { + state.value = + state.value.copy(selected = state.value.rows.single { it.id == id }) + } + override fun activate(id: String, revision: String) = inspect(id, revision) + } +} diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt index 4c1b2e1..e4d5c74 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt @@ -29,6 +29,7 @@ import de.rfc1437.ironstorage.core.AuthenticationRepository import de.rfc1437.ironstorage.core.AuthenticationState import de.rfc1437.ironstorage.core.PasswordField import de.rfc1437.ironstorage.core.PasswordKind +import de.rfc1437.ironstorage.core.PasswordMutation import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.PasswordRow import de.rfc1437.ironstorage.core.PasswordSection @@ -215,9 +216,14 @@ class PasswordScreenTest { override fun edit(id: ULong) = Unit override fun dismissEdit() = Unit override fun saveEdit(value: String) = Unit + override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) { + inspect(id, revision) + prepareMutation(action) + } + override fun unlockMutation(passphrase: String) = unlock(passphrase) override fun prepareMutation(action: de.rfc1437.ironstorage.core.PasswordMutation) = Unit override fun dismissMutation() = Unit - override fun mutate(destination: String?, overwrite: Boolean) = Unit + override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) = Unit override fun openSearch(id: String, revision: String) { error("Search is not used by this fixture") } diff --git a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeTreeMutationTest.kt b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeTreeMutationTest.kt new file mode 100644 index 0000000..a543089 --- /dev/null +++ b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeTreeMutationTest.kt @@ -0,0 +1,187 @@ +package de.rfc1437.ironstorage.core + +import androidx.test.platform.app.InstrumentationRegistry +import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeTreeMutationTest { + @Test fun nativePlansDirtyEditorsNavigationAndInterruptionRemainCoherent(): Unit = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map { + File(it, NativeEntryViewerTest.rootName).apply { mkdirs() }.canonicalPath + } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val session = NativePrivateSession(context, { + PrivateDirectories(roots[0], roots[1], roots[2]) + }, NativeEntryViewerTest.MemoryKeystore()) + val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO) + val passwords = NativePasswordRepository( + session, + scope, + authentication, + Dispatchers.IO, + NativeEntryViewerTest.MemoryClipboard() + ) + val editor = + NativeEditorRepository(authentication, passwords, scope) + val passphrase = "public-mutation-passphrase" + suspend fun request(action: () -> Unit): PasswordState { + withContext(Dispatchers.Main.immediate) { action() } + return withTimeout(30000) { passwords.state.first { !it.loading } } + } + try { + withContext(Dispatchers.IO) { + session.initialize() + val setup = session.storage().androidOnboarding(session.backend()) + val key = requireNotNull(setup.generate("Mutation fixture", passphrase).selectedKey) + setup.setupLocal(key, passphrase) + } + authentication.authenticated { auth -> + auth.createDirectory("", "source") + auth.createDirectory("", "destination") + val draft = auth.beginCreateEntry("source", "entry", passphrase) + auth.saveEntryEditor( + draft.id, + draft.page.fields.map { + MobileEntryEditorInput(it.id, it.name, "fixture-secret") + } + ) + } + withContext(Dispatchers.Main.immediate) { authentication.lock() } + val root = request { passwords.restore("", null, null) } + val source = root.rows.single { it.path == "source" } + val prepared = + request { + passwords.prepareRowMutation(source.id, source.revision, PasswordMutation.Move) + } + assertFalse(authentication.state.value.unlocked) + assertNull(prepared.sections) + assertEquals(PasswordKind.Folder, requireNotNull(prepared.mutation).kind) + assertEquals(1u, requireNotNull(prepared.mutation).entries) + assertEquals("source/", requireNotNull(prepared.mutation).source) + request { passwords.dismissMutation() } + val unchanged = request { passwords.refresh() } + assertTrue(unchanged.rows.any { it.path == "source" }) + request { + passwords.prepareRowMutation(source.id, source.revision, PasswordMutation.Move) + } + assertNull(request { passwords.unlockMutation(passphrase) }.error) + withContext(Dispatchers.Main.immediate) { editor.begin("source/entry") } + val opened = withTimeout(30000) { + editor.state.first { + !it.busy && + it.fields.isNotEmpty() + } + } + val field = opened.fields.first().id + withContext(Dispatchers.Main.immediate) { editor.change(field, null, "unsaved edit") } + withTimeout(30000) { editor.state.first { !it.busy && it.dirty } } + val dirty = withTimeout(30000) { + var page = + request { + passwords.prepareRowMutation( + source.id, + source.revision, + PasswordMutation.Move + ) + } + while (page.mutation?.hasDirtyEditor != true) { + delay(20) + page = + request { + passwords.prepareRowMutation( + source.id, + source.revision, + PasswordMutation.Move + ) + } + } + page + } + assertTrue(requireNotNull(dirty.mutation).hasOpenEditor) + assertTrue(requireNotNull(dirty.mutation).hasDirtyEditor) + val rejected = request { passwords.mutate("destination", false, false) } + assertNotNull(rejected.error) + assertTrue(editor.state.value.open) + assertEquals("unsaved edit", editor.state.value.fields.first().value) + val refreshedSource = rejected.rows.single { it.path == "source" } + request { + passwords.prepareRowMutation( + refreshedSource.id, + refreshedSource.revision, + PasswordMutation.Move + ) + } + val moved = request { passwords.mutate("destination", false, true) } + assertNull(moved.error) + assertEquals("destination/source", moved.path) + assertFalse(editor.state.value.open) + assertTrue(editor.state.value.fields.isEmpty()) + val entry = moved.rows.single() + request { passwords.activate(entry.id, moved.revision) } + request { passwords.unlock(passphrase) } + val password = passwords.state.value.sections!!.first().fields.first().id + val revealed = request { passwords.reveal(password) } + assertEquals("fixture-secret", revealed.sections!!.first().fields.first().value) + request { passwords.prepareMutation(PasswordMutation.Copy) } + val copied = request { passwords.mutate("destination", false, false) } + assertNull(copied.error) + assertEquals("destination", copied.path) + val selected = requireNotNull(copied.selected) + authentication.authenticated { auth -> + auth.replaceEntryField("destination/entry", 0u, "replacement-secret") + } + val stale = + request { + passwords.prepareRowMutation( + selected.id, + selected.revision, + PasswordMutation.Delete + ) + } + assertNotNull(stale.error) + assertNull(stale.mutation) + request { passwords.refresh() } + val current = passwords.state.value.rows.single { it.path == "destination/entry" } + request { + passwords.prepareRowMutation(current.id, current.revision, PasswordMutation.Delete) + } + withContext(Dispatchers.Main.immediate) { + authentication.lifecycle(AuthenticationLifecycle.Background) + assertNull(passwords.state.value.mutation) + assertNull(passwords.state.value.sections) + passwords.mutate(null, false, false) + } + val retained = request { passwords.refresh() } + assertTrue(retained.rows.any { it.id == current.id }) + assertFalse(authentication.state.value.unlocked) + request { + passwords.prepareRowMutation(current.id, current.revision, PasswordMutation.Delete) + } + request { passwords.unlockMutation(passphrase) } + val deleted = request { passwords.mutate(null, false, false) } + assertNull(deleted.error) + assertEquals("destination", deleted.path) + assertTrue(deleted.rows.none { it.id == current.id }) + assertNotNull(deleted.notice) + } finally { + withContext(Dispatchers.Main.immediate) { scope.cancel() } + roots.forEach { assertTrue(File(it).deleteRecursively()) } + } + } +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeEditorRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeEditorRepository.kt index 8183d31..6cbb0cd 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeEditorRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeEditorRepository.kt @@ -43,7 +43,20 @@ class NativeEditorRepository( mutable.value = mutable.value.copy(locked = true, fields = emptyList()) } } - scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() } + val discarded = authentication.presentation.registerEditorDiscard { ids -> + if (checkpoint in ids) { + epoch += 1 + pending?.cancel() + checkpoint = null + retry = null + afterClose = {} + mutable.value = EditorState() + } + } + scope.coroutineContext[Job]?.invokeOnCompletion { + registration.close() + discarded.close() + } } override fun processRecreated() { scope.launch { diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt index 9349841..7406b9e 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt @@ -50,12 +50,8 @@ class NativePasswordRepository( private val mutex = Mutex() init { val registration = authentication.presentation.register { - if (mutable.value.sections != null) { - sequence.incrementAndGet() - mutable.value = mutable.value.cleared().copy(loading = false) - } else { - mutable.value = mutable.value.cleared() - } + sequence.incrementAndGet() + mutable.value = mutable.value.cleared().copy(loading = false) } scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() } // Hidden codes still expire; only the composed UI requests decryption/countdown refresh. @@ -394,46 +390,89 @@ class NativePasswordRepository( current.copy(error = session.text(R.string.core_copy_unavailable), notice = null) } } - override fun prepareMutation(action: PasswordMutation) = content { current -> - val plan = authentication.authenticated { - it.preparePresentedEntryMutation( - current.entryPath(), - requireNotNull(current.documentRevision), - action.native() - ) - } - current.copy( - mutation = PasswordMutationPlan( - action, - plan.source, - plan.revision, - plan.destinations.map { - PasswordDestination(it.path, it.title, it.detail, it.requiresOverwrite) - }, - plan.hasOpenEditor - ) - ) + override fun prepareMutation(action: PasswordMutation) { + val row = mutable.value.selected ?: return + prepareRowMutation(row.id, row.revision, action) } - override fun dismissMutation() { - sequence.incrementAndGet() - mutable.value = mutable.value.copy(mutation = null, loading = false) - } - override fun mutate(destination: String?, overwrite: Boolean) = content { current -> - val plan = current.mutation ?: return@content current - authentication.authenticated { - it.performEntryMutation( - MobileMutationRequest( - plan.action.native(), - plan.source, - plan.revision, - destination, - true, - overwrite, - plan.discardEditor + override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) { + run { + val plan = authentication.authenticated { + it.prepareNavigationMutation(id, revision, action.native()) + } + mutable.value.copy( + mutation = PasswordMutationPlan( + action, plan.source, plan.revision, + plan.destinations.map { + PasswordDestination( + it.path, + it.title, + it.detail, + it.requiresOverwrite, + it.depth, + it.unavailable, + it.collisionTargets + ) + }, + plan.hasOpenEditor, plan.hasDirtyEditor, + if (plan.kind == + MobilePasswordRowKind.DIRECTORY + ) { + PasswordKind.Folder + } else { + PasswordKind.Entry + }, + plan.sourceTitle, plan.entries ) ) } - session.storage().passwordNavigation(current.path, null, null, false).toFacade() + } + override fun unlockMutation(passphrase: String) { + val current = mutable.value + val plan = current.mutation ?: return + run(false) { + authentication.authenticated { + it.unlockMutation(plan.source, plan.revision, passphrase.ifEmpty { null }) + } + current + } + } + override fun dismissMutation() { + sequence.incrementAndGet() + mutable.value = mutable.value.copy(mutation = null, loading = false, error = null) + } + override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) { + val current = mutable.value + val plan = current.mutation ?: return + if (current.loading || !authentication.state.value.unlocked) return + authentication.touch() + run { + val outcome = try { + authentication.authenticated { + it.performNavigationMutation( + MobileMutationRequest( + plan.action.native(), + plan.source, + plan.revision, + destination, + true, + overwrite, + discardEditor + ), + current.path + ) + } + } catch (error: MobileAuthenticationFfiException.Failed) { + return@run session.storage().passwordNavigation(current.path, null, null, false) + .toFacade().copy(error = error.title + "\n" + error.detail) + } + withContext(Dispatchers.Main.immediate) { + authentication.presentation.editorsDiscarded(outcome.discardedEditors.toSet()) + } + outcome.navigation.toFacade().copy( + notice = + outcome.result.title + "\n" + outcome.result.detail + ) + } } private fun content( userActivity: Boolean = true, @@ -494,7 +533,15 @@ class NativePasswordRepository( } if (sequence.get() == ticket) { mutable.value = - (if (authentication.state.value.unlocked) result else result.cleared()) + ( + if (authentication.state.value.unlocked) { + result + } else { + result.cleared().copy( + mutation = result.mutation + ) + } + ) .copy(loading = false) } } diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt index 7f62acd..187ccbc 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt @@ -57,14 +57,21 @@ data class PasswordDestination( val path: String, val title: String, val detail: String, - val overwrite: Boolean + val overwrite: Boolean, + val depth: UInt = 0u, + val unavailable: String? = null, + val collisionTargets: List = emptyList() ) data class PasswordMutationPlan( val action: PasswordMutation, val source: String, val revision: String, val destinations: List, - val discardEditor: Boolean + val hasOpenEditor: Boolean, + val hasDirtyEditor: Boolean = false, + val kind: PasswordKind = PasswordKind.Entry, + val sourceTitle: String = source, + val entries: UInt = 1u ) data class PasswordState( val path: String = "", @@ -105,6 +112,8 @@ interface PasswordRepository { fun dismissEdit() fun saveEdit(value: String) fun prepareMutation(action: PasswordMutation) + fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) + fun unlockMutation(passphrase: String) fun dismissMutation() - fun mutate(destination: String?, overwrite: Boolean) + fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean = false) } diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt index 2918225..85a6cd7 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt @@ -21,6 +21,17 @@ class ProtectedPresentation { fun clearMetadata() { synchronized(metadata) { metadata.values.toList() }.forEach { it() } } + private val editorListeners = linkedMapOf) -> Unit>() + fun registerEditorDiscard(discard: (Set) -> Unit): AutoCloseable { + val registration = Any() + synchronized(editorListeners) { editorListeners[registration] = discard } + return AutoCloseable { + synchronized(editorListeners) { editorListeners.remove(registration) } + } + } + fun editorsDiscarded(ids: Set) { + synchronized(editorListeners) { editorListeners.values.toList() }.forEach { it(ids) } + } private val listeners = linkedMapOf Unit>() private val mutableGeneration = MutableStateFlow(0L) val generation: StateFlow = mutableGeneration diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt index dc341bf..c8b9fc7 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt @@ -281,66 +281,29 @@ fun EntryDialogs( properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn) ) } - state.mutation?.let { plan -> - var destination by remember(plan) { mutableStateOf(null) } - var overwrite by remember(plan, destination) { mutableStateOf(false) } - val selected = plan.destinations.find { it.path == destination } - ProtectedWindow() - AlertDialog( - onDismissRequest = repository::dismissMutation, - title = { Text(stringResource(plan.action.label())) }, - text = { - ProtectedInputSurface() - Column(Modifier.verticalScroll(rememberScrollState())) { - Text(plan.source) - if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor)) - if (plan.action == PasswordMutation.Delete) { - Text(stringResource(R.string.entry_delete_confirm)) - } else { - plan.destinations.forEach { candidate -> - TextButton(onClick = { destination = candidate.path }) { - Text(candidate.title + "\n" + candidate.detail) - } - } - selected?.let { Text(stringResource(R.string.entry_destination, it.path)) } - if (selected?.overwrite == true) { - TextButton(onClick = { overwrite = !overwrite }) { - Text( - stringResource( - if (overwrite) { - R.string.entry_overwrite_enabled - } else { - R.string.entry_overwrite - } - ) - ) - } - } - } - } - }, - confirmButton = { - TextButton( - enabled = !state.loading && ( - plan.action == PasswordMutation.Delete || - selected != null && (!selected.overwrite || overwrite) - ), - onClick = { repository.mutate(destination, overwrite) } - ) { - Text(stringResource(R.string.entry_confirm)) - } - }, - dismissButton = { - TextButton(onClick = repository::dismissMutation) { - Text(stringResource(R.string.auth_cancel)) - } - }, - properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn) - ) +} +internal fun PasswordMutation.label( + kind: de.rfc1437.ironstorage.core.PasswordKind = de.rfc1437.ironstorage.core.PasswordKind.Entry +) = when (this) { + PasswordMutation.Move -> if (kind == + de.rfc1437.ironstorage.core.PasswordKind.Folder + ) { + R.string.mutation_move_folder + } else { + R.string.entry_move + } + PasswordMutation.Copy -> if (kind == + de.rfc1437.ironstorage.core.PasswordKind.Folder + ) { + R.string.mutation_copy_folder + } else { + R.string.entry_copy_entry + } + PasswordMutation.Delete -> if (kind == + de.rfc1437.ironstorage.core.PasswordKind.Folder + ) { + R.string.mutation_delete_folder + } else { + R.string.entry_delete } } -private fun PasswordMutation.label() = when (this) { - PasswordMutation.Move -> R.string.entry_move - PasswordMutation.Copy -> R.string.entry_copy_entry - PasswordMutation.Delete -> R.string.entry_delete -} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/MutationDialog.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/MutationDialog.kt new file mode 100644 index 0000000..6d9d3f6 --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/MutationDialog.kt @@ -0,0 +1,215 @@ +package de.rfc1437.ironstorage.features + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.rfc1437.ironstorage.core.AuthenticationFailureKind +import de.rfc1437.ironstorage.core.AuthenticationRepository +import de.rfc1437.ironstorage.core.PasswordKind +import de.rfc1437.ironstorage.core.PasswordMutation +import de.rfc1437.ironstorage.core.PasswordMutationPlan +import de.rfc1437.ironstorage.core.PasswordRepository +import de.rfc1437.ironstorage.core.PasswordState + +@Composable +internal fun MutationDialog( + plan: PasswordMutationPlan, + state: PasswordState, + repository: PasswordRepository, + authentication: AuthenticationRepository +) { + val lease by authentication.state.collectAsStateWithLifecycle() + var destination by remember(plan) { mutableStateOf(null) } + var overwrite by remember(plan, destination) { mutableStateOf(false) } + var discard by remember(plan) { mutableStateOf(false) } + var manual by remember(plan) { mutableStateOf(false) } + var requested by remember(plan) { mutableStateOf(false) } + val selected = plan.destinations.find { it.path == destination } + LaunchedEffect(lease.failure, lease.busy, state.loading) { + if (requested && !lease.busy && !state.loading) { + requested = false + if (lease.failure?.kind == AuthenticationFailureKind.PassphraseRequired || + lease.failure?.kind == AuthenticationFailureKind.BiometryUnavailable + ) { + manual = true + } + } + } + val cancel = { + repository.dismissMutation() + if (lease.busy) authentication.cancel() + } + ProtectedWindow() + AlertDialog( + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + onDismissRequest = cancel, + title = { Text(stringResource(plan.action.label(plan.kind))) }, + text = { + ProtectedInputSurface(authentication.presentation) + LazyColumn( + Modifier.fillMaxWidth().heightIn(max = 400.dp).testTag("mutation-destinations") + ) { + item { + Text(plan.sourceTitle, style = MaterialTheme.typography.titleLarge) + Text(plan.source) + if (plan.kind == PasswordKind.Folder) { + Text( + pluralStringResource( + R.plurals.mutation_entries, + plan.entries.coerceAtMost(Int.MAX_VALUE.toUInt()).toInt(), + plan.entries.toString() + ) + ) + } + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + lease.failure?.let { + Text(it.title + "\n" + it.detail, color = MaterialTheme.colorScheme.error) + } + if (!lease.unlocked) { + TextButton(enabled = !lease.busy && !state.loading, onClick = { + requested = true + repository.unlockMutation("") + }) { Text(stringResource(R.string.password_unlock)) } + TextButton(enabled = !lease.busy, onClick = { manual = true }) { + Text(stringResource(R.string.auth_manual)) + } + } + if (plan.hasOpenEditor) { + Text( + stringResource( + if (plan.hasDirtyEditor) { + R.string.mutation_dirty_editor + } else { + R.string.mutation_open_editor + } + ) + ) + Row( + Modifier.fillMaxWidth().toggleable(discard, role = Role.Checkbox) { + discard = it + } + ) { + Checkbox(discard, onCheckedChange = null) + Text(stringResource(R.string.mutation_discard_editor)) + } + } + if (plan.action == PasswordMutation.Delete) { + Text(stringResource(R.string.mutation_delete_target, plan.source)) + } + } + if (plan.action != PasswordMutation.Delete) { + items(plan.destinations, key = { it.path }) { candidate -> + Row( + Modifier.fillMaxWidth().padding( + start = ( + candidate.depth.coerceAtMost(12u).toInt() * + 12 + ).dp + ) + .selectable( + selected = destination == candidate.path, + enabled = + candidate.unavailable == null, + role = Role.RadioButton, + onClick = { destination = candidate.path } + ) + ) { + RadioButton( + selected = destination == candidate.path, + onClick = null, + enabled = + candidate.unavailable == null + ) + Column { + Text(candidate.title + "\n" + candidate.detail) + candidate.unavailable?.let { + Text(it, color = MaterialTheme.colorScheme.error) + } + } + } + } + selected?.let { target -> + item { + Text(stringResource(R.string.entry_destination, target.path)) + if (target.overwrite) { + Text(stringResource(R.string.mutation_collision)) + target.collisionTargets.forEach { Text(it) } + Row( + Modifier.fillMaxWidth().toggleable( + overwrite, + role = Role.Checkbox + ) { + overwrite = it + } + ) { + Checkbox(overwrite, onCheckedChange = null) + Text(stringResource(R.string.entry_overwrite)) + } + } + } + } + } + } + }, + confirmButton = { + TextButton( + enabled = lease.unlocked && !state.loading && !lease.busy && + (!plan.hasOpenEditor || discard) && + ( + plan.action == PasswordMutation.Delete || + selected != null && selected.unavailable == null && + (!selected.overwrite || overwrite) + ), + onClick = { repository.mutate(destination, overwrite, discard) } + ) { + Text(stringResource(R.string.entry_confirm)) + } + }, + dismissButton = { + TextButton(onClick = cancel) { Text(stringResource(R.string.auth_cancel)) } + } + ) + if (manual) { + PassphraseDialog( + stringResource(R.string.auth_manual), + stringResource(R.string.auth_manual_detail), + presentation = authentication.presentation, + onCancel = { + manual = false + authentication.cancel() + }, + onSubmit = { + manual = false + requested = true + repository.unlockMutation(it) + } + ) + } +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt index 0f9ee7f..0b07588 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt @@ -1,8 +1,10 @@ package de.rfc1437.ironstorage.features import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -18,6 +20,8 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @@ -25,13 +29,17 @@ import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -50,8 +58,10 @@ import de.rfc1437.ironstorage.core.AuthenticationFailureKind import de.rfc1437.ironstorage.core.AuthenticationRepository import de.rfc1437.ironstorage.core.EditorRepository import de.rfc1437.ironstorage.core.PasswordKind +import de.rfc1437.ironstorage.core.PasswordMutation import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.ShellIcon +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable @@ -150,6 +160,7 @@ fun PasswordScreen( ) Text(state.path.ifEmpty { stringResource(R.string.password_root) }) if (state.loading) CircularProgressIndicator() + state.notice?.let { Text(it) } state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } @@ -182,57 +193,121 @@ fun PasswordScreen( R.string.password_entry } ) - ListItem( - headlineContent = { Text(row.title) }, - supportingContent = { Text(row.detail) }, - leadingContent = { - Icon( - painterResource( - when (row.icon) { - ShellIcon.Folder -> R.drawable.ic_folder - ShellIcon.Key -> R.drawable.ic_key - else -> error("Invalid Rust navigation icon") - } - ), - null - ) - }, - trailingContent = { - TextButton( - modifier = Modifier.semantics { - contentDescription = - rowActions - }, - enabled = !state.loading, - onClick = { - actions = true - repository.inspect(row.id, state.revision) + val mutationLabels = PasswordMutation.entries.associateWith { action -> + stringResource( + R.string.mutation_action_for, + stringResource(action.label(row.kind)), + row.title + ) + } + key(row.id, row.revision) { + val swipe = rememberSwipeToDismissBoxState() + val swipeScope = rememberCoroutineScope() + SwipeToDismissBox( + state = swipe, + gesturesEnabled = !state.loading, + onDismiss = { direction -> + val action = if (direction == + SwipeToDismissBoxValue.EndToStart + ) { + PasswordMutation.Delete + } else { + PasswordMutation.Move } - ) { Text(actionLabel) } - }, - modifier = Modifier.combinedClickable( - enabled = !state.loading, - onClickLabel = stringResource(R.string.password_open), - onLongClickLabel = rowActions, - onClick = { - actions = false - repository.activate(row.id, state.revision) + repository.prepareRowMutation(row.id, row.revision, action) + swipeScope.launch { swipe.reset() } }, - onLongClick = { - actions = true - repository.inspect(row.id, state.revision) + backgroundContent = { + val action = if (swipe.dismissDirection == + SwipeToDismissBoxValue.EndToStart + ) { + PasswordMutation.Delete + } else { + PasswordMutation.Move + } + Box( + Modifier.fillMaxSize().background( + MaterialTheme.colorScheme.surfaceContainerHigh + ).padding(16.dp), + contentAlignment = if (action == + PasswordMutation.Delete + ) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + } + ) { + Text(mutationLabels.getValue(action)) + } } - ).semantics { - stateDescription = kindLabel - customActions = listOf( - CustomAccessibilityAction(rowActions) { - actions = true - repository.inspect(row.id, state.revision) - true + ) { + ListItem( + headlineContent = { Text(row.title) }, + supportingContent = { Text(row.detail) }, + leadingContent = { + Icon( + painterResource( + when (row.icon) { + ShellIcon.Folder -> R.drawable.ic_folder + ShellIcon.Key -> R.drawable.ic_key + else -> error( + "Invalid Rust navigation icon" + ) + } + ), + null + ) + }, + trailingContent = { + TextButton( + modifier = Modifier.semantics { + contentDescription = + rowActions + }, + enabled = !state.loading, + onClick = { + actions = true + repository.inspect(row.id, state.revision) + } + ) { Text(actionLabel) } + }, + modifier = Modifier.combinedClickable( + enabled = !state.loading, + onClickLabel = stringResource(R.string.password_open), + onLongClickLabel = rowActions, + onClick = { + actions = false + repository.activate(row.id, state.revision) + }, + onLongClick = { + actions = true + repository.inspect(row.id, state.revision) + } + ).semantics { + stateDescription = kindLabel + customActions = listOf( + CustomAccessibilityAction(rowActions) { + actions = true + repository.inspect(row.id, state.revision) + true + } + ) + PasswordMutation.entries.map { action -> + CustomAccessibilityAction( + mutationLabels.getValue(action) + ) { + actions = false + repository.prepareRowMutation( + row.id, + row.revision, + action + ) + true + } + } } ) } - ) + } HorizontalDivider() } } @@ -331,6 +406,7 @@ fun PasswordScreen( ) { EntryDialogs(state, repository, authentication) } + state.mutation?.let { MutationDialog(it, state, repository, authentication) } if (manual && entry != null) { PassphraseDialog( stringResource(R.string.auth_manual), @@ -363,7 +439,15 @@ fun PasswordScreen( }, text = { ProtectedInputSurface() - Text(selected.detail + "\n" + selected.path) + Column { + Text(selected.detail + "\n" + selected.path) + PasswordMutation.entries.forEach { action -> + TextButton(onClick = { + actions = false + repository.prepareRowMutation(selected.id, selected.revision, action) + }) { Text(stringResource(action.label(selected.kind))) } + } + } }, confirmButton = { TextButton(onClick = { diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index 498050f..f1e114c 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -253,4 +253,17 @@ This entry no longer has a TOTP code. This entry changed. Refresh the catalog and select it again. Back to TOTP entries + %1$s %2$s + Move folder + Copy folder + Delete folder + Delete %1$s and its contents? This requires confirmation. + This target has an open editor. + This target has unsaved edits. + Discard its open editors and unsaved edits + These existing entries will be replaced: + + %1$s password entry + %1$s password entries + diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index b17a6e5..8464e5a 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -932,6 +932,69 @@ impl From for MobileMutationPlan { } } +#[derive(Clone, uniffi::Record)] +pub struct MobileTreeMutationDestination { + pub path: String, + pub title: String, + pub detail: String, + pub requires_overwrite: bool, + pub depth: u32, + pub unavailable: Option, + pub collision_targets: Vec, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileTreeMutationPlan { + pub action: MobileMutationAction, + pub source: String, + pub source_title: String, + pub kind: MobilePasswordRowKind, + pub revision: String, + pub entries: u32, + pub destinations: Vec, + pub has_open_editor: bool, + pub has_dirty_editor: bool, +} + +impl From for MobileTreeMutationPlan { + fn from(plan: StorageMutationPlan) -> Self { + Self { + action: plan.action().into(), + source: plan.source().to_owned(), + source_title: plan.source_title().to_owned(), + kind: if plan.is_directory() { + MobilePasswordRowKind::Directory + } else { + MobilePasswordRowKind::Entry + }, + revision: plan.revision().to_owned(), + entries: plan.entries(), + destinations: plan + .destinations() + .iter() + .map(|destination| MobileTreeMutationDestination { + path: destination.path().to_owned(), + title: destination.title().to_owned(), + detail: destination.detail().to_owned(), + requires_overwrite: destination.requires_overwrite(), + depth: destination.depth(), + unavailable: destination.unavailable().map(str::to_owned), + collision_targets: destination.collision_targets().to_vec(), + }) + .collect(), + has_open_editor: plan.has_open_editor(), + has_dirty_editor: plan.has_dirty_editor(), + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileNavigationMutationOutcome { + pub result: MobileMutationOutcome, + pub navigation: MobilePasswordNavigation, + pub discarded_editors: Vec, +} + #[derive(Clone, uniffi::Record)] pub struct MobileMutationRequest { pub action: MobileMutationAction, @@ -2103,6 +2166,53 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn prepare_navigation_mutation( + &self, + id: String, + revision: String, + action: MobileMutationAction, + ) -> Result { + self.authentication + .prepare_navigation_mutation(&id, &revision, action.into()) + .map(Into::into) + .map_err(Into::into) + } + + pub fn unlock_mutation( + &self, + source: String, + revision: String, + passphrase: Option, + ) -> Result { + self.authentication + .unlock_mutation( + &source, + &revision, + passphrase + .map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())), + ) + .map(Into::into) + .map_err(Into::into) + } + + pub fn perform_navigation_mutation( + &self, + request: MobileMutationRequest, + return_path: String, + ) -> Result { + self.authentication + .perform_navigation_mutation(request.into(), &return_path) + .map(|outcome| { + let discarded_editors = outcome.result.discarded_editors().to_vec(); + MobileNavigationMutationOutcome { + result: outcome.result.into(), + navigation: outcome.navigation.into(), + discarded_editors, + } + }) + .map_err(Into::into) + } + pub fn prepare_presented_entry_mutation( &self, path: String, diff --git a/crates/storage/src/document.rs b/crates/storage/src/document.rs index 41a7387..51968f0 100644 --- a/crates/storage/src/document.rs +++ b/crates/storage/src/document.rs @@ -253,6 +253,12 @@ pub(crate) struct SealedEntryDocument { } impl SealedEntryDocument { + pub(crate) fn path(&self) -> &EntryPath { + &self.path + } + pub(crate) fn is_modified(&self) -> bool { + self.modified + } pub(crate) fn probe(&self) -> &EncryptedEntry { &self.ciphertext } diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index c70820e..cb20235 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -334,6 +334,11 @@ struct EditorCheckpoint { inputs: Option, } +pub struct MobileNavigationMutationOutcome { + pub result: MobileMutationOutcome, + pub navigation: crate::mobile_passwords::navigation::PasswordNavigation, +} + /// Only released after the checkpoint's encryption key has an active authentication lease. pub struct MobileEntryEditorRecovery { pub session: MobileEntryEditorSession, @@ -1240,6 +1245,127 @@ impl MobileAuthentication { result } + pub fn prepare_navigation_mutation( + &self, + id: &str, + revision: &str, + action: MobileMutationAction, + ) -> Result { + self.ensure_repository_idle()?; + let object = + crate::mobile_passwords::navigation::checked_row(&self.repository, id, revision) + .map_err(|error| entry_detail(error.title(), error.detail()))?; + let source = match object { + crate::read::TreeNodeId::Entry(path) => path.to_string(), + crate::read::TreeNodeId::Directory(path) => format!("{path}/"), + }; + let plan = self.prepare_entry_mutation(&source, action)?; + crate::mobile_passwords::navigation::checked_row(&self.repository, id, revision) + .map_err(|error| entry_detail(error.title(), error.detail()))?; + Ok(plan) + } + + pub fn unlock_mutation( + &self, + source: &str, + revision: &str, + passphrase: Option, + ) -> Result { + self.ensure_repository_idle()?; + self.check_mutation_revision(revision)?; + let snapshot = self.repository.snapshot().map_err(entry_error)?; + let probe = match snapshot.resolve(source).map_err(entry_error)? { + crate::repository::ResolvedObject::Entry(record) => self + .repository + .read_entry(record.path()) + .map_err(entry_error)?, + crate::repository::ResolvedObject::Directory(record) => { + let path = EntryPath::parse( + record + .path() + .as_path() + .join("IronStorage-mutation-authentication"), + ) + .map_err(entry_error)?; + let recipients = RecipientPolicyManager::new(&self.repository, &self.keys) + .resolve_for_entry(&path, None) + .map_err(|error| entry_detail("Recipients Are Unavailable", error))?; + self.keys + .encrypt( + SecretBytes::new(b"IronStorage mobile mutation authentication".to_vec()), + recipients.recipients(), + ) + .map_err(key_error)? + } + }; + self.unlock_ciphertext(&probe, passphrase)?; + self.check_mutation_revision(revision)?; + self.state() + } + + fn check_mutation_revision(&self, revision: &str) -> Result<(), MobileAuthenticationError> { + if self.repository.mutation_revision().map_err(entry_error)? != revision { + self.manual_lock()?; + return Err(MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Conflict, + "Password Tree Changed", + "Refresh and prepare the action again before confirming.", + )); + } + Ok(()) + } + + pub fn perform_navigation_mutation( + &self, + request: MobileMutationRequest, + return_path: &str, + ) -> Result { + let result = self.perform_entry_mutation(request)?; + let navigation = match result.selection() { + Some(crate::read::TreeNodeId::Directory(path)) => { + crate::mobile_passwords::navigation::navigate_repository( + &self.repository, + &path.to_string(), + None, + None, + false, + ) + } + Some(crate::read::TreeNodeId::Entry(path)) => { + let mut page = crate::mobile_passwords::navigation::navigate_repository( + &self.repository, + &path.parent_directory().to_string(), + None, + None, + false, + ) + .map_err(|error| entry_detail(error.title(), error.detail()))?; + let id = format!("entry:{path}"); + page.selected = page.rows.iter().find(|row| row.row.id() == id).cloned(); + Ok(page) + } + None => crate::mobile_passwords::navigation::navigate_repository( + &self.repository, + return_path, + None, + None, + false, + ), + } + .map_err(|error| { + entry_detail( + "Password Action Completed", + format!( + "{} {} Refresh the tree: {}", + result.title(), + result.detail(), + error.detail() + ), + ) + })?; + Ok(MobileNavigationMutationOutcome { result, navigation }) + } + pub fn prepare_presented_entry_mutation( &self, path: &str, @@ -1268,6 +1394,7 @@ impl MobileAuthentication { path: &str, action: MobileMutationAction, ) -> Result { + self.ensure_repository_idle()?; let (has_open_editor, has_dirty_editor) = self.editor_state(path)?; MobileMutationService::new(&self.repository) .prepare(path, action, has_open_editor, has_dirty_editor) @@ -1302,23 +1429,53 @@ impl MobileAuthentication { AutomaticTreeCommitter::for_source(&self.repository, &request.source, identity) .map_err(git_mutation_error)?; let has_open_editor = self.editor_state(&request.source)?.0; + let checkpoint_ids = if request.discard_editor { + self.status()? + .editor_checkpoints + .iter() + .filter(|(_, checkpoint)| { + mutation_contains(&request.source, checkpoint.document.path()) + }) + .map(|(id, _)| *id) + .collect::>() + } else { + Vec::new() + }; let editors = if has_open_editor && request.discard_editor { self.take_entry_editors(&request.source)? } else { Vec::new() }; let mut provider = KeyOnlyProvider::new(handle, &key); - let result = MobileMutationService::new(&self.repository).perform( + let result = MobileMutationService::new(&self.repository).perform_guarded( &request, - has_open_editor && editors.is_empty(), + has_open_editor && !request.discard_editor, &self.keys, &mut provider, &mut committer, + &|| { + self.ensure_active() + .map(|_| ()) + .map_err(|_| crate::mutation::MutationError::Cancelled) + }, ); match result { - Ok(outcome) => Ok(outcome), + Ok(mut outcome) => { + let mut discarded = checkpoint_ids; + discarded.extend(editors.iter().map(|(id, _)| *id)); + discarded.sort_unstable(); + discarded.dedup(); + let mut status = self.status()?; + for id in &discarded { + status.editor_checkpoints.remove(id); + } + outcome.set_discarded_editors(discarded); + Ok(outcome) + } Err(error) => { - self.restore_entry_editors(editors)?; + if self.ensure_active().is_ok() { + self.restore_entry_editors(editors)?; + } Err(mutation_error(error)) } } @@ -1605,6 +1762,18 @@ impl MobileAuthentication { document: &mut EntryDocument, field_id: u64, value: String, + ) -> Result { + self.reserve_entry_mutation()?; + let result = self.save_reserved_entry_field(document, field_id, value); + self.release_entry_mutation(); + result + } + + fn save_reserved_entry_field( + &self, + document: &mut EntryDocument, + field_id: u64, + value: String, ) -> Result { document .replace_field_value(EntryFieldId::from_value(field_id), value.into_bytes()) @@ -1627,6 +1796,7 @@ impl MobileAuthentication { &self, path: &str, ) -> Result { + self.ensure_repository_idle()?; let document = self.open_active_document(path)?; self.store_editor(MobileEntryDraft::new(document, false)) } @@ -1637,6 +1807,7 @@ impl MobileAuthentication { name: &str, passphrase: Option, ) -> Result { + self.ensure_repository_idle()?; let directory = DirectoryPath::parse(directory).map_err(entry_error)?; let leaf = EntryPath::parse(name).map_err(entry_error)?; if leaf @@ -1737,6 +1908,7 @@ impl MobileAuthentication { editor: u64, fields: Vec, ) -> Result { + self.ensure_repository_idle()?; self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; @@ -1751,6 +1923,7 @@ impl MobileAuthentication { name: Option, value: String, ) -> Result { + self.ensure_repository_idle()?; self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; @@ -1763,6 +1936,7 @@ impl MobileAuthentication { editor: u64, image: SecretBytes, ) -> Result { + self.ensure_repository_idle()?; self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; @@ -1775,6 +1949,7 @@ impl MobileAuthentication { editor: u64, field: u64, ) -> Result { + self.ensure_repository_idle()?; self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; @@ -1788,6 +1963,7 @@ impl MobileAuthentication { field: u64, index: usize, ) -> Result { + self.ensure_repository_idle()?; self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; @@ -1801,6 +1977,7 @@ impl MobileAuthentication { length: Option, no_symbols: bool, ) -> Result { + self.ensure_repository_idle()?; self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; @@ -1817,6 +1994,17 @@ impl MobileAuthentication { ) -> Result { self.ensure_active()?; self.ensure_repository_idle()?; + self.reserve_entry_mutation()?; + let result = self.save_reserved_entry_editor(editor, fields); + self.release_entry_mutation(); + result + } + + fn save_reserved_entry_editor( + &self, + editor: u64, + fields: Vec, + ) -> Result { let mut draft = self .status()? .editors @@ -2169,10 +2357,11 @@ impl MobileAuthentication { } fn ensure_repository_idle(&self) -> Result<(), MobileAuthenticationError> { - if self.status()?.repository_operation_active { + let status = self.status()?; + if status.repository_operation_active || status.mutation_active { Err(entry_detail( "Repository Action In Progress", - "wait for the current commit, fetch, pull, or push action to finish", + "wait for the current mutation, commit, fetch, pull, or push action to finish", )) } else { Ok(()) @@ -2187,17 +2376,25 @@ impl MobileAuthentication { fn editor_state(&self, path: &str) -> Result<(bool, bool), MobileAuthenticationError> { let status = self.status()?; - let mut matching = status + let active = status .editors .values() - .filter(|draft| draft.document().path().to_string() == path); - let Some(first) = matching.next() else { - return Ok((false, false)); - }; - Ok(( - true, - first.document().is_modified() || matching.any(|draft| draft.document().is_modified()), - )) + .filter(|draft| mutation_contains(path, draft.document().path())); + let sealed = status + .editor_checkpoints + .values() + .filter(|checkpoint| mutation_contains(path, checkpoint.document.path())); + let mut open = false; + let mut dirty = false; + for document in active.map(|draft| draft.document()) { + open = true; + dirty |= document.is_modified(); + } + for checkpoint in sealed { + open = true; + dirty |= checkpoint.document.is_modified() || checkpoint.inputs.is_some(); + } + Ok((open, dirty)) } fn take_entry_editors( @@ -2208,7 +2405,7 @@ impl MobileAuthentication { let ids = status .editors .iter() - .filter(|(_, draft)| draft.document().path().to_string() == path) + .filter(|(_, draft)| mutation_contains(path, draft.document().path())) .map(|(id, _)| *id) .collect::>(); Ok(ids @@ -2230,7 +2427,9 @@ impl MobileAuthentication { editor: u64, draft: MobileEntryDraft, ) -> Result<(), MobileAuthenticationError> { - self.status()?.editors.insert(editor, draft); + if self.ensure_active().is_ok() { + self.status()?.editors.insert(editor, draft); + } Ok(()) } @@ -2269,6 +2468,16 @@ impl MobileAuthentication { } } +fn mutation_contains(source: &str, entry: &EntryPath) -> bool { + if source.ends_with('/') { + entry + .as_path() + .starts_with(Path::new(source.trim_end_matches('/'))) + } else { + entry.to_string() == source + } +} + fn repository_operation_conflict( operation: MobileRepositoryOperation, busy: bool, @@ -2895,6 +3104,161 @@ mod tests { assert!(!authentication.state().unwrap().unlocked()); } + #[test] + fn navigation_mutations_keep_dirty_checkpoints_on_failure_and_discard_them_only_after_success() + { + use crate::{ + mobile_mutation::{MobileMutationAction, MobileMutationRequest}, + repository::{EntryPath, SecretBytes}, + }; + let (_root, auth, _) = otp_fixture("otpauth://totp/Fixture:alice?secret=JBSWY3DPEHPK3PXP"); + let navigation = crate::mobile_passwords::navigation::navigate_repository( + &auth.repository, + "", + None, + None, + false, + ) + .unwrap(); + let row = navigation + .rows + .iter() + .find(|row| row.row.path() == "otp") + .unwrap(); + let plan = auth + .prepare_navigation_mutation(row.row.id(), &row.revision, MobileMutationAction::Delete) + .unwrap(); + assert!(!auth.state().unwrap().unlocked()); + auth.unlock_mutation( + plan.source(), + plan.revision(), + Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), + ) + .unwrap(); + let editor = auth.begin_entry_editor("otp").unwrap(); + let first = editor.page().fields()[0].id(); + auth.update_entry_editor( + editor.id(), + editor + .page() + .fields() + .iter() + .map(|field| { + crate::mobile_entry::MobileEntryEditorInput::new( + field.id(), + field.name().map(str::to_owned), + if field.id() == first { + Some("unsaved value".to_owned()) + } else { + field.value().map(str::to_owned) + }, + ) + }) + .collect(), + ) + .unwrap(); + auth.checkpoint_entry_editor(editor.id(), 1).unwrap(); + let plan = auth + .prepare_navigation_mutation(row.row.id(), &row.revision, MobileMutationAction::Delete) + .unwrap(); + assert!(plan.has_open_editor()); + assert!(plan.has_dirty_editor()); + let mut request = MobileMutationRequest { + action: MobileMutationAction::Delete, + source: plan.source().to_owned(), + revision: plan.revision().to_owned(), + destination: None, + confirmed: true, + overwrite: false, + discard_editor: false, + }; + assert!( + auth.perform_navigation_mutation(request.clone(), "") + .is_err() + ); + assert_eq!( + auth.entry_editor(editor.id()).unwrap().fields()[0].value(), + Some("unsaved value") + ); + let path = EntryPath::parse("otp").unwrap(); + let original = auth.repository.read_entry(&path).unwrap(); + let lock = auth.repository.root_path().join(".git/index.lock"); + std::fs::write(&lock, b"locked").unwrap(); + request.discard_editor = true; + assert!( + auth.perform_navigation_mutation(request.clone(), "") + .is_err() + ); + assert_eq!(auth.repository.read_entry(&path).unwrap(), original); + assert_eq!( + auth.entry_editor(editor.id()).unwrap().fields()[0].value(), + Some("unsaved value") + ); + assert!( + auth.status() + .unwrap() + .editor_checkpoints + .contains_key(&editor.id()) + ); + std::fs::remove_file(lock).unwrap(); + assert!( + auth.prepare_navigation_mutation( + row.row.id(), + &row.revision, + MobileMutationAction::Delete + ) + .is_err() + ); + let refreshed = crate::mobile_passwords::navigation::navigate_repository( + &auth.repository, + "", + None, + None, + false, + ) + .unwrap(); + let row = refreshed + .rows + .iter() + .find(|row| row.row.path() == "otp") + .unwrap(); + auth.manual_lock().unwrap(); + assert_eq!(auth.editor_state("otp").unwrap(), (true, true)); + let plan = auth + .prepare_navigation_mutation(row.row.id(), &row.revision, MobileMutationAction::Delete) + .unwrap(); + request.revision = plan.revision().to_owned(); + assert!( + auth.perform_navigation_mutation(request.clone(), "") + .is_err() + ); + auth.unlock_mutation( + plan.source(), + plan.revision(), + Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), + ) + .unwrap(); + let outcome = auth.perform_navigation_mutation(request, "").unwrap(); + assert_eq!(outcome.result.discarded_editors(), &[editor.id()]); + assert!(auth.repository.read_entry(&path).is_err()); + assert!( + outcome + .navigation + .rows + .iter() + .all(|row| row.row.path() != "otp") + ); + assert!(auth.resume_entry_editor(editor.id(), None).is_err()); + assert!( + auth.prepare_navigation_mutation( + row.row.id(), + &row.revision, + MobileMutationAction::Delete + ) + .is_err() + ); + } + #[test] fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() { use crate::authentication::SystemAuthenticationClock; diff --git a/crates/storage/src/mobile_mutation.rs b/crates/storage/src/mobile_mutation.rs index 39ad591..edaefdb 100644 --- a/crates/storage/src/mobile_mutation.rs +++ b/crates/storage/src/mobile_mutation.rs @@ -2,15 +2,14 @@ use std::{error::Error, fmt}; -use data_encoding::HEXLOWER; -use sha2::{Digest as _, Sha256}; - use crate::{ command::{CopyRequest, MoveRequest, RemoveRequest}, crypto::{KeyStore, SecretProvider}, mutation::{MutationError, TreeCommitter, TreeMutator}, read::hidden_path, - repository::{DirectoryPath, EntryPath, Repository, RepositoryError}, + repository::{ + DirectoryPath, EntryPath, Repository, RepositoryError, RepositorySnapshot, ResolvedObject, + }, write::OverwriteDecision, }; @@ -27,9 +26,22 @@ pub struct MobileMutationDestination { title: String, detail: String, requires_overwrite: bool, + depth: u32, + unavailable: Option, + collision_targets: Vec, } impl MobileMutationDestination { + pub fn collision_targets(&self) -> &[String] { + &self.collision_targets + } + pub fn depth(&self) -> u32 { + self.depth + } + pub fn unavailable(&self) -> Option<&str> { + self.unavailable.as_deref() + } + pub fn path(&self) -> &str { &self.path } @@ -56,9 +68,18 @@ pub struct MobileMutationPlan { destinations: Vec, has_open_editor: bool, has_dirty_editor: bool, + directory: bool, + entries: u32, } impl MobileMutationPlan { + pub fn is_directory(&self) -> bool { + self.directory + } + pub fn entries(&self) -> u32 { + self.entries + } + pub fn action(&self) -> MobileMutationAction { self.action } @@ -106,9 +127,20 @@ pub struct MobileMutationOutcome { destination: Option, title: String, detail: String, + selection: Option, + discarded_editors: Vec, } impl MobileMutationOutcome { + pub fn selection(&self) -> Option<&crate::read::TreeNodeId> { + self.selection.as_ref() + } + pub fn discarded_editors(&self) -> &[u64] { + &self.discarded_editors + } + pub(crate) fn set_discarded_editors(&mut self, editors: Vec) { + self.discarded_editors = editors; + } pub fn action(&self) -> MobileMutationAction { self.action } @@ -146,53 +178,69 @@ impl<'a> MobileMutationService<'a> { has_open_editor: bool, has_dirty_editor: bool, ) -> Result { - let source = EntryPath::parse(source)?; - if hidden_path(source.as_path()) { + let snapshot = self.repository.snapshot()?; + let (path, directory) = source_path(&snapshot, source)?; + if hidden_path(path.as_path()) || path.as_path().as_os_str().is_empty() { return Err(MobileMutationError::InvalidSource { - path: source.to_string(), + path: source.to_owned(), }); } - let ciphertext = self.repository.read_entry(&source)?; - let snapshot = self.repository.snapshot()?; - let source_title = source + let source_title = path .as_path() .file_name() .and_then(|name| name.to_str()) - .expect("a string entry path has a UTF-8 file name") + .ok_or_else(|| MobileMutationError::InvalidSource { + path: source.to_owned(), + })? .to_owned(); let destinations = if action == MobileMutationAction::Delete { Vec::new() } else { snapshot .directories() - .filter(|directory| !hidden_path(directory.path().as_path())) - .filter_map(|directory| { - let destination = EntryPath::parse( - directory - .path() - .as_path() - .join(source.as_path().file_name()?), - ) - .ok()?; - (destination != source).then(|| MobileMutationDestination { - path: directory_path(directory.path()), - title: directory_title(directory.path()), - detail: directory_detail(directory.path()), - requires_overwrite: snapshot - .entries() - .any(|entry| entry.path() == &destination), + .filter(|record| !hidden_path(record.path().as_path())) + .filter_map(|record| { + let target = record.path().as_path().join(path.as_path().file_name()?); + if target == path.as_path() || directory && target.starts_with(path.as_path()) { + return None; + } + let (collision_targets, unavailable) = + collision(&snapshot, &path, directory, &target); + Some(MobileMutationDestination { + path: directory_path(record.path()), + title: directory_title(record.path()), + detail: directory_detail(record.path()), + requires_overwrite: !collision_targets.is_empty(), + collision_targets, + depth: u32::try_from(record.path().as_path().components().count()) + .unwrap_or(u32::MAX), + unavailable, }) }) .collect() }; + let entries = if directory { + snapshot + .entries() + .filter(|entry| entry.path().as_path().starts_with(path.as_path())) + .count() + } else { + 1 + }; Ok(MobileMutationPlan { action, - source: source.to_string(), + source: if directory { + format!("{path}/") + } else { + path.to_string() + }, source_title, - revision: revision(ciphertext.as_bytes()), + revision: self.repository.mutation_revision()?, destinations, has_open_editor, has_dirty_editor, + directory, + entries: u32::try_from(entries).unwrap_or(u32::MAX), }) } @@ -203,9 +251,25 @@ impl<'a> MobileMutationService<'a> { keys: &KeyStore, provider: &mut impl SecretProvider, committer: &mut impl TreeCommitter, + ) -> Result { + self.perform_guarded(request, has_open_editor, keys, provider, committer, &|| { + Ok(()) + }) + } + + pub(crate) fn perform_guarded( + &self, + request: &MobileMutationRequest, + has_open_editor: bool, + keys: &KeyStore, + provider: &mut impl SecretProvider, + committer: &mut impl TreeCommitter, + validation: &(dyn Fn() -> Result<(), MutationError> + Sync), ) -> Result { let destination = self.preflight(request, has_open_editor)?; - let mutator = TreeMutator::new(self.repository, keys); + let mutator = TreeMutator::new(self.repository, keys) + .with_revision(&request.revision) + .with_validation(validation); let overwrite = if request.overwrite { OverwriteDecision::Allow } else { @@ -215,7 +279,10 @@ impl<'a> MobileMutationService<'a> { MobileMutationAction::Delete => mutator.remove( &RemoveRequest { entry: request.source.clone(), - recursive: false, + recursive: matches!( + self.repository.snapshot()?.resolve(&request.source)?, + ResolvedObject::Directory(_) + ), force: false, }, OverwriteDecision::Allow, @@ -244,6 +311,14 @@ impl<'a> MobileMutationService<'a> { committer, )?, }; + let selection = outcome.selection().map(|selection| match selection { + crate::mutation::MutationSelection::Entry(path) => { + crate::read::TreeNodeId::Entry(path.clone()) + } + crate::mutation::MutationSelection::Directory(path) => { + crate::read::TreeNodeId::Directory(path.clone()) + } + }); let destination = outcome .selection() .map(|selection| selection.display_path()); @@ -274,6 +349,8 @@ impl<'a> MobileMutationService<'a> { destination, title: title.to_owned(), detail, + selection, + discarded_editors: Vec::new(), }) } @@ -282,14 +359,14 @@ impl<'a> MobileMutationService<'a> { request: &MobileMutationRequest, has_open_editor: bool, ) -> Result, MobileMutationError> { - let source = EntryPath::parse(&request.source)?; - if hidden_path(source.as_path()) { + let snapshot = self.repository.snapshot()?; + let (source, directory) = source_path(&snapshot, &request.source)?; + if hidden_path(source.as_path()) || source.as_path().as_os_str().is_empty() { return Err(MobileMutationError::InvalidSource { path: source.to_string(), }); } - let current = self.repository.read_entry(&source)?; - if revision(current.as_bytes()) != request.revision { + if self.repository.mutation_revision()? != request.revision { return Err(MobileMutationError::StaleEntry { path: source.to_string(), }); @@ -328,19 +405,24 @@ impl<'a> MobileMutationService<'a> { .as_path() .join(source.as_path().file_name().expect("validated source name")), )?; - if destination_entry == source { + if destination_entry.as_path() == source.as_path() { return Err(MobileMutationError::SameDestination); } - let destination_exists = match self.repository.read_entry(&destination_entry) { - Ok(_) => true, - Err(RepositoryError::NotFound { .. }) => false, - Err(error) => return Err(error.into()), - }; - if destination_exists && !request.overwrite { + let (destination_exists, unavailable) = + collision(&snapshot, &source, directory, destination_entry.as_path()); + if let Some(detail) = unavailable { + return Err(MobileMutationError::DestinationUnavailable { detail }); + } + if !destination_exists.is_empty() && !request.overwrite { return Err(MobileMutationError::OverwriteRequired { path: destination_entry.to_string(), }); } + if !request.confirmed { + return Err(MobileMutationError::ConfirmationRequired { + path: source.to_string(), + }); + } let destination = directory_path(&destination); Ok(Some(if destination.is_empty() { destination @@ -350,8 +432,126 @@ impl<'a> MobileMutationService<'a> { } } -fn revision(ciphertext: &[u8]) -> String { - HEXLOWER.encode(&Sha256::digest(ciphertext)) +fn source_path( + snapshot: &RepositorySnapshot, + source: &str, +) -> Result<(DirectoryPath, bool), MobileMutationError> { + Ok(match snapshot.resolve(source)? { + ResolvedObject::Entry(record) => (DirectoryPath::parse(record.path().as_path())?, false), + ResolvedObject::Directory(record) => (record.path().clone(), true), + }) +} + +fn collision( + snapshot: &RepositorySnapshot, + source: &DirectoryPath, + directory: bool, + target: &std::path::Path, +) -> (Vec, Option) { + if directory && target.starts_with(source.as_path()) { + return ( + Vec::new(), + Some("A folder cannot be moved or copied into itself.".to_owned()), + ); + } + if snapshot + .entries() + .any(|entry| entry.path().as_path() == target) + && directory + || snapshot + .directories() + .any(|record| record.path().as_path() == target) + && !directory + { + return ( + Vec::new(), + Some("An entry and a folder cannot replace each other.".to_owned()), + ); + } + if !directory { + return ( + snapshot + .entries() + .filter(|entry| entry.path().as_path() == target) + .map(|entry| entry.path().to_string()) + .collect(), + None, + ); + } + for policy in snapshot + .recipient_policies() + .filter(|policy| policy.directory().as_path().starts_with(source.as_path())) + { + let destination = target.join( + policy + .directory() + .as_path() + .strip_prefix(source.as_path()) + .expect("source prefix"), + ); + if snapshot + .recipient_policies() + .any(|existing| existing.directory().as_path() == destination) + { + return ( + Vec::new(), + Some( + "The destination has a recipient policy that cannot be overwritten safely." + .to_owned(), + ), + ); + } + } + let mut overwrite = Vec::new(); + for entry in snapshot + .entries() + .filter(|entry| entry.path().as_path().starts_with(source.as_path())) + { + let destination = target.join( + entry + .path() + .as_path() + .strip_prefix(source.as_path()) + .expect("source prefix"), + ); + if snapshot + .directories() + .any(|record| record.path().as_path() == destination) + { + return ( + Vec::new(), + Some("A destination folder conflicts with an entry in the source.".to_owned()), + ); + } + if snapshot + .entries() + .any(|existing| existing.path().as_path() == destination) + { + overwrite.push(destination.to_string_lossy().into_owned()); + } + } + for record in snapshot + .directories() + .filter(|record| record.path().as_path().starts_with(source.as_path())) + { + let destination = target.join( + record + .path() + .as_path() + .strip_prefix(source.as_path()) + .expect("source prefix"), + ); + if snapshot + .entries() + .any(|entry| entry.path().as_path() == destination) + { + return ( + Vec::new(), + Some("A destination entry conflicts with a folder in the source.".to_owned()), + ); + } + } + (overwrite, None) } fn directory_path(path: &DirectoryPath) -> String { @@ -384,6 +584,7 @@ pub enum MobileMutationError { ConfirmationRequired { path: String }, DestinationRequired, InvalidDestination { path: String }, + DestinationUnavailable { detail: String }, SameDestination, OverwriteRequired { path: String }, } @@ -405,7 +606,9 @@ impl MobileMutationError { Self::EditorOpen { .. } => "Password Editor Is Open", Self::ConfirmationRequired { .. } => "Delete Confirmation Required", Self::DestinationRequired => "Destination Required", - Self::InvalidDestination { .. } | Self::SameDestination => "Destination Is Invalid", + Self::InvalidDestination { .. } + | Self::DestinationUnavailable { .. } + | Self::SameDestination => "Destination Is Invalid", Self::OverwriteRequired { .. } => "Password Already Exists", Self::InvalidSource { .. } => "Password Entry Is Unavailable", Self::Repository(_) | Self::Mutation(_) => "Password Action Failed", @@ -439,6 +642,7 @@ impl fmt::Display for MobileMutationError { "the selected password folder is unavailable: {path}" ) } + Self::DestinationUnavailable { detail } => formatter.write_str(detail), Self::SameDestination => { formatter.write_str("the source is already in the selected folder") } @@ -477,6 +681,70 @@ mod tests { }; use crate::repository::Repository; + #[test] + fn lease_revocation_before_apply_does_not_delete_a_confirmed_entry() + -> Result<(), Box> { + use crate::{ + crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + mutation::{MutationError, TreeCommit, TreeCommitError, TreeCommitter}, + repository::{EntryPath, SecretBytes}, + }; + struct NoSecrets; + impl SecretProvider for NoSecrets { + fn secret_for(&mut self, _: &KeyInfo) -> Result { + Err(SecretProviderError::Missing) + } + } + struct NoCommits; + impl TreeCommitter for NoCommits { + fn commit(&mut self, _: &TreeCommit) -> Result<(), TreeCommitError> { + panic!("revoked mutation must not commit") + } + } + let root = tempdir()?; + fs::write(root.path().join("entry.gpg"), b"encrypted-entry")?; + let repository = Repository::open(root.path())?; + let keys = KeyStore::new(); + let service = MobileMutationService::new(&repository); + let plan = service.prepare("entry", MobileMutationAction::Delete, false, false)?; + let validations = std::sync::atomic::AtomicUsize::new(0); + let result = service.perform_guarded( + &MobileMutationRequest { + action: MobileMutationAction::Delete, + source: "entry".into(), + revision: plan.revision().into(), + destination: None, + confirmed: true, + overwrite: false, + discard_editor: false, + }, + false, + &keys, + &mut NoSecrets, + &mut NoCommits, + &|| { + let count = validations.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + if count == 1 { + Ok(()) + } else { + Err(MutationError::Cancelled) + } + }, + ); + assert!(matches!( + result, + Err(MobileMutationError::Mutation(MutationError::Cancelled)) + )); + assert_eq!(validations.load(std::sync::atomic::Ordering::Relaxed), 2); + assert_eq!( + repository + .read_entry(&EntryPath::parse("entry")?)? + .as_bytes(), + b"encrypted-entry" + ); + Ok(()) + } + #[test] fn plans_destinations_collisions_hidden_paths_and_stale_revisions() -> Result<(), Box> { diff --git a/crates/storage/src/mobile_passwords/navigation.rs b/crates/storage/src/mobile_passwords/navigation.rs index aa4d6f6..26a5f0f 100644 --- a/crates/storage/src/mobile_passwords/navigation.rs +++ b/crates/storage/src/mobile_passwords/navigation.rs @@ -136,6 +136,37 @@ fn open_search_repository( Ok(page) } +pub(crate) fn checked_row( + repository: &Repository, + id: &str, + revision: &str, +) -> Result { + let object = if let Some(path) = id.strip_prefix("entry:") { + crate::read::TreeNodeId::Entry( + EntryPath::parse(path).map_err(MobilePasswordError::repository)?, + ) + } else if let Some(path) = id.strip_prefix("directory:") { + let path = DirectoryPath::parse(path).map_err(MobilePasswordError::repository)?; + if path.as_path().as_os_str().is_empty() { + return Err(MobilePasswordError::invalid_path()); + } + crate::read::TreeNodeId::Directory(path) + } else { + return Err(MobilePasswordError::invalid_path()); + }; + let current = repository + .navigation_revision(object.path(), object.entry().is_some()) + .map_err(MobilePasswordError::repository)?; + if HEXLOWER.encode(&Sha256::digest(current.as_bytes())) != revision { + return Err(MobilePasswordError { + kind: MobilePasswordErrorKind::InvalidPath, + title: "Password Object Changed".to_owned(), + detail: "Refresh the password tree before selecting an action.".to_owned(), + }); + } + Ok(object) +} + fn unavailable() -> MobilePasswordError { MobilePasswordError { kind: MobilePasswordErrorKind::Configuration, diff --git a/crates/storage/src/mutation.rs b/crates/storage/src/mutation.rs index 11c6f72..d1b1767 100644 --- a/crates/storage/src/mutation.rs +++ b/crates/storage/src/mutation.rs @@ -129,11 +129,45 @@ impl MutationSelection { pub struct TreeMutator<'a> { repository: &'a Repository, keys: &'a KeyStore, + expected_revision: Option<&'a str>, + validation: Option<&'a (dyn Fn() -> Result<(), MutationError> + Sync)>, } impl<'a> TreeMutator<'a> { pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { - Self { repository, keys } + Self { + repository, + keys, + expected_revision: None, + validation: None, + } + } + + pub(crate) fn with_revision(mut self, revision: &'a str) -> Self { + self.expected_revision = Some(revision); + self + } + + pub(crate) fn with_validation( + mut self, + validation: &'a (dyn Fn() -> Result<(), MutationError> + Sync), + ) -> Self { + self.validation = Some(validation); + self + } + + fn check_revision(&self) -> Result<(), MutationError> { + if let Some(expected) = self.expected_revision + && self.repository.mutation_revision()? != expected + { + return Err(MutationError::TreeChanged { + path: std::path::PathBuf::new(), + }); + } + if let Some(validation) = self.validation { + validation()?; + } + Ok(()) } pub fn remove( @@ -142,6 +176,7 @@ impl<'a> TreeMutator<'a> { decision: OverwriteDecision, committer: &mut impl TreeCommitter, ) -> Result { + self.check_revision()?; let snapshot = self.repository.snapshot()?; let resolved = snapshot.resolve(&request.entry)?; if !request.force && decision == OverwriteDecision::Decline { @@ -175,6 +210,7 @@ impl<'a> TreeMutator<'a> { ) } }; + self.check_revision()?; if let Err(operation) = self.remove_sources(&entries, &policies, &directories) { return Err(with_rollback( operation, @@ -265,6 +301,7 @@ impl<'a> TreeMutator<'a> { provider: &mut impl SecretProvider, committer: &mut impl TreeCommitter, ) -> Result { + self.check_revision()?; let snapshot = self.repository.snapshot()?; let resolved = snapshot.resolve(source)?; let requested_destination = DirectoryPath::parse(destination)?; @@ -460,6 +497,7 @@ impl<'a> TreeMutator<'a> { }) .collect::, _>>()? }; + self.check_revision()?; let mut created_directories = Vec::new(); let applied = (|| -> Result<(), MutationError> { for directory in &destination_directories { diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index 30d51dc..9df6e9c 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -391,6 +391,54 @@ impl Repository { Ok(EncryptedEntry(bytes)) } + /// Bind confirmed mutations to ciphertext, policy, and filesystem object identities. + pub(crate) fn mutation_revision(&self) -> Result { + use sha2::{Digest, Sha256}; + let snapshot = self.snapshot()?; + let mut digest = Sha256::new(); + let mut field = |bytes: &[u8]| { + digest.update((bytes.len() as u64).to_le_bytes()); + digest.update(bytes); + }; + for directory in snapshot.directories() { + field(b"directory"); + field(directory.path().as_path().as_os_str().as_encoded_bytes()); + field( + self.navigation_revision(directory.path().as_path(), false)? + .as_bytes(), + ); + } + for entry in snapshot.entries() { + field(b"entry"); + field(entry.path().as_path().as_os_str().as_encoded_bytes()); + field( + self.navigation_revision(entry.path().as_path(), true)? + .as_bytes(), + ); + field(self.read_entry(entry.path())?.as_bytes()); + } + for policy in snapshot.recipient_policies() { + field(b"policy"); + field(policy.directory().as_path().as_os_str().as_encoded_bytes()); + field( + &self + .read_policy_file(policy.directory(), false)? + .unwrap_or_default(), + ); + field( + &self + .read_policy_file(policy.directory(), true)? + .unwrap_or_default(), + ); + } + for auxiliary in snapshot.auxiliary_files() { + field(b"auxiliary"); + field(auxiliary.path().as_os_str().as_encoded_bytes()); + field(&auxiliary.length().to_le_bytes()); + } + Ok(data_encoding::HEXLOWER.encode(&digest.finalize())) + } + /// A non-secret object revision; navigation never opens encrypted contents. pub(crate) fn navigation_revision( &self, diff --git a/crates/storage/tests/tree_mutations.rs b/crates/storage/tests/tree_mutations.rs index d6b3f8e..0e21d40 100644 --- a/crates/storage/tests/tree_mutations.rs +++ b/crates/storage/tests/tree_mutations.rs @@ -16,6 +16,249 @@ use ironstorage::{ }; use support::compatibility::{FixtureSet, TestResult}; +#[test] +fn mobile_tree_plans_confirm_folders_and_preserve_pass_recipient_semantics() -> TestResult { + use ironstorage::mobile_mutation::{ + MobileMutationAction as Action, MobileMutationRequest, MobileMutationService, + }; + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + fs::create_dir(store.path().join("archive"))?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let service = MobileMutationService::new(&repository); + let mut secrets = Secrets::all(&fixture); + let mut committer = Committer::default(); + let original = repository.read_entry(&EntryPath::parse("email/personal")?)?; + let plan = service.prepare("email/", Action::Copy, false, false)?; + assert!(plan.is_directory()); + assert_eq!(plan.entries(), 1); + assert!( + plan.destinations() + .iter() + .all(|destination| destination.path() != "email") + ); + let mut request = MobileMutationRequest { + action: Action::Copy, + source: plan.source().into(), + revision: plan.revision().into(), + destination: Some("team".into()), + confirmed: false, + overwrite: false, + discard_editor: false, + }; + assert!( + service + .perform(&request, false, &keys, &mut secrets, &mut committer) + .is_err() + ); + assert!(committer.changes.is_empty()); + assert!(!store.path().join("team/email").exists()); + request.confirmed = true; + service.perform(&request, false, &keys, &mut secrets, &mut committer)?; + assert_eq!( + repository.read_entry(&EntryPath::parse("email/personal")?)?, + original + ); + let copied = repository.read_entry(&EntryPath::parse("team/email/personal")?)?; + assert_ne!(copied, original); + assert!(keys.is_encrypted_for( + &copied, + &[keys.resolve(&fixture.key("bob")?.primary_fingerprint)?] + )?); + assert_eq!( + keys.decrypt(&copied, &mut secrets)?.expose(), + fixture.read("expected/basic/email/personal.txt")? + ); + let plan = service.prepare("team/", Action::Move, false, false)?; + let policy = fs::read(store.path().join("team/.gpg-id"))?; + service.perform( + &MobileMutationRequest { + action: Action::Move, + source: plan.source().into(), + revision: plan.revision().into(), + destination: Some("archive".into()), + confirmed: true, + overwrite: false, + discard_editor: false, + }, + false, + &keys, + &mut secrets, + &mut committer, + )?; + assert!(!store.path().join("team").exists()); + assert_eq!(fs::read(store.path().join("archive/team/.gpg-id"))?, policy); + assert_eq!( + repository.read_entry(&EntryPath::parse("archive/team/email/personal")?)?, + copied + ); + let plan = service.prepare("archive/team/", Action::Delete, false, false)?; + let mut request = MobileMutationRequest { + action: Action::Delete, + source: plan.source().into(), + revision: plan.revision().into(), + destination: None, + confirmed: false, + overwrite: false, + discard_editor: false, + }; + assert!( + service + .perform(&request, false, &keys, &mut secrets, &mut committer) + .is_err() + ); + assert!(store.path().join("archive/team").exists()); + request.confirmed = true; + service.perform(&request, false, &keys, &mut secrets, &mut committer)?; + assert!(!store.path().join("archive/team").exists()); + assert_eq!( + repository.read_entry(&EntryPath::parse("email/personal")?)?, + original + ); + Ok(()) +} + +#[test] +fn mobile_overwrite_is_explicit_and_destination_replacement_invalidates_the_plan() -> TestResult { + use ironstorage::mobile_mutation::{ + MobileMutationAction as Action, MobileMutationError, MobileMutationRequest, + MobileMutationService, + }; + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let path = EntryPath::parse("team/personal")?; + let original = repository.read_entry(&EntryPath::parse("email/personal")?)?; + repository.write_entry(&path, &original)?; + let service = MobileMutationService::new(&repository); + let plan = service.prepare("email/personal", Action::Copy, false, false)?; + let target = plan + .destinations() + .iter() + .find(|target| target.path() == "team") + .unwrap(); + assert!(target.requires_overwrite()); + assert_eq!(target.collision_targets(), &["team/personal"]); + let mut request = MobileMutationRequest { + action: Action::Copy, + source: plan.source().into(), + revision: plan.revision().into(), + destination: Some("team".into()), + confirmed: true, + overwrite: false, + discard_editor: false, + }; + let mut secrets = Secrets::all(&fixture); + let mut committer = Committer::default(); + assert!(matches!( + service.perform(&request, false, &keys, &mut secrets, &mut committer), + Err(MobileMutationError::OverwriteRequired { .. }) + )); + repository.write_entry(&path, &original)?; + request.overwrite = true; + assert!(matches!( + service.perform(&request, false, &keys, &mut secrets, &mut committer), + Err(MobileMutationError::StaleEntry { .. }) + )); + assert_eq!(repository.read_entry(&path)?, original); + assert!(committer.changes.is_empty()); + Ok(()) +} + +#[test] +fn mobile_rechecks_revisions_after_reencryption_and_rolls_back_failed_commits() -> TestResult { + use ironstorage::mobile_mutation::{ + MobileMutationAction as Action, MobileMutationRequest, MobileMutationService, + }; + struct ReplacingSecrets<'a> { + inner: Secrets, + repository: &'a Repository, + path: EntryPath, + } + impl SecretProvider for ReplacingSecrets<'_> { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + self.repository + .write_entry( + &self.path, + &ironstorage::repository::EncryptedEntry::new(b"replacement".to_vec()), + ) + .map_err(|_| SecretProviderError::Unavailable)?; + self.inner.secret_for(key) + } + } + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + fs::create_dir(store.path().join("archive"))?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let service = MobileMutationService::new(&repository); + let original = repository.read_entry(&EntryPath::parse("email/personal")?)?; + let plan = service.prepare("email/personal", Action::Move, false, false)?; + let mut provider = ReplacingSecrets { + inner: Secrets::all(&fixture), + repository: &repository, + path: EntryPath::parse("email/personal")?, + }; + let mut committer = Committer::default(); + assert!( + service + .perform( + &MobileMutationRequest { + action: Action::Move, + source: plan.source().into(), + revision: plan.revision().into(), + destination: Some("team".into()), + confirmed: true, + overwrite: false, + discard_editor: false, + }, + false, + &keys, + &mut provider, + &mut committer + ) + .is_err() + ); + assert_eq!( + repository + .read_entry(&EntryPath::parse("email/personal")?)? + .as_bytes(), + b"replacement" + ); + assert!(!store.path().join("team/personal.gpg").exists()); + assert!(committer.changes.is_empty()); + repository.write_entry(&EntryPath::parse("email/personal")?, &original)?; + let plan = service.prepare("email/", Action::Move, false, false)?; + committer.fail = true; + assert!( + service + .perform( + &MobileMutationRequest { + action: Action::Move, + source: plan.source().into(), + revision: plan.revision().into(), + destination: Some("archive".into()), + confirmed: true, + overwrite: false, + discard_editor: false, + }, + false, + &keys, + &mut Secrets::all(&fixture), + &mut committer + ) + .is_err() + ); + assert_eq!( + repository.read_entry(&EntryPath::parse("email/personal")?)?, + original + ); + assert!(!store.path().join("archive/email").exists()); + Ok(()) +} + struct Secrets(BTreeMap>); impl Secrets { fn all(fixture: &FixtureSet) -> Self { diff --git a/docs/android/mutations.md b/docs/android/mutations.md new file mode 100644 index 0000000..24a15aa --- /dev/null +++ b/docs/android/mutations.md @@ -0,0 +1,61 @@ +# Android move, copy and delete + +Issue [#108](https://git.rfc1437.de/hugo/IronStorage/issues/108) exposes entry and +folder mutations from row action buttons, long press, TalkBack actions, swipe and +the normal entry viewer's More menu. Swiping right selects Move; swiping left +selects Delete. The row resets and opens the action dialog. A swipe never applies +a mutation. Every action requires explicit confirmation of the Rust-provided +target, and deletion also identifies the folder's contents. + +The dialog works from locked rows. Its metadata is read without decryption; its +Unlock action authenticates through the shared Rust lease before confirmation can +execute. Folder authentication uses an in-memory encryption probe for its actual +recipient policy, including empty folders. This creates no password-store entry. + +Rust provides destination hierarchy depth, labels, validation, collisions and +the exact replacement targets. Unavailable destinations are disabled. Replacing +entries requires a separate checkbox, as does discarding open editors or unsaved +edits. Changing destination resets overwrite consent. Pending dialogs and their +confirmation flags are never saved across process recreation and are cancelled +by background, relock or presentation revocation. + +## Transaction and revision ownership + +`crates/storage` validates typed row IDs against their filesystem revisions, +then binds plans to the managed tree's ciphertext, recipient policies and object +identities. Changes anywhere in that tree conservatively require a fresh plan; +an existing target replaced after preparation cannot inherit prior overwrite +consent. The transaction engine checks the revision and authentication lease +again after any reencryption and immediately before applying changes. + +The existing pass-compatible `TreeMutator` owns selective reencryption, policy +preservation, recursive deletion, rollback, empty-directory cleanup and automatic +Git commits. Unsupported auxiliary files or nested Git repositories remain +protected by its validation. Mobile reservations block overlapping repository +actions and entry/editor mutation while a transaction is applying. + +Editor coordination includes encrypted checkpoints left after background or +relock. Failure retains recoverable edits while an expired lease drops plaintext. +Confirmed success removes only affected editors and checkpoints and reports their +typed identities to the Android presentation owner. It cannot resurrect a +discarded draft. Rust also returns navigation following the copied/moved object +or the nearest surviving folder after deletion; rejected transactions refresh the +current tree. Kotlin renders those results and performs no repository I/O, +recipient selection, target-path construction or commit policy. + +## Validation + +Upstream-compatible encrypted fixtures prove folder copy's selective reencryption, +policy/ciphertext preservation on move, recursive delete confirmation, explicit +overwrite, replacement-target rejection, a source changing during reencryption, +commit rollback, pre-apply lease revocation and checkpoint retention/discard. +NativeTreeMutationTest runs real encrypted stores through the Kotlin facade and +checks locked preparation, dirty editor consent, navigation, stale rows and +background interruption. MutationWorkflowTest checks full swipes, cancellation, +separate overwrite/discard consent and folder TalkBack/menu actions. Its optional +inspection mode supplies disposable presentation fixtures for computer use. + +Emulator updates use `install -r` and preserve retained app data and enrollment. +The deterministic native tests supply OS capability callbacks; this issue changes +no real Keystore/biometric adapter. Release ABIs are arm64-v8a and armeabi-v7a. +Physical devices and x86_64 are excluded by the milestone's support scope. diff --git a/docs/android/parity.md b/docs/android/parity.md index 85879a9..bc081cf 100644 --- a/docs/android/parity.md +++ b/docs/android/parity.md @@ -80,7 +80,7 @@ milestone. This list gives ownership, not permission to skip acceptance criteria | [#105 Accessibility/adaptive/localization/performance](https://git.rfc1437.de/hugo/IronStorage/issues/105) | Android features/resources plus storage cancellation | Compose semantics/TalkBack/resources, measured Macrobenchmark/profiles if justified | | [#106 CameraX GPG QR import/export](https://git.rfc1437.de/hugo/IronStorage/issues/106) | storage QR/key transfer plus platform camera/Compose | CameraX frames, Rust decode/envelope/matrices, permission and bounded payload checks | | [#107 TOTP list/detail](https://git.rfc1437.de/hugo/IronStorage/issues/107) | storage mobile_totp/otp plus Compose features | Rust codes/timing, large display/copy/refresh, OTP creation/import navigation | -| [#108 Move/copy/delete](https://git.rfc1437.de/hugo/IronStorage/issues/108) | storage mobile_mutation/mutation plus Compose features | Rust plans/tokens/overwrite/reencryption, destination and confirmation UI | +| [#108 Move/copy/delete](https://git.rfc1437.de/hugo/IronStorage/issues/108) | storage mobile_mutation/mutation plus Compose features | [Mutation behavior and evidence](mutations.md); Rust plans/revisions/overwrite/reencryption, destination and confirmation UI | | [#109 Credential Manager/Autofill](https://git.rfc1437.de/hugo/IronStorage/issues/109) | storage identity/permission/credential contract plus OS services | AndroidX provider and framework AutofillService, explicit selection/lease before secrets | | [#110 TOTP catalog/discovery/search](https://git.rfc1437.de/hugo/IronStorage/issues/110) | storage mobile_totp plus Compose features | Existing hash/path/is_totp catalog, Rust progress/checkpoints/filter, no Watch | | [#111 Parity/security/reliability audit](https://git.rfc1437.de/hugo/IronStorage/issues/111) | All above plus independent acceptance review | Completed matrix evidence, Rust/Android release gates and emulator threat tests |