Implement confirmed Android tree mutation workflows (#108)
This commit is contained in:
1 parent
6a864a8d7f
commit
316a875d63
22 files changed
+2103
-153
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")
|
||||||
}
|
}
|
||||||
|
|||||||
+187
@@ -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 {
|
||||||
|
|||||||
+74
-27
@@ -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,34 +390,65 @@ 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 row = mutable.value.selected ?: return
|
||||||
|
prepareRowMutation(row.id, row.revision, action)
|
||||||
|
}
|
||||||
|
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
|
||||||
|
run {
|
||||||
val plan = authentication.authenticated {
|
val plan = authentication.authenticated {
|
||||||
it.preparePresentedEntryMutation(
|
it.prepareNavigationMutation(id, revision, action.native())
|
||||||
current.entryPath(),
|
}
|
||||||
requireNotNull(current.documentRevision),
|
mutable.value.copy(
|
||||||
action.native()
|
mutation = PasswordMutationPlan(
|
||||||
|
action, plan.source, plan.revision,
|
||||||
|
plan.destinations.map {
|
||||||
|
PasswordDestination(
|
||||||
|
it.path,
|
||||||
|
it.title,
|
||||||
|
it.detail,
|
||||||
|
it.requiresOverwrite,
|
||||||
|
it.depth,
|
||||||
|
it.unavailable,
|
||||||
|
it.collisionTargets
|
||||||
|
)
|
||||||
|
},
|
||||||
|
plan.hasOpenEditor, plan.hasDirtyEditor,
|
||||||
|
if (plan.kind ==
|
||||||
|
MobilePasswordRowKind.DIRECTORY
|
||||||
|
) {
|
||||||
|
PasswordKind.Folder
|
||||||
|
} else {
|
||||||
|
PasswordKind.Entry
|
||||||
|
},
|
||||||
|
plan.sourceTitle, plan.entries
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
current.copy(
|
}
|
||||||
mutation = PasswordMutationPlan(
|
override fun unlockMutation(passphrase: String) {
|
||||||
action,
|
val current = mutable.value
|
||||||
plan.source,
|
val plan = current.mutation ?: return
|
||||||
plan.revision,
|
run(false) {
|
||||||
plan.destinations.map {
|
authentication.authenticated {
|
||||||
PasswordDestination(it.path, it.title, it.detail, it.requiresOverwrite)
|
it.unlockMutation(plan.source, plan.revision, passphrase.ifEmpty { null })
|
||||||
},
|
}
|
||||||
plan.hasOpenEditor
|
current
|
||||||
)
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
override fun dismissMutation() {
|
override fun dismissMutation() {
|
||||||
sequence.incrementAndGet()
|
sequence.incrementAndGet()
|
||||||
mutable.value = mutable.value.copy(mutation = null, loading = false)
|
mutable.value = mutable.value.copy(mutation = null, loading = false, error = null)
|
||||||
}
|
}
|
||||||
override fun mutate(destination: String?, overwrite: Boolean) = content { current ->
|
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) {
|
||||||
val plan = current.mutation ?: return@content current
|
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 {
|
authentication.authenticated {
|
||||||
it.performEntryMutation(
|
it.performNavigationMutation(
|
||||||
MobileMutationRequest(
|
MobileMutationRequest(
|
||||||
plan.action.native(),
|
plan.action.native(),
|
||||||
plan.source,
|
plan.source,
|
||||||
@@ -429,11 +456,23 @@ class NativePasswordRepository(
|
|||||||
destination,
|
destination,
|
||||||
true,
|
true,
|
||||||
overwrite,
|
overwrite,
|
||||||
plan.discardEditor
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
session.storage().passwordNavigation(current.path, null, null, false).toFacade()
|
|
||||||
}
|
}
|
||||||
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) }
|
|
||||||
var overwrite by remember(plan, destination) { mutableStateOf(false) }
|
|
||||||
val selected = plan.destinations.find { it.path == destination }
|
|
||||||
ProtectedWindow()
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = repository::dismissMutation,
|
|
||||||
title = { Text(stringResource(plan.action.label())) },
|
|
||||||
text = {
|
|
||||||
ProtectedInputSurface()
|
|
||||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
|
||||||
Text(plan.source)
|
|
||||||
if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor))
|
|
||||||
if (plan.action == PasswordMutation.Delete) {
|
|
||||||
Text(stringResource(R.string.entry_delete_confirm))
|
|
||||||
} else {
|
|
||||||
plan.destinations.forEach { candidate ->
|
|
||||||
TextButton(onClick = { destination = candidate.path }) {
|
|
||||||
Text(candidate.title + "\n" + candidate.detail)
|
|
||||||
}
|
}
|
||||||
}
|
internal fun PasswordMutation.label(
|
||||||
selected?.let { Text(stringResource(R.string.entry_destination, it.path)) }
|
kind: de.rfc1437.ironstorage.core.PasswordKind = de.rfc1437.ironstorage.core.PasswordKind.Entry
|
||||||
if (selected?.overwrite == true) {
|
) = when (this) {
|
||||||
TextButton(onClick = { overwrite = !overwrite }) {
|
PasswordMutation.Move -> if (kind ==
|
||||||
Text(
|
de.rfc1437.ironstorage.core.PasswordKind.Folder
|
||||||
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))
|
R.string.mutation_move_folder
|
||||||
|
} else {
|
||||||
|
R.string.entry_move
|
||||||
}
|
}
|
||||||
},
|
PasswordMutation.Copy -> if (kind ==
|
||||||
dismissButton = {
|
de.rfc1437.ironstorage.core.PasswordKind.Folder
|
||||||
TextButton(onClick = repository::dismissMutation) {
|
) {
|
||||||
Text(stringResource(R.string.auth_cancel))
|
R.string.mutation_copy_folder
|
||||||
|
} else {
|
||||||
|
R.string.entry_copy_entry
|
||||||
}
|
}
|
||||||
},
|
PasswordMutation.Delete -> if (kind ==
|
||||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
|
de.rfc1437.ironstorage.core.PasswordKind.Folder
|
||||||
)
|
) {
|
||||||
|
R.string.mutation_delete_folder
|
||||||
|
} else {
|
||||||
|
R.string.entry_delete
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun PasswordMutation.label() = when (this) {
|
|
||||||
PasswordMutation.Move -> R.string.entry_move
|
|
||||||
PasswordMutation.Copy -> R.string.entry_copy_entry
|
|
||||||
PasswordMutation.Delete -> R.string.entry_delete
|
|
||||||
}
|
|
||||||
@@ -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,6 +193,54 @@ fun PasswordScreen(
|
|||||||
R.string.password_entry
|
R.string.password_entry
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
val mutationLabels = PasswordMutation.entries.associateWith { action ->
|
||||||
|
stringResource(
|
||||||
|
R.string.mutation_action_for,
|
||||||
|
stringResource(action.label(row.kind)),
|
||||||
|
row.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
key(row.id, row.revision) {
|
||||||
|
val swipe = rememberSwipeToDismissBoxState()
|
||||||
|
val swipeScope = rememberCoroutineScope()
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = swipe,
|
||||||
|
gesturesEnabled = !state.loading,
|
||||||
|
onDismiss = { direction ->
|
||||||
|
val action = if (direction ==
|
||||||
|
SwipeToDismissBoxValue.EndToStart
|
||||||
|
) {
|
||||||
|
PasswordMutation.Delete
|
||||||
|
} else {
|
||||||
|
PasswordMutation.Move
|
||||||
|
}
|
||||||
|
repository.prepareRowMutation(row.id, row.revision, action)
|
||||||
|
swipeScope.launch { swipe.reset() }
|
||||||
|
},
|
||||||
|
backgroundContent = {
|
||||||
|
val action = if (swipe.dismissDirection ==
|
||||||
|
SwipeToDismissBoxValue.EndToStart
|
||||||
|
) {
|
||||||
|
PasswordMutation.Delete
|
||||||
|
} else {
|
||||||
|
PasswordMutation.Move
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
).padding(16.dp),
|
||||||
|
contentAlignment = if (action ==
|
||||||
|
PasswordMutation.Delete
|
||||||
|
) {
|
||||||
|
Alignment.CenterEnd
|
||||||
|
} else {
|
||||||
|
Alignment.CenterStart
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(mutationLabels.getValue(action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(row.title) },
|
headlineContent = { Text(row.title) },
|
||||||
supportingContent = { Text(row.detail) },
|
supportingContent = { Text(row.detail) },
|
||||||
@@ -191,7 +250,9 @@ fun PasswordScreen(
|
|||||||
when (row.icon) {
|
when (row.icon) {
|
||||||
ShellIcon.Folder -> R.drawable.ic_folder
|
ShellIcon.Folder -> R.drawable.ic_folder
|
||||||
ShellIcon.Key -> R.drawable.ic_key
|
ShellIcon.Key -> R.drawable.ic_key
|
||||||
else -> error("Invalid Rust navigation icon")
|
else -> error(
|
||||||
|
"Invalid Rust navigation icon"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
null
|
null
|
||||||
@@ -230,9 +291,23 @@ fun PasswordScreen(
|
|||||||
repository.inspect(row.id, state.revision)
|
repository.inspect(row.id, state.revision)
|
||||||
true
|
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()
|
||||||
|
Column {
|
||||||
Text(selected.detail + "\n" + selected.path)
|
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>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
if self.ensure_active().is_ok() {
|
||||||
self.restore_entry_editors(editors)?;
|
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> {
|
||||||
|
if self.ensure_active().is_ok() {
|
||||||
self.status()?.editors.insert(editor, draft);
|
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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
|
|||||||
Reference in new issue
Block a user