diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-02 21:31:19 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-02 21:31:19 +0100 |
| commit | 0ba7321ce6b94a0046119e62a7ced8badb5baa5f (patch) | |
| tree | 8331e14668f8439270254294b52aa038810508a8 /app | |
| parent | a2b27dade21f85b68654245efefbd339c3e59584 (diff) | |
| download | HardenedPass-0ba7321ce6b94a0046119e62a7ced8badb5baa5f.tar.gz HardenedPass-0ba7321ce6b94a0046119e62a7ced8badb5baa5f.zip | |
fix(app): harden store delete, move and rename
- renameFolder stops when the move fails instead of recording and committing
- delete runs its file work on the IO dispatcher
- folder delete no longer follows directory symlinks (new test)
- overwrite dialog is dismissed when its coroutine is cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app')
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/list/StoreActions.kt | 40 | ||||
| -rw-r--r-- | app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt | 29 |
2 files changed, 57 insertions, 12 deletions
diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt index 6b31f652..06646461 100644 --- a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt +++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt @@ -22,6 +22,7 @@ import app.passwordstore.util.settings.PasswordSortOrder import app.passwordstore.util.settings.PreferenceKeys import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.io.File +import java.nio.file.Files import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -54,12 +55,24 @@ fun Context.recordUse(file: File) { } } +/** Deletes this file or folder, never following symlinks; true when everything is gone. */ +internal fun File.deleteWithoutFollowingLinks(): Boolean { + if (isDirectory && !Files.isSymbolicLink(toPath())) { + // Evaluate every child, no short-circuit, so one failure leaves the rest deleted. + val children = listFiles().orEmpty().map { it.deleteWithoutFollowingLinks() } + if (!children.all { it }) return false + } + return delete() +} + /** Deletes [files] (entries or whole folders), drops their autofill matches and commits. */ suspend fun BaseGitActivity.deleteItems(files: List<File>) { val root = PasswordRepository.getRepositoryDirectory() - val deleted = files.flatMap { if (it.isDirectory) it.listFilesRecursively() else listOf(it) } - files.forEach { it.deleteRecursively() } - AutofillMatcher.updateMatches(applicationContext, delete = deleted) + withContext(dispatcherProvider.io()) { + val deleted = files.flatMap { if (it.isDirectory) it.listFilesRecursively() else listOf(it) } + files.forEach { it.deleteWithoutFollowingLinks() } + AutofillMatcher.updateMatches(applicationContext, delete = deleted) + } commitChange( getString( R.string.git_commit_remove_text, @@ -109,7 +122,7 @@ suspend fun BaseGitActivity.moveItems(files: List<File>, target: File) { /** Renames the folder [from] to [to], keeps its recently-used time, then commits. */ suspend fun BaseGitActivity.renameFolder(from: File, to: File) { - moveFile(from, to) + if (!moveFile(from, to)) return val history = getSharedPreferences(HISTORY, Context.MODE_PRIVATE) val time = history.getString(from.absolutePath.base64()) if (time != null) @@ -120,7 +133,7 @@ suspend fun BaseGitActivity.renameFolder(from: File, to: File) { commitChange(getString(R.string.git_commit_move_text, from.name, to.name)) } -private suspend fun BaseGitActivity.moveFile(source: File, destination: File) { +private suspend fun BaseGitActivity.moveFile(source: File, destination: File): Boolean { val moved = withContext(dispatcherProvider.io()) { val map = @@ -143,17 +156,20 @@ private suspend fun BaseGitActivity.moveFile(source: File, destination: File) { .setPositiveButton(android.R.string.ok, null) .show() } + return moved } /** Shows [message] under [title] and suspends until answered; true when confirmed. */ // ponytail: View dialog, see moveFile. private suspend fun Context.confirm(title: String, message: String): Boolean = suspendCancellableCoroutine { cont -> - MaterialAlertDialogBuilder(this) - .setTitle(title) - .setMessage(message) - .setPositiveButton(R.string.dialog_ok) { _, _ -> cont.resume(true) } - .setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(false) } - .setOnCancelListener { cont.resume(false) } - .show() + val dialog = + MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.dialog_ok) { _, _ -> cont.resume(true) } + .setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(false) } + .setOnCancelListener { cont.resume(false) } + .show() + cont.invokeOnCancellation { dialog.dismiss() } } diff --git a/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt b/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt new file mode 100644 index 00000000..8a1ae1d9 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.list + +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StoreActionsTest { + + @Test + fun deleteDoesNotFollowSymlinks() { + val root = Files.createTempDirectory("aps").toFile() + val outsideDir = File(root, "outside").apply { mkdirs() } + val outsideFile = File(outsideDir, "secret.gpg").apply { writeText("x") } + val folder = File(root, "store/folder").apply { mkdirs() } + File(folder, "login.gpg").writeText("x") + Files.createSymbolicLink(File(folder, "link").toPath(), outsideDir.toPath()) + + assertTrue(folder.deleteWithoutFollowingLinks()) + + assertFalse(folder.exists()) + assertTrue(outsideFile.exists()) + } +} |
