aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-03 08:43:49 +0100
committerDanilo M. <danix@danix.xyz>2026-11-03 08:43:49 +0100
commit84d89854ce02a5833e88bfd9c150b8e90b51b4a5 (patch)
tree496494f43f1accf18e787eac89dba3dfcb9e1aa4 /app/src/main/java
parent5871d4e73245e179e22286580f4668da69c1484e (diff)
downloadHardenedPass-84d89854ce02a5833e88bfd9c150b8e90b51b4a5.tar.gz
HardenedPass-84d89854ce02a5833e88bfd9c150b8e90b51b4a5.zip
fix(app): harden the editor save path and the picker
- Run the save's write, original delete, autofill and history updates under NonCancellable so a recreated activity cannot leave a half done rename. - fsync the temporary file before the atomic move. - Move dialog: refuse targets outside the store (symlinks) and moving a folder into itself or a descendant, with a toast. - Folder picker: do not navigate into a new folder whose mkdirs failed; show a toast (new string, en and it). - Normalize entryFile so "./" and "a/../b" give the same keys as the list; covered in EditorModelTest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt8
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt5
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt8
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListDestination.kt20
4 files changed, 33 insertions, 8 deletions
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt
index cd57400d..d7437df3 100644
--- a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt
@@ -18,6 +18,7 @@ import com.github.michaelbull.result.Result
import com.github.michaelbull.result.mapError
import com.github.michaelbull.result.runCatching
import java.io.File
+import java.io.FileOutputStream
import java.io.IOException
import java.net.URLEncoder
import java.nio.file.Files
@@ -64,7 +65,7 @@ fun formError(form: EditorForm): Int? =
/** Where [form] is saved in the store at [root]. */
fun entryFile(root: File, form: EditorForm): File =
- File(File(root, form.dir), "${form.name.trim()}.gpg")
+ File(File(root, form.dir), "${form.name.trim()}.gpg").toPath().normalize().toFile()
/** True when saving to [target] would replace a file other than the [original] being edited. */
fun overwrites(target: File, original: File?): Boolean =
@@ -79,7 +80,10 @@ fun writeEntry(target: File, bytes: ByteArray) {
dir.mkdirs()
val temp = File(dir, ".${target.name}.tmp")
try {
- temp.writeBytes(bytes)
+ FileOutputStream(temp).use {
+ it.write(bytes)
+ it.fd.sync()
+ }
Files.move(
temp.toPath(),
target.toPath(),
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt
index b67b435d..0c8b4676 100644
--- a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt
@@ -42,6 +42,7 @@ import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import javax.inject.Inject
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import logcat.LogPriority.ERROR
import logcat.asLog
@@ -216,7 +217,9 @@ constructor(
encrypted.error.message.orEmpty(),
)
}
- return withContext(dispatcherProvider.io()) {
+ // NonCancellable: a recreated activity must not stop between writing the target and deleting
+ // the original, which would leave a rename that cannot be retried ("duplicate").
+ return withContext(NonCancellable + dispatcherProvider.io()) {
try {
writeEntry(target, out.toByteArray())
} catch (e: IOException) {
diff --git a/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt
index 5b610ff7..bd5b66f3 100644
--- a/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt
+++ b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt
@@ -5,6 +5,7 @@
package app.passwordstore.ui.list
+import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -79,9 +80,12 @@ fun FolderPicker(
validate = { folderNameError(it, File(dir, it)) },
onConfirm = { name ->
val folder = File(dir, name).canonicalFile
- folder.mkdirs()
naming = false
- path = folder.relativeTo(root.canonicalFile).path
+ if (folder.mkdirs() || folder.isDirectory) {
+ path = folder.relativeTo(root.canonicalFile).path
+ } else {
+ Toast.makeText(context, R.string.folder_creation_err_failed, Toast.LENGTH_LONG).show()
+ }
},
onDismiss = { naming = false },
)
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
index 147e77b7..5c248dcb 100644
--- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
+++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
@@ -5,6 +5,7 @@
package app.passwordstore.ui.list
+import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -36,6 +37,7 @@ import app.passwordstore.ui.compose.theme.SpacingMedium
import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
import app.passwordstore.ui.git.config.GitServerConfigActivity
import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.util.extensions.isInsideRepository
import app.passwordstore.util.extensions.sharedPrefs
import app.passwordstore.util.settings.AuthMode
import app.passwordstore.util.settings.PreferenceKeys
@@ -237,12 +239,24 @@ fun ListDestination(
start = path,
onPick = { target ->
// One move per pick: a double tap on Select must not move twice.
- if (dialog == ListDialog.Move) {
- val files = selected.map(::File)
+ val destination = File(root, target)
+ val files = selected.map(::File)
+ val canonical = destination.canonicalFile
+ if (
+ !destination.isInsideRepository() ||
+ files.any { it.isDirectory && canonical.startsWith(it.canonicalFile) }
+ ) {
+ Toast.makeText(
+ activity,
+ R.string.message_error_destination_outside_repo,
+ Toast.LENGTH_LONG,
+ )
+ .show()
+ } else if (dialog == ListDialog.Move) {
dialog = ListDialog.None
selected = emptyList()
activity.lifecycleScope.launch {
- activity.moveItems(files, File(root, target))
+ activity.moveItems(files, destination)
refresh()
}
}