Implement confirmed Android tree mutation workflows (#108)
Android ARM verification / rust (push) Failing after 49m2s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 2m40s

This commit is contained in:
hugo committed 2026-10-12 13:06:22 +02:00
1 parent 6a864a8d7f
commit 316a875d63
22 files changed
+2170 -220

No files matched your search

@@ -331,6 +331,11 @@ class EntryViewerTest {
saved = value saved = value
dismissEdit() 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) { override fun prepareMutation(action: PasswordMutation) {
this.action = action this.action = action
state.value = state.value =
@@ -354,7 +359,7 @@ class EntryViewerTest {
override fun dismissMutation() { override fun dismissMutation() {
state.value = state.value.copy(mutation = null) 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 = this.destination =
destination destination
dismissMutation() dismissMutation()
@@ -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<MainActivity>()
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)
}
}
@@ -29,6 +29,7 @@ import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.PasswordField import de.rfc1437.ironstorage.core.PasswordField
import de.rfc1437.ironstorage.core.PasswordKind import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordRow import de.rfc1437.ironstorage.core.PasswordRow
import de.rfc1437.ironstorage.core.PasswordSection import de.rfc1437.ironstorage.core.PasswordSection
@@ -215,9 +216,14 @@ class PasswordScreenTest {
override fun edit(id: ULong) = Unit override fun edit(id: ULong) = Unit
override fun dismissEdit() = Unit override fun dismissEdit() = Unit
override fun saveEdit(value: String) = 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 prepareMutation(action: de.rfc1437.ironstorage.core.PasswordMutation) = Unit
override fun dismissMutation() = 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) { override fun openSearch(id: String, revision: String) {
error("Search is not used by this fixture") error("Search is not used by this fixture")
} }
@@ -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()) }
}
}
}
@@ -43,7 +43,20 @@ class NativeEditorRepository(
mutable.value = mutable.value.copy(locked = true, fields = emptyList()) 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() { override fun processRecreated() {
scope.launch { scope.launch {
@@ -50,12 +50,8 @@ class NativePasswordRepository(
private val mutex = Mutex() private val mutex = Mutex()
init { init {
val registration = authentication.presentation.register { val registration = authentication.presentation.register {
if (mutable.value.sections != null) { sequence.incrementAndGet()
sequence.incrementAndGet() mutable.value = mutable.value.cleared().copy(loading = false)
mutable.value = mutable.value.cleared().copy(loading = false)
} else {
mutable.value = mutable.value.cleared()
}
} }
scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() } scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() }
// Hidden codes still expire; only the composed UI requests decryption/countdown refresh. // 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) current.copy(error = session.text(R.string.core_copy_unavailable), notice = null)
} }
} }
override fun prepareMutation(action: PasswordMutation) = content { current -> override fun prepareMutation(action: PasswordMutation) {
val plan = authentication.authenticated { val row = mutable.value.selected ?: return
it.preparePresentedEntryMutation( prepareRowMutation(row.id, row.revision, action)
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 dismissMutation() { override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
sequence.incrementAndGet() run {
mutable.value = mutable.value.copy(mutation = null, loading = false) val plan = authentication.authenticated {
} it.prepareNavigationMutation(id, revision, action.native())
override fun mutate(destination: String?, overwrite: Boolean) = content { current -> }
val plan = current.mutation ?: return@content current mutable.value.copy(
authentication.authenticated { mutation = PasswordMutationPlan(
it.performEntryMutation( action, plan.source, plan.revision,
MobileMutationRequest( plan.destinations.map {
plan.action.native(), PasswordDestination(
plan.source, it.path,
plan.revision, it.title,
destination, it.detail,
true, it.requiresOverwrite,
overwrite, it.depth,
plan.discardEditor 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( private fun content(
userActivity: Boolean = true, userActivity: Boolean = true,
@@ -494,7 +533,15 @@ class NativePasswordRepository(
} }
if (sequence.get() == ticket) { if (sequence.get() == ticket) {
mutable.value = 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) .copy(loading = false)
} }
} }
@@ -57,14 +57,21 @@ data class PasswordDestination(
val path: String, val path: String,
val title: String, val title: String,
val detail: String, val detail: String,
val overwrite: Boolean val overwrite: Boolean,
val depth: UInt = 0u,
val unavailable: String? = null,
val collisionTargets: List<String> = emptyList()
) )
data class PasswordMutationPlan( data class PasswordMutationPlan(
val action: PasswordMutation, val action: PasswordMutation,
val source: String, val source: String,
val revision: String, val revision: String,
val destinations: List<PasswordDestination>, val destinations: List<PasswordDestination>,
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( data class PasswordState(
val path: String = "", val path: String = "",
@@ -105,6 +112,8 @@ interface PasswordRepository {
fun dismissEdit() fun dismissEdit()
fun saveEdit(value: String) fun saveEdit(value: String)
fun prepareMutation(action: PasswordMutation) fun prepareMutation(action: PasswordMutation)
fun prepareRowMutation(id: String, revision: String, action: PasswordMutation)
fun unlockMutation(passphrase: String)
fun dismissMutation() fun dismissMutation()
fun mutate(destination: String?, overwrite: Boolean) fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean = false)
} }
@@ -21,6 +21,17 @@ class ProtectedPresentation {
fun clearMetadata() { fun clearMetadata() {
synchronized(metadata) { metadata.values.toList() }.forEach { it() } synchronized(metadata) { metadata.values.toList() }.forEach { it() }
} }
private val editorListeners = linkedMapOf<Any, (Set<ULong>) -> Unit>()
fun registerEditorDiscard(discard: (Set<ULong>) -> Unit): AutoCloseable {
val registration = Any()
synchronized(editorListeners) { editorListeners[registration] = discard }
return AutoCloseable {
synchronized(editorListeners) { editorListeners.remove(registration) }
}
}
fun editorsDiscarded(ids: Set<ULong>) {
synchronized(editorListeners) { editorListeners.values.toList() }.forEach { it(ids) }
}
private val listeners = linkedMapOf<Any, () -> Unit>() private val listeners = linkedMapOf<Any, () -> Unit>()
private val mutableGeneration = MutableStateFlow(0L) private val mutableGeneration = MutableStateFlow(0L)
val generation: StateFlow<Long> = mutableGeneration val generation: StateFlow<Long> = mutableGeneration
@@ -281,66 +281,29 @@ fun EntryDialogs(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn) properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
) )
} }
state.mutation?.let { plan -> }
var destination by remember(plan) { mutableStateOf<String?>(null) } internal fun PasswordMutation.label(
var overwrite by remember(plan, destination) { mutableStateOf(false) } kind: de.rfc1437.ironstorage.core.PasswordKind = de.rfc1437.ironstorage.core.PasswordKind.Entry
val selected = plan.destinations.find { it.path == destination } ) = when (this) {
ProtectedWindow() PasswordMutation.Move -> if (kind ==
AlertDialog( de.rfc1437.ironstorage.core.PasswordKind.Folder
onDismissRequest = repository::dismissMutation, ) {
title = { Text(stringResource(plan.action.label())) }, R.string.mutation_move_folder
text = { } else {
ProtectedInputSurface() R.string.entry_move
Column(Modifier.verticalScroll(rememberScrollState())) { }
Text(plan.source) PasswordMutation.Copy -> if (kind ==
if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor)) de.rfc1437.ironstorage.core.PasswordKind.Folder
if (plan.action == PasswordMutation.Delete) { ) {
Text(stringResource(R.string.entry_delete_confirm)) R.string.mutation_copy_folder
} else { } else {
plan.destinations.forEach { candidate -> R.string.entry_copy_entry
TextButton(onClick = { destination = candidate.path }) { }
Text(candidate.title + "\n" + candidate.detail) PasswordMutation.Delete -> if (kind ==
} de.rfc1437.ironstorage.core.PasswordKind.Folder
} ) {
selected?.let { Text(stringResource(R.string.entry_destination, it.path)) } R.string.mutation_delete_folder
if (selected?.overwrite == true) { } else {
TextButton(onClick = { overwrite = !overwrite }) { R.string.entry_delete
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)
)
} }
} }
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
}
@@ -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<String?>(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)
}
)
}
}
@@ -1,8 +1,10 @@
package de.rfc1437.ironstorage.features package de.rfc1437.ironstorage.features
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -18,6 +20,8 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi 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.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource 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.AuthenticationRepository
import de.rfc1437.ironstorage.core.EditorRepository import de.rfc1437.ironstorage.core.EditorRepository
import de.rfc1437.ironstorage.core.PasswordKind import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.ShellIcon import de.rfc1437.ironstorage.core.ShellIcon
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
@@ -150,6 +160,7 @@ fun PasswordScreen(
) )
Text(state.path.ifEmpty { stringResource(R.string.password_root) }) Text(state.path.ifEmpty { stringResource(R.string.password_root) })
if (state.loading) CircularProgressIndicator() if (state.loading) CircularProgressIndicator()
state.notice?.let { Text(it) }
state.error?.let { state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error) Text(it, color = MaterialTheme.colorScheme.error)
} }
@@ -182,57 +193,121 @@ fun PasswordScreen(
R.string.password_entry R.string.password_entry
} }
) )
ListItem( val mutationLabels = PasswordMutation.entries.associateWith { action ->
headlineContent = { Text(row.title) }, stringResource(
supportingContent = { Text(row.detail) }, R.string.mutation_action_for,
leadingContent = { stringResource(action.label(row.kind)),
Icon( row.title
painterResource( )
when (row.icon) { }
ShellIcon.Folder -> R.drawable.ic_folder key(row.id, row.revision) {
ShellIcon.Key -> R.drawable.ic_key val swipe = rememberSwipeToDismissBoxState()
else -> error("Invalid Rust navigation icon") val swipeScope = rememberCoroutineScope()
} SwipeToDismissBox(
), state = swipe,
null gesturesEnabled = !state.loading,
) onDismiss = { direction ->
}, val action = if (direction ==
trailingContent = { SwipeToDismissBoxValue.EndToStart
TextButton( ) {
modifier = Modifier.semantics { PasswordMutation.Delete
contentDescription = } else {
rowActions PasswordMutation.Move
},
enabled = !state.loading,
onClick = {
actions = true
repository.inspect(row.id, state.revision)
} }
) { Text(actionLabel) } repository.prepareRowMutation(row.id, row.revision, action)
}, swipeScope.launch { swipe.reset() }
modifier = Modifier.combinedClickable(
enabled = !state.loading,
onClickLabel = stringResource(R.string.password_open),
onLongClickLabel = rowActions,
onClick = {
actions = false
repository.activate(row.id, state.revision)
}, },
onLongClick = { backgroundContent = {
actions = true val action = if (swipe.dismissDirection ==
repository.inspect(row.id, state.revision) 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 ListItem(
customActions = listOf( headlineContent = { Text(row.title) },
CustomAccessibilityAction(rowActions) { supportingContent = { Text(row.detail) },
actions = true leadingContent = {
repository.inspect(row.id, state.revision) Icon(
true 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() HorizontalDivider()
} }
} }
@@ -331,6 +406,7 @@ fun PasswordScreen(
) { ) {
EntryDialogs(state, repository, authentication) EntryDialogs(state, repository, authentication)
} }
state.mutation?.let { MutationDialog(it, state, repository, authentication) }
if (manual && entry != null) { if (manual && entry != null) {
PassphraseDialog( PassphraseDialog(
stringResource(R.string.auth_manual), stringResource(R.string.auth_manual),
@@ -363,7 +439,15 @@ fun PasswordScreen(
}, },
text = { text = {
ProtectedInputSurface() 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 = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
@@ -253,4 +253,17 @@
<string name="totp_unavailable">This entry no longer has a TOTP code.</string> <string name="totp_unavailable">This entry no longer has a TOTP code.</string>
<string name="totp_stale">This entry changed. Refresh the catalog and select it again.</string> <string name="totp_stale">This entry changed. Refresh the catalog and select it again.</string>
<string name="totp_back">Back to TOTP entries</string> <string name="totp_back">Back to TOTP entries</string>
<string name="mutation_action_for">%1$s %2$s</string>
<string name="mutation_move_folder">Move folder</string>
<string name="mutation_copy_folder">Copy folder</string>
<string name="mutation_delete_folder">Delete folder</string>
<string name="mutation_delete_target">Delete %1$s and its contents? This requires confirmation.</string>
<string name="mutation_open_editor">This target has an open editor.</string>
<string name="mutation_dirty_editor">This target has unsaved edits.</string>
<string name="mutation_discard_editor">Discard its open editors and unsaved edits</string>
<string name="mutation_collision">These existing entries will be replaced:</string>
<plurals name="mutation_entries">
<item quantity="one">%1$s password entry</item>
<item quantity="other">%1$s password entries</item>
</plurals>
</resources> </resources>
+110
View File
@@ -932,6 +932,69 @@ impl From<StorageMutationPlan> 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<String>,
pub collision_targets: Vec<String>,
}
#[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<MobileTreeMutationDestination>,
pub has_open_editor: bool,
pub has_dirty_editor: bool,
}
impl From<StorageMutationPlan> 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<u64>,
}
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
pub struct MobileMutationRequest { pub struct MobileMutationRequest {
pub action: MobileMutationAction, pub action: MobileMutationAction,
@@ -2103,6 +2166,53 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn prepare_navigation_mutation(
&self,
id: String,
revision: String,
action: MobileMutationAction,
) -> Result<MobileTreeMutationPlan, MobileAuthenticationFfiError> {
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<String>,
) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
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<MobileNavigationMutationOutcome, MobileAuthenticationFfiError> {
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( pub fn prepare_presented_entry_mutation(
&self, &self,
path: String, path: String,
+6
View File
@@ -253,6 +253,12 @@ pub(crate) struct SealedEntryDocument {
} }
impl 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 { pub(crate) fn probe(&self) -> &EncryptedEntry {
&self.ciphertext &self.ciphertext
} }
+381 -17
View File
@@ -334,6 +334,11 @@ struct EditorCheckpoint {
inputs: Option<SealedEditorInputs>, inputs: Option<SealedEditorInputs>,
} }
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. /// Only released after the checkpoint's encryption key has an active authentication lease.
pub struct MobileEntryEditorRecovery { pub struct MobileEntryEditorRecovery {
pub session: MobileEntryEditorSession, pub session: MobileEntryEditorSession,
@@ -1240,6 +1245,127 @@ impl MobileAuthentication {
result result
} }
pub fn prepare_navigation_mutation(
&self,
id: &str,
revision: &str,
action: MobileMutationAction,
) -> Result<MobileMutationPlan, MobileAuthenticationError> {
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<SecretBytes>,
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
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<MobileNavigationMutationOutcome, MobileAuthenticationError> {
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( pub fn prepare_presented_entry_mutation(
&self, &self,
path: &str, path: &str,
@@ -1268,6 +1394,7 @@ impl MobileAuthentication {
path: &str, path: &str,
action: MobileMutationAction, action: MobileMutationAction,
) -> Result<MobileMutationPlan, MobileAuthenticationError> { ) -> Result<MobileMutationPlan, MobileAuthenticationError> {
self.ensure_repository_idle()?;
let (has_open_editor, has_dirty_editor) = self.editor_state(path)?; let (has_open_editor, has_dirty_editor) = self.editor_state(path)?;
MobileMutationService::new(&self.repository) MobileMutationService::new(&self.repository)
.prepare(path, action, has_open_editor, has_dirty_editor) .prepare(path, action, has_open_editor, has_dirty_editor)
@@ -1302,23 +1429,53 @@ impl MobileAuthentication {
AutomaticTreeCommitter::for_source(&self.repository, &request.source, identity) AutomaticTreeCommitter::for_source(&self.repository, &request.source, identity)
.map_err(git_mutation_error)?; .map_err(git_mutation_error)?;
let has_open_editor = self.editor_state(&request.source)?.0; 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::<Vec<_>>()
} else {
Vec::new()
};
let editors = if has_open_editor && request.discard_editor { let editors = if has_open_editor && request.discard_editor {
self.take_entry_editors(&request.source)? self.take_entry_editors(&request.source)?
} else { } else {
Vec::new() Vec::new()
}; };
let mut provider = KeyOnlyProvider::new(handle, &key); let mut provider = KeyOnlyProvider::new(handle, &key);
let result = MobileMutationService::new(&self.repository).perform( let result = MobileMutationService::new(&self.repository).perform_guarded(
&request, &request,
has_open_editor && editors.is_empty(), has_open_editor && !request.discard_editor,
&self.keys, &self.keys,
&mut provider, &mut provider,
&mut committer, &mut committer,
&|| {
self.ensure_active()
.map(|_| ())
.map_err(|_| crate::mutation::MutationError::Cancelled)
},
); );
match result { 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) => { Err(error) => {
self.restore_entry_editors(editors)?; if self.ensure_active().is_ok() {
self.restore_entry_editors(editors)?;
}
Err(mutation_error(error)) Err(mutation_error(error))
} }
} }
@@ -1605,6 +1762,18 @@ impl MobileAuthentication {
document: &mut EntryDocument, document: &mut EntryDocument,
field_id: u64, field_id: u64,
value: String, value: String,
) -> Result<MobileEntryPage, MobileAuthenticationError> {
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<MobileEntryPage, MobileAuthenticationError> { ) -> Result<MobileEntryPage, MobileAuthenticationError> {
document document
.replace_field_value(EntryFieldId::from_value(field_id), value.into_bytes()) .replace_field_value(EntryFieldId::from_value(field_id), value.into_bytes())
@@ -1627,6 +1796,7 @@ impl MobileAuthentication {
&self, &self,
path: &str, path: &str,
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> { ) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
self.ensure_repository_idle()?;
let document = self.open_active_document(path)?; let document = self.open_active_document(path)?;
self.store_editor(MobileEntryDraft::new(document, false)) self.store_editor(MobileEntryDraft::new(document, false))
} }
@@ -1637,6 +1807,7 @@ impl MobileAuthentication {
name: &str, name: &str,
passphrase: Option<SecretBytes>, passphrase: Option<SecretBytes>,
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> { ) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
self.ensure_repository_idle()?;
let directory = DirectoryPath::parse(directory).map_err(entry_error)?; let directory = DirectoryPath::parse(directory).map_err(entry_error)?;
let leaf = EntryPath::parse(name).map_err(entry_error)?; let leaf = EntryPath::parse(name).map_err(entry_error)?;
if leaf if leaf
@@ -1737,6 +1908,7 @@ impl MobileAuthentication {
editor: u64, editor: u64,
fields: Vec<MobileEntryEditorInput>, fields: Vec<MobileEntryEditorInput>,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> { ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
self.ensure_repository_idle()?;
self.ensure_active()?; self.ensure_active()?;
let mut status = self.status()?; let mut status = self.status()?;
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
@@ -1751,6 +1923,7 @@ impl MobileAuthentication {
name: Option<String>, name: Option<String>,
value: String, value: String,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> { ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
self.ensure_repository_idle()?;
self.ensure_active()?; self.ensure_active()?;
let mut status = self.status()?; let mut status = self.status()?;
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
@@ -1763,6 +1936,7 @@ impl MobileAuthentication {
editor: u64, editor: u64,
image: SecretBytes, image: SecretBytes,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> { ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
self.ensure_repository_idle()?;
self.ensure_active()?; self.ensure_active()?;
let mut status = self.status()?; let mut status = self.status()?;
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
@@ -1775,6 +1949,7 @@ impl MobileAuthentication {
editor: u64, editor: u64,
field: u64, field: u64,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> { ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
self.ensure_repository_idle()?;
self.ensure_active()?; self.ensure_active()?;
let mut status = self.status()?; let mut status = self.status()?;
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
@@ -1788,6 +1963,7 @@ impl MobileAuthentication {
field: u64, field: u64,
index: usize, index: usize,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> { ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
self.ensure_repository_idle()?;
self.ensure_active()?; self.ensure_active()?;
let mut status = self.status()?; let mut status = self.status()?;
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
@@ -1801,6 +1977,7 @@ impl MobileAuthentication {
length: Option<u32>, length: Option<u32>,
no_symbols: bool, no_symbols: bool,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> { ) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
self.ensure_repository_idle()?;
self.ensure_active()?; self.ensure_active()?;
let mut status = self.status()?; let mut status = self.status()?;
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
@@ -1817,6 +1994,17 @@ impl MobileAuthentication {
) -> Result<MobileEntryPage, MobileAuthenticationError> { ) -> Result<MobileEntryPage, MobileAuthenticationError> {
self.ensure_active()?; self.ensure_active()?;
self.ensure_repository_idle()?; 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<MobileEntryEditorInput>,
) -> Result<MobileEntryPage, MobileAuthenticationError> {
let mut draft = self let mut draft = self
.status()? .status()?
.editors .editors
@@ -2169,10 +2357,11 @@ impl MobileAuthentication {
} }
fn ensure_repository_idle(&self) -> Result<(), MobileAuthenticationError> { 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( Err(entry_detail(
"Repository Action In Progress", "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 { } else {
Ok(()) Ok(())
@@ -2187,17 +2376,25 @@ impl MobileAuthentication {
fn editor_state(&self, path: &str) -> Result<(bool, bool), MobileAuthenticationError> { fn editor_state(&self, path: &str) -> Result<(bool, bool), MobileAuthenticationError> {
let status = self.status()?; let status = self.status()?;
let mut matching = status let active = status
.editors .editors
.values() .values()
.filter(|draft| draft.document().path().to_string() == path); .filter(|draft| mutation_contains(path, draft.document().path()));
let Some(first) = matching.next() else { let sealed = status
return Ok((false, false)); .editor_checkpoints
}; .values()
Ok(( .filter(|checkpoint| mutation_contains(path, checkpoint.document.path()));
true, let mut open = false;
first.document().is_modified() || matching.any(|draft| draft.document().is_modified()), 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( fn take_entry_editors(
@@ -2208,7 +2405,7 @@ impl MobileAuthentication {
let ids = status let ids = status
.editors .editors
.iter() .iter()
.filter(|(_, draft)| draft.document().path().to_string() == path) .filter(|(_, draft)| mutation_contains(path, draft.document().path()))
.map(|(id, _)| *id) .map(|(id, _)| *id)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
Ok(ids Ok(ids
@@ -2230,7 +2427,9 @@ impl MobileAuthentication {
editor: u64, editor: u64,
draft: MobileEntryDraft, draft: MobileEntryDraft,
) -> Result<(), MobileAuthenticationError> { ) -> Result<(), MobileAuthenticationError> {
self.status()?.editors.insert(editor, draft); if self.ensure_active().is_ok() {
self.status()?.editors.insert(editor, draft);
}
Ok(()) 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( fn repository_operation_conflict(
operation: MobileRepositoryOperation, operation: MobileRepositoryOperation,
busy: bool, busy: bool,
@@ -2895,6 +3104,161 @@ mod tests {
assert!(!authentication.state().unwrap().unlocked()); 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] #[test]
fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() { fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() {
use crate::authentication::SystemAuthenticationClock; use crate::authentication::SystemAuthenticationClock;
+313 -45
View File
@@ -2,15 +2,14 @@
use std::{error::Error, fmt}; use std::{error::Error, fmt};
use data_encoding::HEXLOWER;
use sha2::{Digest as _, Sha256};
use crate::{ use crate::{
command::{CopyRequest, MoveRequest, RemoveRequest}, command::{CopyRequest, MoveRequest, RemoveRequest},
crypto::{KeyStore, SecretProvider}, crypto::{KeyStore, SecretProvider},
mutation::{MutationError, TreeCommitter, TreeMutator}, mutation::{MutationError, TreeCommitter, TreeMutator},
read::hidden_path, read::hidden_path,
repository::{DirectoryPath, EntryPath, Repository, RepositoryError}, repository::{
DirectoryPath, EntryPath, Repository, RepositoryError, RepositorySnapshot, ResolvedObject,
},
write::OverwriteDecision, write::OverwriteDecision,
}; };
@@ -27,9 +26,22 @@ pub struct MobileMutationDestination {
title: String, title: String,
detail: String, detail: String,
requires_overwrite: bool, requires_overwrite: bool,
depth: u32,
unavailable: Option<String>,
collision_targets: Vec<String>,
} }
impl MobileMutationDestination { 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 { pub fn path(&self) -> &str {
&self.path &self.path
} }
@@ -56,9 +68,18 @@ pub struct MobileMutationPlan {
destinations: Vec<MobileMutationDestination>, destinations: Vec<MobileMutationDestination>,
has_open_editor: bool, has_open_editor: bool,
has_dirty_editor: bool, has_dirty_editor: bool,
directory: bool,
entries: u32,
} }
impl MobileMutationPlan { impl MobileMutationPlan {
pub fn is_directory(&self) -> bool {
self.directory
}
pub fn entries(&self) -> u32 {
self.entries
}
pub fn action(&self) -> MobileMutationAction { pub fn action(&self) -> MobileMutationAction {
self.action self.action
} }
@@ -106,9 +127,20 @@ pub struct MobileMutationOutcome {
destination: Option<String>, destination: Option<String>,
title: String, title: String,
detail: String, detail: String,
selection: Option<crate::read::TreeNodeId>,
discarded_editors: Vec<u64>,
} }
impl MobileMutationOutcome { 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<u64>) {
self.discarded_editors = editors;
}
pub fn action(&self) -> MobileMutationAction { pub fn action(&self) -> MobileMutationAction {
self.action self.action
} }
@@ -146,53 +178,69 @@ impl<'a> MobileMutationService<'a> {
has_open_editor: bool, has_open_editor: bool,
has_dirty_editor: bool, has_dirty_editor: bool,
) -> Result<MobileMutationPlan, MobileMutationError> { ) -> Result<MobileMutationPlan, MobileMutationError> {
let source = EntryPath::parse(source)?; let snapshot = self.repository.snapshot()?;
if hidden_path(source.as_path()) { 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 { return Err(MobileMutationError::InvalidSource {
path: source.to_string(), path: source.to_owned(),
}); });
} }
let ciphertext = self.repository.read_entry(&source)?; let source_title = path
let snapshot = self.repository.snapshot()?;
let source_title = source
.as_path() .as_path()
.file_name() .file_name()
.and_then(|name| name.to_str()) .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(); .to_owned();
let destinations = if action == MobileMutationAction::Delete { let destinations = if action == MobileMutationAction::Delete {
Vec::new() Vec::new()
} else { } else {
snapshot snapshot
.directories() .directories()
.filter(|directory| !hidden_path(directory.path().as_path())) .filter(|record| !hidden_path(record.path().as_path()))
.filter_map(|directory| { .filter_map(|record| {
let destination = EntryPath::parse( let target = record.path().as_path().join(path.as_path().file_name()?);
directory if target == path.as_path() || directory && target.starts_with(path.as_path()) {
.path() return None;
.as_path() }
.join(source.as_path().file_name()?), let (collision_targets, unavailable) =
) collision(&snapshot, &path, directory, &target);
.ok()?; Some(MobileMutationDestination {
(destination != source).then(|| MobileMutationDestination { path: directory_path(record.path()),
path: directory_path(directory.path()), title: directory_title(record.path()),
title: directory_title(directory.path()), detail: directory_detail(record.path()),
detail: directory_detail(directory.path()), requires_overwrite: !collision_targets.is_empty(),
requires_overwrite: snapshot collision_targets,
.entries() depth: u32::try_from(record.path().as_path().components().count())
.any(|entry| entry.path() == &destination), .unwrap_or(u32::MAX),
unavailable,
}) })
}) })
.collect() .collect()
}; };
let entries = if directory {
snapshot
.entries()
.filter(|entry| entry.path().as_path().starts_with(path.as_path()))
.count()
} else {
1
};
Ok(MobileMutationPlan { Ok(MobileMutationPlan {
action, action,
source: source.to_string(), source: if directory {
format!("{path}/")
} else {
path.to_string()
},
source_title, source_title,
revision: revision(ciphertext.as_bytes()), revision: self.repository.mutation_revision()?,
destinations, destinations,
has_open_editor, has_open_editor,
has_dirty_editor, has_dirty_editor,
directory,
entries: u32::try_from(entries).unwrap_or(u32::MAX),
}) })
} }
@@ -203,9 +251,25 @@ impl<'a> MobileMutationService<'a> {
keys: &KeyStore, keys: &KeyStore,
provider: &mut impl SecretProvider, provider: &mut impl SecretProvider,
committer: &mut impl TreeCommitter, committer: &mut impl TreeCommitter,
) -> Result<MobileMutationOutcome, MobileMutationError> {
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<MobileMutationOutcome, MobileMutationError> { ) -> Result<MobileMutationOutcome, MobileMutationError> {
let destination = self.preflight(request, has_open_editor)?; 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 { let overwrite = if request.overwrite {
OverwriteDecision::Allow OverwriteDecision::Allow
} else { } else {
@@ -215,7 +279,10 @@ impl<'a> MobileMutationService<'a> {
MobileMutationAction::Delete => mutator.remove( MobileMutationAction::Delete => mutator.remove(
&RemoveRequest { &RemoveRequest {
entry: request.source.clone(), entry: request.source.clone(),
recursive: false, recursive: matches!(
self.repository.snapshot()?.resolve(&request.source)?,
ResolvedObject::Directory(_)
),
force: false, force: false,
}, },
OverwriteDecision::Allow, OverwriteDecision::Allow,
@@ -244,6 +311,14 @@ impl<'a> MobileMutationService<'a> {
committer, 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 let destination = outcome
.selection() .selection()
.map(|selection| selection.display_path()); .map(|selection| selection.display_path());
@@ -274,6 +349,8 @@ impl<'a> MobileMutationService<'a> {
destination, destination,
title: title.to_owned(), title: title.to_owned(),
detail, detail,
selection,
discarded_editors: Vec::new(),
}) })
} }
@@ -282,14 +359,14 @@ impl<'a> MobileMutationService<'a> {
request: &MobileMutationRequest, request: &MobileMutationRequest,
has_open_editor: bool, has_open_editor: bool,
) -> Result<Option<String>, MobileMutationError> { ) -> Result<Option<String>, MobileMutationError> {
let source = EntryPath::parse(&request.source)?; let snapshot = self.repository.snapshot()?;
if hidden_path(source.as_path()) { 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 { return Err(MobileMutationError::InvalidSource {
path: source.to_string(), path: source.to_string(),
}); });
} }
let current = self.repository.read_entry(&source)?; if self.repository.mutation_revision()? != request.revision {
if revision(current.as_bytes()) != request.revision {
return Err(MobileMutationError::StaleEntry { return Err(MobileMutationError::StaleEntry {
path: source.to_string(), path: source.to_string(),
}); });
@@ -328,19 +405,24 @@ impl<'a> MobileMutationService<'a> {
.as_path() .as_path()
.join(source.as_path().file_name().expect("validated source name")), .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); return Err(MobileMutationError::SameDestination);
} }
let destination_exists = match self.repository.read_entry(&destination_entry) { let (destination_exists, unavailable) =
Ok(_) => true, collision(&snapshot, &source, directory, destination_entry.as_path());
Err(RepositoryError::NotFound { .. }) => false, if let Some(detail) = unavailable {
Err(error) => return Err(error.into()), return Err(MobileMutationError::DestinationUnavailable { detail });
}; }
if destination_exists && !request.overwrite { if !destination_exists.is_empty() && !request.overwrite {
return Err(MobileMutationError::OverwriteRequired { return Err(MobileMutationError::OverwriteRequired {
path: destination_entry.to_string(), path: destination_entry.to_string(),
}); });
} }
if !request.confirmed {
return Err(MobileMutationError::ConfirmationRequired {
path: source.to_string(),
});
}
let destination = directory_path(&destination); let destination = directory_path(&destination);
Ok(Some(if destination.is_empty() { Ok(Some(if destination.is_empty() {
destination destination
@@ -350,8 +432,126 @@ impl<'a> MobileMutationService<'a> {
} }
} }
fn revision(ciphertext: &[u8]) -> String { fn source_path(
HEXLOWER.encode(&Sha256::digest(ciphertext)) 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<String>, Option<String>) {
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 { fn directory_path(path: &DirectoryPath) -> String {
@@ -384,6 +584,7 @@ pub enum MobileMutationError {
ConfirmationRequired { path: String }, ConfirmationRequired { path: String },
DestinationRequired, DestinationRequired,
InvalidDestination { path: String }, InvalidDestination { path: String },
DestinationUnavailable { detail: String },
SameDestination, SameDestination,
OverwriteRequired { path: String }, OverwriteRequired { path: String },
} }
@@ -405,7 +606,9 @@ impl MobileMutationError {
Self::EditorOpen { .. } => "Password Editor Is Open", Self::EditorOpen { .. } => "Password Editor Is Open",
Self::ConfirmationRequired { .. } => "Delete Confirmation Required", Self::ConfirmationRequired { .. } => "Delete Confirmation Required",
Self::DestinationRequired => "Destination 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::OverwriteRequired { .. } => "Password Already Exists",
Self::InvalidSource { .. } => "Password Entry Is Unavailable", Self::InvalidSource { .. } => "Password Entry Is Unavailable",
Self::Repository(_) | Self::Mutation(_) => "Password Action Failed", Self::Repository(_) | Self::Mutation(_) => "Password Action Failed",
@@ -439,6 +642,7 @@ impl fmt::Display for MobileMutationError {
"the selected password folder is unavailable: {path}" "the selected password folder is unavailable: {path}"
) )
} }
Self::DestinationUnavailable { detail } => formatter.write_str(detail),
Self::SameDestination => { Self::SameDestination => {
formatter.write_str("the source is already in the selected folder") formatter.write_str("the source is already in the selected folder")
} }
@@ -477,6 +681,70 @@ mod tests {
}; };
use crate::repository::Repository; use crate::repository::Repository;
#[test]
fn lease_revocation_before_apply_does_not_delete_a_confirmed_entry()
-> Result<(), Box<dyn std::error::Error>> {
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<SecretBytes, SecretProviderError> {
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] #[test]
fn plans_destinations_collisions_hidden_paths_and_stale_revisions() fn plans_destinations_collisions_hidden_paths_and_stale_revisions()
-> Result<(), Box<dyn std::error::Error>> { -> Result<(), Box<dyn std::error::Error>> {
@@ -136,6 +136,37 @@ fn open_search_repository(
Ok(page) Ok(page)
} }
pub(crate) fn checked_row(
repository: &Repository,
id: &str,
revision: &str,
) -> Result<crate::read::TreeNodeId, MobilePasswordError> {
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 { fn unavailable() -> MobilePasswordError {
MobilePasswordError { MobilePasswordError {
kind: MobilePasswordErrorKind::Configuration, kind: MobilePasswordErrorKind::Configuration,
+39 -1
View File
@@ -129,11 +129,45 @@ impl MutationSelection {
pub struct TreeMutator<'a> { pub struct TreeMutator<'a> {
repository: &'a Repository, repository: &'a Repository,
keys: &'a KeyStore, keys: &'a KeyStore,
expected_revision: Option<&'a str>,
validation: Option<&'a (dyn Fn() -> Result<(), MutationError> + Sync)>,
} }
impl<'a> TreeMutator<'a> { impl<'a> TreeMutator<'a> {
pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { 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( pub fn remove(
@@ -142,6 +176,7 @@ impl<'a> TreeMutator<'a> {
decision: OverwriteDecision, decision: OverwriteDecision,
committer: &mut impl TreeCommitter, committer: &mut impl TreeCommitter,
) -> Result<MutationOutcome, MutationError> { ) -> Result<MutationOutcome, MutationError> {
self.check_revision()?;
let snapshot = self.repository.snapshot()?; let snapshot = self.repository.snapshot()?;
let resolved = snapshot.resolve(&request.entry)?; let resolved = snapshot.resolve(&request.entry)?;
if !request.force && decision == OverwriteDecision::Decline { 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) { if let Err(operation) = self.remove_sources(&entries, &policies, &directories) {
return Err(with_rollback( return Err(with_rollback(
operation, operation,
@@ -265,6 +301,7 @@ impl<'a> TreeMutator<'a> {
provider: &mut impl SecretProvider, provider: &mut impl SecretProvider,
committer: &mut impl TreeCommitter, committer: &mut impl TreeCommitter,
) -> Result<MutationOutcome, MutationError> { ) -> Result<MutationOutcome, MutationError> {
self.check_revision()?;
let snapshot = self.repository.snapshot()?; let snapshot = self.repository.snapshot()?;
let resolved = snapshot.resolve(source)?; let resolved = snapshot.resolve(source)?;
let requested_destination = DirectoryPath::parse(destination)?; let requested_destination = DirectoryPath::parse(destination)?;
@@ -460,6 +497,7 @@ impl<'a> TreeMutator<'a> {
}) })
.collect::<Result<Vec<_>, _>>()? .collect::<Result<Vec<_>, _>>()?
}; };
self.check_revision()?;
let mut created_directories = Vec::new(); let mut created_directories = Vec::new();
let applied = (|| -> Result<(), MutationError> { let applied = (|| -> Result<(), MutationError> {
for directory in &destination_directories { for directory in &destination_directories {
+48
View File
@@ -391,6 +391,54 @@ impl Repository {
Ok(EncryptedEntry(bytes)) Ok(EncryptedEntry(bytes))
} }
/// Bind confirmed mutations to ciphertext, policy, and filesystem object identities.
pub(crate) fn mutation_revision(&self) -> Result<String, RepositoryError> {
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. /// A non-secret object revision; navigation never opens encrypted contents.
pub(crate) fn navigation_revision( pub(crate) fn navigation_revision(
&self, &self,
+243
View File
@@ -16,6 +16,249 @@ use ironstorage::{
}; };
use support::compatibility::{FixtureSet, TestResult}; 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<SecretBytes, SecretProviderError> {
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<String, Vec<u8>>); struct Secrets(BTreeMap<String, Vec<u8>>);
impl Secrets { impl Secrets {
fn all(fixture: &FixtureSet) -> Self { fn all(fixture: &FixtureSet) -> Self {
+61
View File
@@ -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.
+1 -1
View File
@@ -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 | | [#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 | | [#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 | | [#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 | | [#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 | | [#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 | | [#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 |