diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-03 08:43:49 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-03 08:43:49 +0100 |
| commit | 84d89854ce02a5833e88bfd9c150b8e90b51b4a5 (patch) | |
| tree | 496494f43f1accf18e787eac89dba3dfcb9e1aa4 /app/src/main/java | |
| parent | 5871d4e73245e179e22286580f4668da69c1484e (diff) | |
| download | HardenedPass-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')
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() } } |
