From 515ce195f1d158d77e8e4c307f7a7d3e85a9832d Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 08:07:33 +0200 Subject: [PATCH 01/31] feat: support Pass-Secrets mapped names --- app/src/main/AndroidManifest.xml | 8 +- .../java/app/passwordstore/Application.kt | 2 + .../data/password/PasswordItem.kt | 51 +++- .../passsecrets/PassSecretsMapStore.kt | 166 +++++++++++ .../ui/crypto/PassSecretsMapUnlockActivity.kt | 101 +++++++ .../ui/passwords/PasswordFragment.kt | 16 ++ .../SearchableRepositoryViewModel.kt | 42 ++- .../passsecrets/PassSecretsMapStoreTest.kt | 264 ++++++++++++++++++ 8 files changed, 620 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt create mode 100644 app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3181217805..9cdd5284ea 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -54,6 +54,12 @@ android:name=".ui.crypto.DecryptActivity" android:exported="true" /> + + - + \ No newline at end of file diff --git a/app/src/main/java/app/passwordstore/Application.kt b/app/src/main/java/app/passwordstore/Application.kt index c253f906ed..df336a952c 100644 --- a/app/src/main/java/app/passwordstore/Application.kt +++ b/app/src/main/java/app/passwordstore/Application.kt @@ -21,6 +21,7 @@ import androidx.appcompat.app.AppCompatDelegate import app.passwordstore.injection.context.FilesDirPath import app.passwordstore.injection.prefs.PGPPassphrases import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.passsecrets.PassSecretsMapStore import app.passwordstore.ui.crypto.BasePGPActivity.Companion.cachedPassphrases import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.crypto.AESEncryption @@ -154,6 +155,7 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere if (intent.action == Intent.ACTION_SCREEN_OFF) { cachedPassphrases.values.forEach { it.wipe() } cachedPassphrases.clear() + PassSecretsMapStore.clear() } } } diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt index e0033b5273..35c4895460 100644 --- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -17,22 +17,48 @@ data class PasswordItem( val type: Char, val file: File, val rootDir: File, + val mappedName: String? = null, ) : Comparable { + private val physicalName = name.replace("\\.gpg$".toRegex(), "") + val fullPathToParent = PasswordRepository.getParentPath(file.absolutePath, rootDir.absolutePath) + val physicalLongName = + PasswordRepository.getLongName(fullPathToParent, rootDir.absolutePath, physicalName) + val longName = PasswordRepository.getLongName(fullPathToParent, rootDir.absolutePath, toString()) + val searchableName = + if (mappedName != null) "$longName $physicalLongName" else physicalLongName + + fun matchesSearch(filter: String): Boolean = searchableName.contains(filter, ignoreCase = true) + + fun matchesStrictDomain(regex: Regex): Boolean { + val physicalPath = + try { + file.relativeTo(rootDir).path + } catch (_: IllegalArgumentException) { + return false + } + if (regex.containsMatchIn(physicalPath)) return true + + return mappedName + ?.split(Regex("[\\s/]+")) + ?.filter { it.isNotBlank() } + ?.any { token -> regex.containsMatchIn("$token.gpg") } == true + } + override fun equals(other: Any?): Boolean { return (other is PasswordItem) && (other.file == file) } override fun compareTo(other: PasswordItem): Int { - return (type + name).compareTo(other.type + other.name, ignoreCase = true) + return (type + toString()).compareTo(other.type + other.toString(), ignoreCase = true) } override fun toString(): String { - return name.replace("\\.gpg$".toRegex(), "") + return mappedName ?: physicalName } override fun hashCode(): Int { @@ -42,7 +68,7 @@ data class PasswordItem( /** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */ fun createAuthEnabledIntent(context: Context): Intent { val intent = Intent(context, LaunchActivity::class.java) - intent.putExtra("NAME", toString()) // this.toString + intent.putExtra("NAME", toString()) intent.putExtra(BasePGPActivity.EXTRA_FILE_PATH, file.absolutePath) intent.putExtra( BasePGPActivity.EXTRA_REPO_PATH, @@ -70,13 +96,24 @@ data class PasswordItem( } @JvmStatic - fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem { - return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir) + fun newPassword( + name: String, + file: File, + parent: PasswordItem, + rootDir: File, + mappedName: String? = null, + ): PasswordItem { + return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir, mappedName) } @JvmStatic - fun newPassword(name: String, file: File, rootDir: File): PasswordItem { - return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir) + fun newPassword( + name: String, + file: File, + rootDir: File, + mappedName: String? = null, + ): PasswordItem { + return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir, mappedName) } @JvmStatic diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt new file mode 100644 index 0000000000..2ef32b71d9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt @@ -0,0 +1,166 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.passsecrets + +import java.io.File + +/** In-memory resolver for Pass-Secrets' per-identity `.secrets.gpg` mapping files. */ +object PassSecretsMapStore { + + const val MAP_FILE_NAME = ".secrets.gpg" + const val MASK_FILE_NAME = ".mask.gpg" + private const val PENDING_VALUE = "(pendente)" + + private data class FileVersion(val lastModified: Long, val length: Long) + + private data class LoadedMap(val version: FileVersion, val values: Map) + + private enum class GateState { + Pending, + Skipped, + } + + private data class Gate(val version: FileVersion, val state: GateState) + + private val lock = Any() + private val loadedMaps = mutableMapOf() + private val gates = mutableMapOf() + + /** Parse the plaintext map format ` = `. */ + fun parse(plaintext: String): Map { + val values = linkedMapOf() + plaintext.lineSequence().forEach { rawLine -> + val separator = rawLine.indexOf('=') + if (separator <= 0) return@forEach + + val key = rawLine.substring(0, separator).trim() + val value = rawLine.substring(separator + 1).trim() + if (!isValidMapKey(key) || value.isBlank() || value == PENDING_VALUE) return@forEach + + // Deliberately let the last mapping win for malformed files with duplicate keys. + values[key] = value + } + return values + } + + /** Resolve the mapped display name for a physical password file, if its map is unlocked. */ + fun mappedName(file: File, repositoryRoot: File): String? { + if (!file.isFile || file.extension != "gpg" || isMetadataFile(file)) return null + val identity = findNearestIdentity(file.parentFile ?: return null, repositoryRoot) ?: return null + val mapFile = File(identity, MAP_FILE_NAME) + if (!mapFile.isFile) return null + + val relativePath = + try { + file.relativeTo(identity).invariantSeparatorsPath.removeSuffix(".gpg") + } catch (_: IllegalArgumentException) { + return null + } + + synchronized(lock) { + val identityKey = identity.absolutePath + val loaded = loadedMaps[identityKey] ?: return null + if (loaded.version != mapFile.version()) { + loadedMaps.remove(identityKey) + gates.remove(identityKey) + return null + } + return loaded.values[relativePath] + } + } + + /** + * Claim the map belonging to [directory]'s nearest identity for lazy unlock. + * + * A nested `.gpg-id` is always a hard boundary: if that identity has no map, the parent map is + * never inherited. A map is only returned once until it is either loaded, skipped, changed, or + * the in-memory state is cleared. + */ + fun claimForDirectory(directory: File, repositoryRoot: File): File? { + val identity = findNearestIdentity(directory, repositoryRoot) ?: return null + val mapFile = File(identity, MAP_FILE_NAME) + if (!mapFile.isFile) return null + + synchronized(lock) { + val identityKey = identity.absolutePath + val version = mapFile.version() + val loaded = loadedMaps[identityKey] + if (loaded != null) { + if (loaded.version == version) return null + loadedMaps.remove(identityKey) + } + + val gate = gates[identityKey] + if (gate != null) { + if (gate.version == version) return null + gates.remove(identityKey) + } + + gates[identityKey] = Gate(version, GateState.Pending) + return mapFile + } + } + + /** Store a successfully decrypted map. Plaintext mappings never leave process memory. */ + fun put(mapFile: File, values: Map) { + val identity = mapFile.parentFile ?: return + synchronized(lock) { + val identityKey = identity.absolutePath + loadedMaps[identityKey] = LoadedMap(mapFile.version(), values.toMap()) + gates.remove(identityKey) + } + } + + /** Suppress repeated automatic prompts after cancellation or a non-recoverable unlock failure. */ + fun skip(mapFile: File) { + val identity = mapFile.parentFile ?: return + synchronized(lock) { + val identityKey = identity.absolutePath + if (!loadedMaps.containsKey(identityKey)) { + gates[identityKey] = Gate(mapFile.version(), GateState.Skipped) + } + } + } + + /** Forget all decrypted labels and prompt gates, e.g. when the screen locks. */ + fun clear() { + synchronized(lock) { + loadedMaps.clear() + gates.clear() + } + } + + fun isMetadataFile(file: File): Boolean { + return file.isFile && (file.name == MAP_FILE_NAME || file.name == MASK_FILE_NAME) + } + + private fun findNearestIdentity(start: File, repositoryRoot: File): File? { + val root = repositoryRoot.absoluteFile + var current = start.absoluteFile + if (!isInsideRoot(current, root)) return null + + while (true) { + if (File(current, ".gpg-id").isFile) return current + if (current == root) return null + current = current.parentFile ?: return null + if (!isInsideRoot(current, root)) return null + } + } + + private fun isInsideRoot(file: File, root: File): Boolean { + val rootPath = root.absolutePath.trimEnd(File.separatorChar) + val path = file.absolutePath + return path == rootPath || path.startsWith("$rootPath${File.separator}") + } + + private fun isValidMapKey(key: String): Boolean { + if (key.isBlank() || key.startsWith('/') || key.startsWith('\\') || '\\' in key) return false + return key.split('/').none { component -> + component.isBlank() || component == "." || component == ".." + } + } + + private fun File.version() = FileVersion(lastModified = lastModified(), length = length()) +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt new file mode 100644 index 0000000000..857972a1e8 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt @@ -0,0 +1,101 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.crypto + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.content.edit +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.crypto.errors.IncorrectPassphraseException +import app.passwordstore.passsecrets.PassSecretsMapStore +import app.passwordstore.util.extensions.wipe +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.getError +import com.github.michaelbull.result.getOrThrow +import dagger.hilt.android.AndroidEntryPoint +import java.io.ByteArrayOutputStream +import java.io.File +import kotlinx.coroutines.withContext +import logcat.asLog +import logcat.logcat + +/** Authenticates and decrypts a Pass-Secrets map into the process-local mapping cache. */ +@AndroidEntryPoint +class PassSecretsMapUnlockActivity : BasePGPActivity() { + + private var mapLoaded = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.hide() + requireKeysExist { + requireDecryptionKeysExist(relativeParentPath) { ids -> getPersistentAndDecrypt(ids) } + } + } + + override suspend fun decryptWithPassphrase( + passphrases: Map, + identifiers: List, + onSuccess: suspend (String) -> Unit, + ) { + val message = withContext(dispatcherProvider.io()) { File(fullPath).readBytes().inputStream() } + val outputStream = ByteArrayOutputStream() + val results = repository.decrypt(passphrases, identifiers, message, outputStream) + val lastResult = results.lastOrNull() + + if (lastResult != null && lastResult.second.isOk) { + val decryptedOutput = lastResult.second.getOrThrow() + val decryptedBytes = decryptedOutput.toByteArray() + decryptedOutput.wipe() + val mappings = PassSecretsMapStore.parse(decryptedBytes.decodeToString()) + decryptedBytes.wipe() + + PassSecretsMapStore.put(File(fullPath), mappings) + mapLoaded = true + onSuccess(lastResult.first) + setResult(RESULT_OK) + finish() + } else { + passphrases.values.forEach { it?.wipe() } + val incorrectPassphrase = + results + .filter { result -> + if (result.second.getError() is IncorrectPassphraseException) { + persistentPassphrases.edit { remove(result.first) } + cachedPassphrases[result.first]?.wipe() + cachedPassphrases.remove(result.first) + true + } else { + result.second.getError()?.let { error -> logcat { error.asLog() } } + false + } + } + .any() + + if (incorrectPassphrase) decrypt(identifiers, isError = true) else finish() + } + + if (!settings.getBoolean(PreferenceKeys.CACHE_PASSPHRASE, false)) { + cachedPassphrases.values.forEach { it.wipe() } + cachedPassphrases.clear() + } + } + + override fun onDestroy() { + if (!mapLoaded) PassSecretsMapStore.skip(File(fullPath)) + super.onDestroy() + } + + companion object { + + fun newIntent(context: Context, mapFile: File, repositoryRoot: File): Intent { + return Intent(context, PassSecretsMapUnlockActivity::class.java).apply { + putExtra(EXTRA_FILE_PATH, mapFile.absolutePath) + putExtra(EXTRA_REPO_PATH, repositoryRoot.absolutePath) + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt index 8f5fb6dbda..64201c0be0 100644 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt @@ -4,6 +4,7 @@ */ package app.passwordstore.ui.passwords +import android.app.Activity import android.content.Context import android.content.SharedPreferences import android.os.Bundle @@ -30,7 +31,9 @@ import app.passwordstore.data.password.PasswordItem import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.databinding.PasswordRecyclerViewBinding import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.passsecrets.PassSecretsMapStore import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter +import app.passwordstore.ui.crypto.PassSecretsMapUnlockActivity import app.passwordstore.ui.dialogs.BasicBottomSheet import app.passwordstore.ui.dialogs.ItemCreationBottomSheet import app.passwordstore.ui.git.base.BaseGitActivity @@ -81,6 +84,10 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) { binding.swipeRefresher.isRefreshing = false requireStore().refreshPasswordList() } + private val passSecretsUnlockResult = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) requireStore().refreshPasswordList() + } val currentDir: File get() = model.currentDir.value @@ -203,6 +210,7 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) { model.navigateTo(File(path), pushPreviousLocation = false) lifecycleScope.launch { model.searchResult.flowWithLifecycle(lifecycle).collect { result -> + maybeUnlockPassSecretsMap() // Only run animations when the new list is filtered, i.e., the user submitted a search, // and not on folder navigation since the latter leads to too many removal animations. (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered @@ -235,6 +243,14 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) { updateFabSync() } + private fun maybeUnlockPassSecretsMap() { + val repositoryRoot = PasswordRepository.getRepositoryDirectory() + val mapFile = PassSecretsMapStore.claimForDirectory(currentDir, repositoryRoot) ?: return + passSecretsUnlockResult.launch( + PassSecretsMapUnlockActivity.newIntent(requireContext(), mapFile, repositoryRoot) + ) + } + private var fabVisible = true private val actionModeCallback = diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index 0aba857572..0f9fc9bb77 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -24,6 +24,7 @@ import androidx.recyclerview.widget.RecyclerView import app.passwordstore.data.password.PasswordItem import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.passsecrets.PassSecretsMapStore import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.checkMainThread import app.passwordstore.util.coroutines.DispatcherProvider @@ -55,17 +56,18 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.yield import me.zhanghai.android.fastscroll.PopupTextProvider -private fun File.toPasswordItem() = - if (isFile) { - if (name == ".gpg-id") - PasswordItem.newGpgIdItem(name, this, PasswordRepository.getRepositoryDirectory()) +private fun File.toPasswordItem(): PasswordItem { + val root = PasswordRepository.getRepositoryDirectory() + return if (isFile) { + if (name == ".gpg-id") PasswordItem.newGpgIdItem(name, this, root) else if (extension == "gpg") - PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory()) - else PasswordItem.newOtherItem(name, this, PasswordRepository.getRepositoryDirectory()) - } else PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory()) + PasswordItem.newPassword(name, this, root, PassSecretsMapStore.mappedName(this, root)) + else PasswordItem.newOtherItem(name, this, root) + } else PasswordItem.newCategory(name, this, root) +} private fun PasswordItem.fuzzyMatch(filter: String): Int { - val (_, score) = Fuzzy.fuzzyMatch(filter, longName) + val (_, score) = Fuzzy.fuzzyMatch(filter, searchableName) return score } @@ -243,13 +245,8 @@ constructor( } FilterMode.Exact -> { prefilteredResultFlow - .filter { absoluteFile -> - absoluteFile - .relativeTo(root) - .path - .contains(searchAction.filter, ignoreCase = true) - } .map { it.toPasswordItem() } + .filter { item -> item.matchesSearch(searchAction.filter) } .flowOn(dispatcherProvider.io()) .toList() .sortedWith(itemComparator) @@ -261,10 +258,8 @@ constructor( val regex = generateStrictDomainRegex(searchAction.filter) if (regex != null) { prefilteredResultFlow - .filter { absoluteFile -> - regex.containsMatchIn(absoluteFile.relativeTo(root).path) - } .map { it.toPasswordItem() } + .filter { item -> item.matchesStrictDomain(regex) } .flowOn(dispatcherProvider.io()) .toList() .sortedWith(itemComparator) @@ -277,10 +272,11 @@ constructor( } .flowOn(dispatcherProvider.io()) - private fun shouldTake(file: File) = - with(file) { + private fun shouldTake(file: File): Boolean { + if (PassSecretsMapStore.isMetadataFile(file)) return false + return with(file) { if (showHiddenContents) { - return !file.name.startsWith(".git") + return@with !file.name.startsWith(".git") } if (isDirectory) { !isHidden @@ -288,6 +284,7 @@ constructor( !isHidden && file.extension == "gpg" } } + } private fun listFiles(dir: File): Flow { return dir.listFiles(::shouldTake)?.asFlow() ?: emptyFlow() @@ -413,7 +410,8 @@ private object PasswordItemDiffCallback : DiffUtil.ItemCallback() override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem.file.absolutePath == newItem.file.absolutePath - override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem + override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = + oldItem.file == newItem.file && oldItem.mappedName == newItem.mappedName } open class SearchableRepositoryAdapter( @@ -515,6 +513,6 @@ open class SearchableRepositoryAdapter( } final override fun getPopupText(view: View, position: Int): String { - return getItem(position).name[0].toString().uppercase(Locale.getDefault()) + return getItem(position).toString().firstOrNull()?.toString()?.uppercase(Locale.getDefault()) ?: "" } } diff --git a/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt new file mode 100644 index 0000000000..10708c420c --- /dev/null +++ b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt @@ -0,0 +1,264 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.passsecrets + +import app.passwordstore.data.password.PasswordItem +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class PassSecretsMapStoreTest { + + @get:Rule val tempFolder = TemporaryFolder() + + private lateinit var root: File + + @BeforeTest + fun setup() { + PassSecretsMapStore.clear() + root = tempFolder.newFolder("store") + } + + @AfterTest + fun tearDown() { + PassSecretsMapStore.clear() + tempFolder.root.deleteRecursively() + } + + @Test + fun `parser handles whitespace and equals signs in descriptions`() { + val parsed = + PassSecretsMapStore.parse( + """ + Abcde/FgXyz = GitHub personal + Qwert/Asdfg=service = production + """.trimIndent() + ) + + assertEquals("GitHub personal", parsed["Abcde/FgXyz"]) + assertEquals("service = production", parsed["Qwert/Asdfg"]) + } + + @Test + fun `parser ignores malformed unsafe empty and pending entries`() { + val parsed = + PassSecretsMapStore.parse( + """ + malformed line + /absolute/path = nope + foo/../bar = nope + foo\\bar = nope + valid/path = + pending/path = (pendente) + good/path = Good value + """.trimIndent() + ) + + assertEquals(mapOf("good/path" to "Good value"), parsed) + } + + @Test + fun `parser uses last value for duplicate keys`() { + val parsed = PassSecretsMapStore.parse("Foo/Bar = first\nFoo/Bar = second") + assertEquals("second", parsed["Foo/Bar"]) + } + + @Test + fun `mapped name resolves relative to nearest identity`() { + val identity = identity(root, "Work") + val password = password(identity, "Abcde/FgXyz") + val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME) + PassSecretsMapStore.put(mapFile, mapOf("Abcde/FgXyz" to "GitHub work")) + + assertEquals("GitHub work", PassSecretsMapStore.mappedName(password, root)) + } + + @Test + fun `nested identity with own map overrides parent identity`() { + val parent = identity(root, "Parent") + val child = identity(parent, "Nested") + val password = password(child, "Abcde") + PassSecretsMapStore.put( + File(parent, PassSecretsMapStore.MAP_FILE_NAME), + mapOf("Nested/Abcde" to "Wrong parent mapping"), + ) + PassSecretsMapStore.put( + File(child, PassSecretsMapStore.MAP_FILE_NAME), + mapOf("Abcde" to "Correct child mapping"), + ) + + assertEquals("Correct child mapping", PassSecretsMapStore.mappedName(password, root)) + } + + @Test + fun `nested identity without map never inherits parent mapping`() { + val parent = identity(root, "Parent") + val child = identity(parent, "Nested", withMap = false) + val password = password(child, "Abcde") + PassSecretsMapStore.put( + File(parent, PassSecretsMapStore.MAP_FILE_NAME), + mapOf("Nested/Abcde" to "Must not leak across boundary"), + ) + + assertNull(PassSecretsMapStore.mappedName(password, root)) + assertNull(PassSecretsMapStore.claimForDirectory(child, root)) + } + + @Test + fun `claim is automatic once per map until resolved`() { + val identity = identity(root, "Work") + val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME) + + assertEquals(mapFile.absolutePath, PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath) + assertNull(PassSecretsMapStore.claimForDirectory(identity, root)) + + PassSecretsMapStore.put(mapFile, emptyMap()) + assertNull(PassSecretsMapStore.claimForDirectory(identity, root)) + } + + @Test + fun `skipped map does not immediately prompt again`() { + val identity = identity(root, "Work") + val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME) + + assertEquals(mapFile.absolutePath, PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath) + PassSecretsMapStore.skip(mapFile) + assertNull(PassSecretsMapStore.claimForDirectory(identity, root)) + } + + @Test + fun `changing map invalidates loaded labels and permits a new unlock`() { + val identity = identity(root, "Work") + val password = password(identity, "Abcde") + val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME) + PassSecretsMapStore.put(mapFile, mapOf("Abcde" to "Old name")) + assertEquals("Old name", PassSecretsMapStore.mappedName(password, root)) + + mapFile.appendText("changed") + + assertNull(PassSecretsMapStore.mappedName(password, root)) + assertEquals(mapFile.absolutePath, PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath) + } + + @Test + fun `changing skipped map permits retry`() { + val identity = identity(root, "Work") + val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME) + PassSecretsMapStore.claimForDirectory(identity, root) + PassSecretsMapStore.skip(mapFile) + assertNull(PassSecretsMapStore.claimForDirectory(identity, root)) + + mapFile.appendText("changed") + + assertEquals(mapFile.absolutePath, PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath) + } + + @Test + fun `clear removes both decrypted labels and prompt suppression`() { + val identity = identity(root, "Work") + val password = password(identity, "Abcde") + val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME) + PassSecretsMapStore.put(mapFile, mapOf("Abcde" to "GitHub")) + assertEquals("GitHub", PassSecretsMapStore.mappedName(password, root)) + + PassSecretsMapStore.clear() + + assertNull(PassSecretsMapStore.mappedName(password, root)) + assertEquals(mapFile.absolutePath, PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath) + } + + @Test + fun `metadata files are always recognized separately from passwords`() { + val identity = identity(root, "Work") + assertTrue(PassSecretsMapStore.isMetadataFile(File(identity, ".secrets.gpg"))) + File(identity, ".mask.gpg").writeText("ciphertext") + assertTrue(PassSecretsMapStore.isMetadataFile(File(identity, ".mask.gpg"))) + assertFalse(PassSecretsMapStore.isMetadataFile(password(identity, "Abcde"))) + } + + @Test + fun `resolver ignores files outside repository root`() { + val outside = tempFolder.newFolder("outside") + val identity = identity(outside, "Work") + val password = password(identity, "Abcde") + PassSecretsMapStore.put( + File(identity, PassSecretsMapStore.MAP_FILE_NAME), + mapOf("Abcde" to "Outside"), + ) + + assertNull(PassSecretsMapStore.mappedName(password, root)) + assertNull(PassSecretsMapStore.claimForDirectory(identity, root)) + } + + @Test + fun `password item displays mapped name but remains searchable by physical codename`() { + val identity = identity(root, "Work") + val password = password(identity, "Abcde/FgXyz") + val item = + PasswordItem.newPassword( + password.name, + password, + root, + mappedName = "github.com / personal", + ) + + assertEquals("github.com / personal", item.toString()) + assertTrue(item.matchesSearch("github.com")) + assertTrue(item.matchesSearch("FgXyz")) + assertTrue(item.matchesSearch("abcde")) + assertFalse(item.matchesSearch("gitlab")) + } + + @Test + fun `mapped duplicate labels do not collapse physical identity`() { + val identity = identity(root, "Work") + val first = password(identity, "Abcde") + val second = password(identity, "FgXyz") + val firstItem = PasswordItem.newPassword(first.name, first, root, mappedName = "GitHub") + val secondItem = PasswordItem.newPassword(second.name, second, root, mappedName = "GitHub") + + assertEquals(firstItem.toString(), secondItem.toString()) + assertNotEquals(firstItem.file.absolutePath, secondItem.file.absolutePath) + assertFalse(firstItem == secondItem) + } + + @Test + fun `strict domain matching checks mapped description tokens and physical path`() { + val identity = identity(root, "Work") + val mapped = password(identity, "Abcde") + val physical = password(identity, "gitlab.com") + val regex = Regex("(?:^|/)(?:(?:[^/@]+\\.)?github\\.com)(?:\\.gpg|/)") + val gitlabRegex = Regex("(?:^|/)(?:(?:[^/@]+\\.)?gitlab\\.com)(?:\\.gpg|/)") + val mappedItem = PasswordItem.newPassword(mapped.name, mapped, root, "github.com / personal") + val physicalItem = PasswordItem.newPassword(physical.name, physical, root) + + assertTrue(mappedItem.matchesStrictDomain(regex)) + assertTrue(physicalItem.matchesStrictDomain(gitlabRegex)) + assertFalse(mappedItem.matchesStrictDomain(gitlabRegex)) + } + + private fun identity(parent: File, name: String, withMap: Boolean = true): File { + val dir = File(parent, name).apply { mkdirs() } + File(dir, ".gpg-id").writeText("0123456789ABCDEF\n") + if (withMap) File(dir, PassSecretsMapStore.MAP_FILE_NAME).writeText("ciphertext") + return dir + } + + private fun password(identity: File, relativePath: String): File { + val file = File(identity, "$relativePath.gpg") + file.parentFile?.mkdirs() + file.writeText("encrypted") + return file + } +} From 73f71395ea273f3d8b35825d906f2272bd4e74db Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 08:11:01 +0200 Subject: [PATCH 02/31] style: fix manifest formatting --- app/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9cdd5284ea..2221e32fc6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -213,4 +213,4 @@ - \ No newline at end of file + From 574a7ee7f6b10e49297b6a8cbb12947efe2199f0 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 08:11:27 +0200 Subject: [PATCH 03/31] test: clean up Pass-Secrets coverage --- .../passsecrets/PassSecretsMapStoreTest.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt index 10708c420c..b08be0fb18 100644 --- a/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt +++ b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt @@ -13,7 +13,6 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNull -import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.Rule import org.junit.rules.TemporaryFolder @@ -120,7 +119,10 @@ class PassSecretsMapStoreTest { val identity = identity(root, "Work") val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME) - assertEquals(mapFile.absolutePath, PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath) + assertEquals( + mapFile.absolutePath, + PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath, + ) assertNull(PassSecretsMapStore.claimForDirectory(identity, root)) PassSecretsMapStore.put(mapFile, emptyMap()) @@ -132,7 +134,10 @@ class PassSecretsMapStoreTest { val identity = identity(root, "Work") val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME) - assertEquals(mapFile.absolutePath, PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath) + assertEquals( + mapFile.absolutePath, + PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath, + ) PassSecretsMapStore.skip(mapFile) assertNull(PassSecretsMapStore.claimForDirectory(identity, root)) } @@ -148,7 +153,10 @@ class PassSecretsMapStoreTest { mapFile.appendText("changed") assertNull(PassSecretsMapStore.mappedName(password, root)) - assertEquals(mapFile.absolutePath, PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath) + assertEquals( + mapFile.absolutePath, + PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath, + ) } @Test @@ -161,7 +169,10 @@ class PassSecretsMapStoreTest { mapFile.appendText("changed") - assertEquals(mapFile.absolutePath, PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath) + assertEquals( + mapFile.absolutePath, + PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath, + ) } @Test @@ -175,7 +186,10 @@ class PassSecretsMapStoreTest { PassSecretsMapStore.clear() assertNull(PassSecretsMapStore.mappedName(password, root)) - assertEquals(mapFile.absolutePath, PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath) + assertEquals( + mapFile.absolutePath, + PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath, + ) } @Test From 90b87833407da7a6c7df046076fb5a84f6d6c1ac Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 08:12:42 +0200 Subject: [PATCH 04/31] refactor: harden Pass-Secrets map resolution --- .../passsecrets/PassSecretsMapStore.kt | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt index 2ef32b71d9..16b408303c 100644 --- a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt @@ -5,6 +5,7 @@ package app.passwordstore.passsecrets import java.io.File +import java.io.IOException /** In-memory resolver for Pass-Secrets' per-identity `.secrets.gpg` mapping files. */ object PassSecretsMapStore { @@ -12,21 +13,15 @@ object PassSecretsMapStore { const val MAP_FILE_NAME = ".secrets.gpg" const val MASK_FILE_NAME = ".mask.gpg" private const val PENDING_VALUE = "(pendente)" + private val validMapKey = Regex("[A-Za-z0-9_./-]+") private data class FileVersion(val lastModified: Long, val length: Long) private data class LoadedMap(val version: FileVersion, val values: Map) - private enum class GateState { - Pending, - Skipped, - } - - private data class Gate(val version: FileVersion, val state: GateState) - private val lock = Any() private val loadedMaps = mutableMapOf() - private val gates = mutableMapOf() + private val gatedVersions = mutableMapOf() /** Parse the plaintext map format ` = `. */ fun parse(plaintext: String): Map { @@ -64,7 +59,7 @@ object PassSecretsMapStore { val loaded = loadedMaps[identityKey] ?: return null if (loaded.version != mapFile.version()) { loadedMaps.remove(identityKey) - gates.remove(identityKey) + gatedVersions.remove(identityKey) return null } return loaded.values[relativePath] @@ -92,13 +87,13 @@ object PassSecretsMapStore { loadedMaps.remove(identityKey) } - val gate = gates[identityKey] - if (gate != null) { - if (gate.version == version) return null - gates.remove(identityKey) + val gatedVersion = gatedVersions[identityKey] + if (gatedVersion != null) { + if (gatedVersion == version) return null + gatedVersions.remove(identityKey) } - gates[identityKey] = Gate(version, GateState.Pending) + gatedVersions[identityKey] = version return mapFile } } @@ -109,7 +104,7 @@ object PassSecretsMapStore { synchronized(lock) { val identityKey = identity.absolutePath loadedMaps[identityKey] = LoadedMap(mapFile.version(), values.toMap()) - gates.remove(identityKey) + gatedVersions.remove(identityKey) } } @@ -119,7 +114,7 @@ object PassSecretsMapStore { synchronized(lock) { val identityKey = identity.absolutePath if (!loadedMaps.containsKey(identityKey)) { - gates[identityKey] = Gate(mapFile.version(), GateState.Skipped) + gatedVersions[identityKey] = mapFile.version() } } } @@ -128,7 +123,7 @@ object PassSecretsMapStore { fun clear() { synchronized(lock) { loadedMaps.clear() - gates.clear() + gatedVersions.clear() } } @@ -137,8 +132,14 @@ object PassSecretsMapStore { } private fun findNearestIdentity(start: File, repositoryRoot: File): File? { - val root = repositoryRoot.absoluteFile - var current = start.absoluteFile + val root: File + var current: File + try { + root = repositoryRoot.canonicalFile + current = start.canonicalFile + } catch (_: IOException) { + return null + } if (!isInsideRoot(current, root)) return null while (true) { @@ -156,10 +157,8 @@ object PassSecretsMapStore { } private fun isValidMapKey(key: String): Boolean { - if (key.isBlank() || key.startsWith('/') || key.startsWith('\\') || '\\' in key) return false - return key.split('/').none { component -> - component.isBlank() || component == "." || component == ".." - } + if (!validMapKey.matches(key) || key.startsWith('/') || ".." in key) return false + return key.split('/').none { component -> component.isBlank() || component == "." } } private fun File.version() = FileVersion(lastModified = lastModified(), length = length()) From bb4873d594322d53eb4082f22c75e54324532365 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 08:13:13 +0200 Subject: [PATCH 05/31] test: cover Pass-Secrets activation and fallback cases --- .../passsecrets/PassSecretsMapStoreTest.kt | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt index b08be0fb18..0bdbd7ee17 100644 --- a/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt +++ b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt @@ -58,6 +58,7 @@ class PassSecretsMapStoreTest { /absolute/path = nope foo/../bar = nope foo\\bar = nope + foo bar/baz = nope valid/path = pending/path = (pendente) good/path = Good value @@ -73,6 +74,38 @@ class PassSecretsMapStoreTest { assertEquals("second", parsed["Foo/Bar"]) } + @Test + fun `plain store without identity or map never activates`() { + val directory = File(root, "Plain").apply { mkdirs() } + val password = password(directory, "example.com") + + assertNull(PassSecretsMapStore.claimForDirectory(directory, root)) + assertNull(PassSecretsMapStore.mappedName(password, root)) + } + + @Test + fun `root identity is detected automatically`() { + File(root, ".gpg-id").writeText("0123456789ABCDEF\n") + val mapFile = File(root, PassSecretsMapStore.MAP_FILE_NAME).apply { writeText("ciphertext") } + val password = password(root, "Abcde") + + assertEquals( + mapFile.absolutePath, + PassSecretsMapStore.claimForDirectory(root, root)?.absolutePath, + ) + PassSecretsMapStore.put(mapFile, mapOf("Abcde" to "GitHub personal")) + assertEquals("GitHub personal", PassSecretsMapStore.mappedName(password, root)) + } + + @Test + fun `identity without secrets map behaves like a normal store`() { + val identity = identity(root, "Work", withMap = false) + val password = password(identity, "Abcde") + + assertNull(PassSecretsMapStore.claimForDirectory(identity, root)) + assertNull(PassSecretsMapStore.mappedName(password, root)) + } + @Test fun `mapped name resolves relative to nearest identity`() { val identity = identity(root, "Work") @@ -83,6 +116,29 @@ class PassSecretsMapStoreTest { assertEquals("GitHub work", PassSecretsMapStore.mappedName(password, root)) } + @Test + fun `unmapped physical entry falls back cleanly`() { + val identity = identity(root, "Work") + val password = password(identity, "Abcde") + val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME) + PassSecretsMapStore.put(mapFile, mapOf("Different" to "Orphan mapping")) + + assertNull(PassSecretsMapStore.mappedName(password, root)) + val item = PasswordItem.newPassword(password.name, password, root) + assertEquals("Abcde", item.toString()) + assertTrue(item.matchesSearch("Abcde")) + } + + @Test + fun `orphan map entry cannot resolve an unrelated physical file`() { + val identity = identity(root, "Work") + val password = password(identity, "Existing") + val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME) + PassSecretsMapStore.put(mapFile, mapOf("Missing" to "Bank")) + + assertNull(PassSecretsMapStore.mappedName(password, root)) + } + @Test fun `nested identity with own map overrides parent identity`() { val parent = identity(root, "Parent") From ffffcf85e180c51f8fc6510bf0f8e07280d6ca26 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 08:14:07 +0200 Subject: [PATCH 06/31] fix: keep mapped labels out of persisted intents --- .../java/app/passwordstore/data/password/PasswordItem.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt index 35c4895460..7fec2ce5aa 100644 --- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -20,7 +20,7 @@ data class PasswordItem( val mappedName: String? = null, ) : Comparable { - private val physicalName = name.replace("\\.gpg$".toRegex(), "") + val physicalName = name.replace("\\.gpg$".toRegex(), "") val fullPathToParent = PasswordRepository.getParentPath(file.absolutePath, rootDir.absolutePath) @@ -68,7 +68,9 @@ data class PasswordItem( /** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */ fun createAuthEnabledIntent(context: Context): Intent { val intent = Intent(context, LaunchActivity::class.java) - intent.putExtra("NAME", toString()) + // Intent extras may outlive the current UI process through Android shortcuts. Keep the + // persisted identity physical even when a Pass-Secrets label is currently unlocked. + intent.putExtra("NAME", physicalName) intent.putExtra(BasePGPActivity.EXTRA_FILE_PATH, file.absolutePath) intent.putExtra( BasePGPActivity.EXTRA_REPO_PATH, From c46d1c7518096476bfc41d78b89d22e2b8cf0647 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 08:14:20 +0200 Subject: [PATCH 07/31] fix: keep Pass-Secrets labels out of launcher shortcuts --- .../app/passwordstore/util/shortcuts/ShortcutHandler.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt b/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt index 57dd87bee1..642d04ce24 100644 --- a/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt +++ b/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt @@ -71,9 +71,11 @@ class ShortcutHandler @Inject constructor(@ApplicationContext val context: Conte /** Creates a [ShortcutInfo] from [item] and assigns [intent] to it. */ private fun buildShortcut(item: PasswordItem, intent: Intent): ShortcutInfo { - return ShortcutInfo.Builder(context, item.longName) - .setShortLabel(item.toString()) - .setLongLabel("/${item.longName}") + // Android persists launcher shortcuts outside this process. Pass-Secrets labels therefore must + // not be used here: only the obfuscated physical path is safe to persist. + return ShortcutInfo.Builder(context, item.physicalLongName) + .setShortLabel(item.physicalName) + .setLongLabel("/${item.physicalLongName}") .setIcon(Icon.createWithResource(context, R.drawable.ic_lock_open_24px)) .setIntent(intent) .build() From f5b5aef64757ca2ff0a8c045c69ccd158acc22b3 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 08:16:38 +0200 Subject: [PATCH 08/31] style: format PasswordItem --- .../main/java/app/passwordstore/data/password/PasswordItem.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt index 7fec2ce5aa..93d6254575 100644 --- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -29,8 +29,7 @@ data class PasswordItem( val longName = PasswordRepository.getLongName(fullPathToParent, rootDir.absolutePath, toString()) - val searchableName = - if (mappedName != null) "$longName $physicalLongName" else physicalLongName + val searchableName = if (mappedName != null) "$longName $physicalLongName" else physicalLongName fun matchesSearch(filter: String): Boolean = searchableName.contains(filter, ignoreCase = true) From 5c90a7eeff1208b2f2f11989a797d299d9193e9f Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 08:16:56 +0200 Subject: [PATCH 09/31] style: format Pass-Secrets resolver --- .../java/app/passwordstore/passsecrets/PassSecretsMapStore.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt index 16b408303c..e3d3b14fa2 100644 --- a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt @@ -43,7 +43,8 @@ object PassSecretsMapStore { /** Resolve the mapped display name for a physical password file, if its map is unlocked. */ fun mappedName(file: File, repositoryRoot: File): String? { if (!file.isFile || file.extension != "gpg" || isMetadataFile(file)) return null - val identity = findNearestIdentity(file.parentFile ?: return null, repositoryRoot) ?: return null + val identity = + findNearestIdentity(file.parentFile ?: return null, repositoryRoot) ?: return null val mapFile = File(identity, MAP_FILE_NAME) if (!mapFile.isFile) return null From 978f848f4b957b734a77dad76f3ede3a5118e077 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 08:17:53 +0200 Subject: [PATCH 10/31] style: format Pass-Secrets tests --- .../passwordstore/passsecrets/PassSecretsMapStoreTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt index 0bdbd7ee17..3f5318d1d5 100644 --- a/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt +++ b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt @@ -42,7 +42,8 @@ class PassSecretsMapStoreTest { """ Abcde/FgXyz = GitHub personal Qwert/Asdfg=service = production - """.trimIndent() + """ + .trimIndent() ) assertEquals("GitHub personal", parsed["Abcde/FgXyz"]) @@ -62,7 +63,8 @@ class PassSecretsMapStoreTest { valid/path = pending/path = (pendente) good/path = Good value - """.trimIndent() + """ + .trimIndent() ) assertEquals(mapOf("good/path" to "Good value"), parsed) From 4ed8b44a28f709c5f44904eaa233698608e17667 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 08:18:52 +0200 Subject: [PATCH 11/31] style: format repository view model --- .../util/viewmodel/SearchableRepositoryViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index 0f9fc9bb77..c90ca71a23 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -513,6 +513,7 @@ open class SearchableRepositoryAdapter( } final override fun getPopupText(view: View, position: Int): String { - return getItem(position).toString().firstOrNull()?.toString()?.uppercase(Locale.getDefault()) ?: "" + return getItem(position).toString().firstOrNull()?.toString()?.uppercase(Locale.getDefault()) + ?: "" } } From e273eae44a9906d289a8d4fa9d61b13b7616447c Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:38:21 +0200 Subject: [PATCH 12/31] feat: complete Pass-Secrets metadata model --- .../passsecrets/PassSecretsMapStore.kt | 397 +++++++++++++++--- 1 file changed, 348 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt index e3d3b14fa2..d27adee30f 100644 --- a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt @@ -6,22 +6,45 @@ package app.passwordstore.passsecrets import java.io.File import java.io.IOException +import java.security.SecureRandom -/** In-memory resolver for Pass-Secrets' per-identity `.secrets.gpg` mapping files. */ +/** In-memory resolver and mutation model for Pass-Secrets identity metadata. */ object PassSecretsMapStore { const val MAP_FILE_NAME = ".secrets.gpg" const val MASK_FILE_NAME = ".mask.gpg" + private const val GPG_ID_FILE_NAME = ".gpg-id" private const val PENDING_VALUE = "(pendente)" + private const val DEFAULT_CODENAME_LENGTH = 5 + private const val CODENAME_ATTEMPTS = 50 + private const val CONSONANTS = "bcdfglmnprstvz" + private const val VOWELS = "aeiou" private val validMapKey = Regex("[A-Za-z0-9_./-]+") + private val random = SecureRandom() + + data class MaskAssociation(val alias: String, val directory: String) + + data class MetadataFiles(val mapFile: File?, val maskFile: File?) { + + val existingFiles: List + get() = listOfNotNull(mapFile, maskFile) + } private data class FileVersion(val lastModified: Long, val length: Long) + private data class IdentityVersion(val map: FileVersion?, val mask: FileVersion?) + private data class LoadedMap(val version: FileVersion, val values: Map) + private data class LoadedMask( + val version: FileVersion, + val associations: List, + ) + private val lock = Any() private val loadedMaps = mutableMapOf() - private val gatedVersions = mutableMapOf() + private val loadedMasks = mutableMapOf() + private val gatedVersions = mutableMapOf() /** Parse the plaintext map format ` = `. */ fun parse(plaintext: String): Map { @@ -34,29 +57,57 @@ object PassSecretsMapStore { val value = rawLine.substring(separator + 1).trim() if (!isValidMapKey(key) || value.isBlank() || value == PENDING_VALUE) return@forEach - // Deliberately let the last mapping win for malformed files with duplicate keys. + // Pass-Secrets treats a path as a unique key. Last value wins for a malformed duplicate. values[key] = value } return values } + /** Parse `.mask.gpg`'s many-to-many ` = ` format. */ + fun parseMask(plaintext: String): List { + return plaintext + .lineSequence() + .mapNotNull { rawLine -> + val separator = rawLine.indexOf('=') + if (separator <= 0) return@mapNotNull null + val alias = rawLine.substring(0, separator).trim() + val directory = rawLine.substring(separator + 1).trim() + if (alias.isBlank() || !isValidMapKey(directory)) return@mapNotNull null + MaskAssociation(alias, directory) + } + .distinct() + .sortedWith(compareBy { it.alias }.thenBy { it.directory }) + .toList() + } + + fun serializeMap(values: Map): String { + return values + .toSortedMap() + .entries + .joinToString(separator = "\n", postfix = if (values.isEmpty()) "" else "\n") { + (key, value) -> + "$key = $value" + } + } + + fun serializeMask(associations: List): String { + val values = associations.distinct().sortedWith(compareBy { it.alias }.thenBy { it.directory }) + return values.joinToString(separator = "\n", postfix = if (values.isEmpty()) "" else "\n") { + association -> + "${association.alias} = ${association.directory}" + } + } + /** Resolve the mapped display name for a physical password file, if its map is unlocked. */ fun mappedName(file: File, repositoryRoot: File): String? { - if (!file.isFile || file.extension != "gpg" || isMetadataFile(file)) return null - val identity = - findNearestIdentity(file.parentFile ?: return null, repositoryRoot) ?: return null + if (!isPasswordFile(file)) return null + val identity = findNearestIdentity(file.parentFile ?: return null, repositoryRoot) ?: return null val mapFile = File(identity, MAP_FILE_NAME) if (!mapFile.isFile) return null - - val relativePath = - try { - file.relativeTo(identity).invariantSeparatorsPath.removeSuffix(".gpg") - } catch (_: IllegalArgumentException) { - return null - } + val relativePath = passwordRelativePath(file, identity) ?: return null synchronized(lock) { - val identityKey = identity.absolutePath + val identityKey = identity.key() val loaded = loadedMaps[identityKey] ?: return null if (loaded.version != mapFile.version()) { loadedMaps.remove(identityKey) @@ -67,63 +118,145 @@ object PassSecretsMapStore { } } + /** Resolve any `.mask.gpg` aliases applying to this password's physical directory. */ + fun aliases(file: File, repositoryRoot: File): List { + if (!isPasswordFile(file)) return emptyList() + val identity = findNearestIdentity(file.parentFile ?: return emptyList(), repositoryRoot) + ?: return emptyList() + val maskFile = File(identity, MASK_FILE_NAME) + if (!maskFile.isFile) return emptyList() + val relativeDirectory = + try { + file.parentFile + ?.relativeTo(identity) + ?.invariantSeparatorsPath + ?.ifBlank { "." } + ?: return emptyList() + } catch (_: IllegalArgumentException) { + return emptyList() + } + + synchronized(lock) { + val identityKey = identity.key() + val loaded = loadedMasks[identityKey] ?: return emptyList() + if (loaded.version != maskFile.version()) { + loadedMasks.remove(identityKey) + gatedVersions.remove(identityKey) + return emptyList() + } + return loaded.associations + .asSequence() + .filter { association -> directoryContains(association.directory, relativeDirectory) } + .map { it.alias } + .distinct() + .toList() + } + } + /** - * Claim the map belonging to [directory]'s nearest identity for lazy unlock. + * Claim this directory's nearest identity metadata for lazy unlock. * - * A nested `.gpg-id` is always a hard boundary: if that identity has no map, the parent map is - * never inherited. A map is only returned once until it is either loaded, skipped, changed, or - * the in-memory state is cleared. + * A nested `.gpg-id` is always a hard boundary. Both `.secrets.gpg` and `.mask.gpg` are loaded + * in one authentication session when present. The returned path is merely the primary file used + * to launch the unlock activity. */ fun claimForDirectory(directory: File, repositoryRoot: File): File? { val identity = findNearestIdentity(directory, repositoryRoot) ?: return null - val mapFile = File(identity, MAP_FILE_NAME) - if (!mapFile.isFile) return null + val metadata = identity.metadataFiles() + val primaryFile = metadata.mapFile ?: metadata.maskFile ?: return null synchronized(lock) { - val identityKey = identity.absolutePath - val version = mapFile.version() - val loaded = loadedMaps[identityKey] - if (loaded != null) { - if (loaded.version == version) return null - loadedMaps.remove(identityKey) - } + val identityKey = identity.key() + val version = identity.version() + if (isCurrent(identityKey, metadata)) return null val gatedVersion = gatedVersions[identityKey] - if (gatedVersion != null) { - if (gatedVersion == version) return null - gatedVersions.remove(identityKey) - } - + if (gatedVersion == version) return null gatedVersions[identityKey] = version - return mapFile + return primaryFile } } - /** Store a successfully decrypted map. Plaintext mappings never leave process memory. */ - fun put(mapFile: File, values: Map) { - val identity = mapFile.parentFile ?: return + fun metadataFilesForDirectory(directory: File, repositoryRoot: File): MetadataFiles { + val identity = findNearestIdentity(directory, repositoryRoot) ?: return MetadataFiles(null, null) + return identity.metadataFiles() + } + + fun mapFileForDirectory(directory: File, repositoryRoot: File): File? { + return metadataFilesForDirectory(directory, repositoryRoot).mapFile + } + + fun maskFileForDirectory(directory: File, repositoryRoot: File): File? { + return metadataFilesForDirectory(directory, repositoryRoot).maskFile + } + + fun identityForDirectory(directory: File, repositoryRoot: File): File? { + return findNearestIdentity(directory, repositoryRoot) + } + + fun isLoaded(metadataFile: File): Boolean { + val identity = metadataFile.parentFile ?: return false synchronized(lock) { - val identityKey = identity.absolutePath - loadedMaps[identityKey] = LoadedMap(mapFile.version(), values.toMap()) - gatedVersions.remove(identityKey) + return when (metadataFile.name) { + MAP_FILE_NAME -> loadedMaps[identity.key()]?.version == metadataFile.version() + MASK_FILE_NAME -> loadedMasks[identity.key()]?.version == metadataFile.version() + else -> false + } } } - /** Suppress repeated automatic prompts after cancellation or a non-recoverable unlock failure. */ - fun skip(mapFile: File) { + /** Store a successfully decrypted `.secrets.gpg`. Plaintext remains process-local. */ + fun putMap(mapFile: File, values: Map) { val identity = mapFile.parentFile ?: return synchronized(lock) { - val identityKey = identity.absolutePath - if (!loadedMaps.containsKey(identityKey)) { - gatedVersions[identityKey] = mapFile.version() - } + loadedMaps[identity.key()] = LoadedMap(mapFile.version(), values.toMap()) + gatedVersions.remove(identity.key()) + } + } + + /** Store a successfully decrypted `.mask.gpg`. Plaintext remains process-local. */ + fun putMask(maskFile: File, associations: List) { + val identity = maskFile.parentFile ?: return + synchronized(lock) { + loadedMasks[identity.key()] = LoadedMask(maskFile.version(), associations.toList()) + gatedVersions.remove(identity.key()) + } + } + + /** Backwards-compatible shorthand used by older tests/callers for `.secrets.gpg`. */ + fun put(mapFile: File, values: Map) = putMap(mapFile, values) + + fun mapSnapshot(mapFile: File): Map? { + if (mapFile.name != MAP_FILE_NAME || !mapFile.isFile) return null + val identity = mapFile.parentFile ?: return null + synchronized(lock) { + val loaded = loadedMaps[identity.key()] ?: return null + if (loaded.version != mapFile.version()) return null + return loaded.values.toMap() + } + } + + fun maskSnapshot(maskFile: File): List? { + if (maskFile.name != MASK_FILE_NAME || !maskFile.isFile) return null + val identity = maskFile.parentFile ?: return null + synchronized(lock) { + val loaded = loadedMasks[identity.key()] ?: return null + if (loaded.version != maskFile.version()) return null + return loaded.associations.toList() } } - /** Forget all decrypted labels and prompt gates, e.g. when the screen locks. */ + /** Suppress repeated automatic prompts after cancellation/failure for this metadata version. */ + fun skip(metadataFile: File) { + val identity = metadataFile.parentFile ?: return + synchronized(lock) { gatedVersions[identity.key()] = identity.version() } + } + + /** Forget all decrypted labels, aliases and prompt gates, e.g. when the screen locks. */ fun clear() { synchronized(lock) { loadedMaps.clear() + loadedMasks.clear() gatedVersions.clear() } } @@ -132,6 +265,139 @@ object PassSecretsMapStore { return file.isFile && (file.name == MAP_FILE_NAME || file.name == MASK_FILE_NAME) } + fun isProtectedIdentityMarker(file: File): Boolean { + if (file.name != GPG_ID_FILE_NAME || !file.isFile) return false + val parent = file.parentFile ?: return false + return File(parent, MAP_FILE_NAME).isFile || File(parent, MASK_FILE_NAME).isFile + } + + fun passwordRelativePath(file: File, identity: File): String? { + if (!isPasswordFile(file)) return null + return try { + file.relativeTo(identity).invariantSeparatorsPath.removeSuffix(".gpg") + } catch (_: IllegalArgumentException) { + null + } + } + + /** Generate a free codename exactly like Pass-Secrets 2.5.x `namegen`. */ + fun generateCodename(directory: File, length: Int = DEFAULT_CODENAME_LENGTH): String { + require(length > 0) { "Codename length must be positive" } + repeat(CODENAME_ATTEMPTS) { + val startsWithConsonant = random.nextBoolean() + val generated = + buildString(length) { + repeat(length) { index -> + val useConsonant = (index % 2 == 0) == startsWithConsonant + val alphabet = if (useConsonant) CONSONANTS else VOWELS + append(alphabet[random.nextInt(alphabet.length)]) + } + } + val codename = generated.replaceFirstChar { it.uppercaseChar() } + if (!File(directory, codename).exists() && !File(directory, "$codename.gpg").exists()) { + return codename + } + } + error("Unable to generate a free Pass-Secrets codename after $CODENAME_ATTEMPTS attempts") + } + + fun mapAfterMove( + values: Map, + sourceRelativePath: String, + destinationRelativePath: String, + sourceIsDirectory: Boolean, + ): Map { + val result = linkedMapOf() + values.forEach { (key, value) -> + val movedKey = + when { + !sourceIsDirectory && key == sourceRelativePath -> destinationRelativePath + sourceIsDirectory && key.startsWith("$sourceRelativePath/") -> + destinationRelativePath + key.removePrefix(sourceRelativePath) + else -> key + } + result[movedKey] = value + } + return result + } + + fun mapAfterDelete( + values: Map, + relativePath: String, + isDirectory: Boolean, + ): Map { + return values.filterKeys { key -> + if (isDirectory) key != relativePath && !key.startsWith("$relativePath/") + else key != relativePath + } + } + + fun maskAfterMove( + associations: List, + sourceRelativePath: String, + destinationRelativePath: String, + ): List { + return associations + .map { association -> + val movedDirectory = + when { + association.directory == sourceRelativePath -> destinationRelativePath + association.directory.startsWith("$sourceRelativePath/") -> + destinationRelativePath + association.directory.removePrefix(sourceRelativePath) + else -> association.directory + } + association.copy(directory = movedDirectory) + } + .distinct() + } + + fun maskAfterDelete( + associations: List, + relativePath: String, + isDirectory: Boolean, + ): List { + if (!isDirectory) return associations + return associations.filterNot { association -> + association.directory == relativePath || association.directory.startsWith("$relativePath/") + } + } + + /** + * Return true when a generic filesystem move would change the `.gpg-id` owning encrypted files. + * Such a move requires decrypt/re-encrypt and must not be performed by the bulk move path. + */ + fun moveRequiresReencryption(source: File, destination: File, repositoryRoot: File): Boolean { + if (isProtectedIdentityMarker(source)) return true + if (source.isFile) { + if (!isPasswordFile(source)) return false + val sourceIdentity = findNearestIdentity(source.parentFile ?: return false, repositoryRoot) + val targetIdentity = + findNearestIdentity(destination.parentFile ?: return false, repositoryRoot) + return !sameIdentity(sourceIdentity, targetIdentity) + } + + if (!source.isDirectory || File(source, GPG_ID_FILE_NAME).isFile) return false + val sourceIdentity = findNearestIdentity(source, repositoryRoot) + val targetIdentity = + findNearestIdentity(destination.parentFile ?: return false, repositoryRoot) + if (sameIdentity(sourceIdentity, targetIdentity)) return false + + return source + .walkTopDown() + .onEnter { directory -> + directory == source || !File(directory, GPG_ID_FILE_NAME).isFile + } + .any(::isPasswordFile) + } + + private fun isCurrent(identityKey: String, metadata: MetadataFiles): Boolean { + val mapCurrent = + metadata.mapFile == null || loadedMaps[identityKey]?.version == metadata.mapFile.version() + val maskCurrent = + metadata.maskFile == null || loadedMasks[identityKey]?.version == metadata.maskFile.version() + return mapCurrent && maskCurrent + } + private fun findNearestIdentity(start: File, repositoryRoot: File): File? { val root: File var current: File @@ -144,7 +410,7 @@ object PassSecretsMapStore { if (!isInsideRoot(current, root)) return null while (true) { - if (File(current, ".gpg-id").isFile) return current + if (File(current, GPG_ID_FILE_NAME).isFile) return current if (current == root) return null current = current.parentFile ?: return null if (!isInsideRoot(current, root)) return null @@ -158,9 +424,42 @@ object PassSecretsMapStore { } private fun isValidMapKey(key: String): Boolean { - if (!validMapKey.matches(key) || key.startsWith('/') || ".." in key) return false - return key.split('/').none { component -> component.isBlank() || component == "." } + return key.isNotBlank() && validMapKey.matches(key) && !key.startsWith('/') && ".." !in key + } + + private fun directoryContains(mappedDirectory: String, actualDirectory: String): Boolean { + return mappedDirectory == "." || + actualDirectory == mappedDirectory || + actualDirectory.startsWith("$mappedDirectory/") + } + + private fun isPasswordFile(file: File): Boolean { + return file.isFile && file.extension.equals("gpg", ignoreCase = true) && !isMetadataFile(file) + } + + private fun sameIdentity(first: File?, second: File?): Boolean { + if (first == null || second == null) return first == null && second == null + return first.key() == second.key() + } + + private fun File.metadataFiles(): MetadataFiles { + val map = File(this, MAP_FILE_NAME).takeIf { it.isFile } + val mask = File(this, MASK_FILE_NAME).takeIf { it.isFile } + return MetadataFiles(map, mask) } private fun File.version() = FileVersion(lastModified = lastModified(), length = length()) + + private fun File.version(): IdentityVersion = + IdentityVersion( + map = File(this, MAP_FILE_NAME).takeIf { it.isFile }?.version(), + mask = File(this, MASK_FILE_NAME).takeIf { it.isFile }?.version(), + ) + + private fun File.key(): String = + try { + canonicalPath + } catch (_: IOException) { + absolutePath + } } From d6eab2ece53d2ba4bafb1824b0a16354a07c0ed5 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:39:00 +0200 Subject: [PATCH 13/31] feat: persist Pass-Secrets metadata atomically --- .../passsecrets/PassSecretsMapWriter.kt | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt new file mode 100644 index 0000000000..c4f913d76d --- /dev/null +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt @@ -0,0 +1,171 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.passsecrets + +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.crypto.CryptoRepository +import app.passwordstore.passsecrets.PassSecretsMapStore.MaskAssociation +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.extensions.wipe +import com.github.michaelbull.result.getOrThrow +import com.github.michaelbull.result.unwrapError +import dagger.Reusable +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import javax.inject.Inject +import kotlinx.coroutines.withContext + +/** Encrypts and commits Pass-Secrets metadata updates without ever persisting plaintext. */ +@Reusable +class PassSecretsMapWriter +@Inject +constructor( + private val repository: CryptoRepository, + private val dispatcherProvider: DispatcherProvider, +) { + + sealed interface Update { + val file: File + + data class Secrets(override val file: File, val values: Map) : Update + + data class Mask(override val file: File, val associations: List) : Update + } + + private data class StagedUpdate( + val update: Update, + val temporaryFile: File, + val originalCiphertext: ByteArray?, + ) + + suspend fun persist(updates: List) { + if (updates.isEmpty()) return + withContext(dispatcherProvider.io()) { + require(updates.map { it.file.canonicalPath }.distinct().size == updates.size) { + "Pass-Secrets metadata update contains duplicate target files" + } + + val staged = mutableListOf() + try { + updates.forEach { update -> staged += stage(update) } + val committed = mutableListOf() + try { + staged.forEach { stagedUpdate -> + replace(stagedUpdate.temporaryFile, stagedUpdate.update.file) + committed += stagedUpdate + } + } catch (error: Throwable) { + committed.asReversed().forEach(::restoreBestEffort) + throw error + } + + updates.forEach { update -> + when (update) { + is Update.Secrets -> PassSecretsMapStore.putMap(update.file, update.values) + is Update.Mask -> PassSecretsMapStore.putMask(update.file, update.associations) + } + } + } finally { + staged.forEach { stagedUpdate -> + stagedUpdate.temporaryFile.delete() + stagedUpdate.originalCiphertext?.wipe() + } + } + } + } + + private fun stage(update: Update): StagedUpdate { + val target = update.file + val identity = requireNotNull(target.parentFile) { "Metadata file has no parent: $target" } + val plaintext = + when (update) { + is Update.Secrets -> PassSecretsMapStore.serializeMap(update.values) + is Update.Mask -> PassSecretsMapStore.serializeMask(update.associations) + } + val plaintextBytes = plaintext.encodeToByteArray() + val encryptedBytes = + try { + encrypt(identity, plaintextBytes) + } finally { + plaintextBytes.wipe() + } + + val temporaryFile = File.createTempFile(".aps-pass-secrets-", ".tmp", identity) + try { + temporaryFile.writeBytes(encryptedBytes) + } finally { + encryptedBytes.wipe() + } + return StagedUpdate( + update = update, + temporaryFile = temporaryFile, + originalCiphertext = target.takeIf { it.isFile }?.readBytes(), + ) + } + + private fun encrypt(identity: File, plaintext: ByteArray): ByteArray { + val identifiers = identifiersForIdentity(identity) + val output = ByteArrayOutputStream() + val (_, result) = repository.encrypt(identifiers, plaintext.inputStream(), output) + if (result.isErr) throw result.unwrapError() + val encryptedOutput = result.getOrThrow() + return encryptedOutput.toByteArray().also { encryptedOutput.wipe() } + } + + /** Pass-Secrets intentionally uses the exact identity `.gpg-id`; it never inherits a parent. */ + private fun identifiersForIdentity(identity: File): List { + val gpgId = File(identity, ".gpg-id") + require(gpgId.isFile) { "Pass-Secrets identity has no .gpg-id: $identity" } + + val identifiers = mutableListOf() + gpgId.readLines().forEach { rawLine -> + val line = rawLine.substringBefore(Regex("\\s*#|!")).trim() + if (line.isBlank() || line == "gpg-id") return@forEach + require(!line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { + "Short OpenPGP key IDs are not accepted in $gpgId" + } + val identifier = + requireNotNull(PGPIdentifier.fromString(line)) { "Invalid OpenPGP identifier '$line' in $gpgId" } + require(repository.hasKey(identifier)) { "OpenPGP key '$identifier' is not imported" } + identifiers += identifier + } + require(identifiers.isNotEmpty()) { "Pass-Secrets identity has no usable recipients: $identity" } + return identifiers + } + + private fun replace(source: File, target: File) { + try { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } + + private fun restoreBestEffort(stagedUpdate: StagedUpdate) { + runCatching { + val target = stagedUpdate.update.file + val previous = stagedUpdate.originalCiphertext + if (previous == null) { + target.delete() + } else { + val restore = File.createTempFile(".aps-pass-secrets-restore-", ".tmp", target.parentFile) + try { + restore.writeBytes(previous) + replace(restore, target) + } finally { + restore.delete() + } + } + } + } +} From abce869f0492f7ebd1928ae4efaf922b4cd9a775 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:39:52 +0200 Subject: [PATCH 14/31] feat: unlock Pass-Secrets aliases with mappings --- .../ui/crypto/PassSecretsMapUnlockActivity.kt | 80 +++++++++++++++---- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt index 857972a1e8..481fb7fc0d 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt @@ -22,11 +22,11 @@ import kotlinx.coroutines.withContext import logcat.asLog import logcat.logcat -/** Authenticates and decrypts a Pass-Secrets map into the process-local mapping cache. */ +/** Authenticates once and loads all Pass-Secrets metadata for an identity into process memory. */ @AndroidEntryPoint class PassSecretsMapUnlockActivity : BasePGPActivity() { - private var mapLoaded = false + private var metadataLoaded = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -41,27 +41,44 @@ class PassSecretsMapUnlockActivity : BasePGPActivity() { identifiers: List, onSuccess: suspend (String) -> Unit, ) { - val message = withContext(dispatcherProvider.io()) { File(fullPath).readBytes().inputStream() } - val outputStream = ByteArrayOutputStream() - val results = repository.decrypt(passphrases, identifiers, message, outputStream) - val lastResult = results.lastOrNull() + val primaryFile = File(fullPath) + val primaryResults = decryptFile(primaryFile, passphrases, identifiers) + val lastResult = primaryResults.lastOrNull() if (lastResult != null && lastResult.second.isOk) { - val decryptedOutput = lastResult.second.getOrThrow() - val decryptedBytes = decryptedOutput.toByteArray() - decryptedOutput.wipe() - val mappings = PassSecretsMapStore.parse(decryptedBytes.decodeToString()) - decryptedBytes.wipe() + loadMetadata(primaryFile, lastResult.second.getOrThrow()) + metadataLoaded = true + + // `.secrets.gpg` and `.mask.gpg` belong to the same exact identity. Reuse the successful + // authentication to load the sibling too, avoiding a second passphrase/biometric prompt. + val identity = primaryFile.parentFile + if (identity != null) { + listOf(PassSecretsMapStore.MAP_FILE_NAME, PassSecretsMapStore.MASK_FILE_NAME) + .map { File(identity, it) } + .filter { it.isFile && it != primaryFile } + .forEach { sibling -> + val siblingResults = decryptFile(sibling, passphrases, identifiers) + val siblingResult = siblingResults.lastOrNull() + if (siblingResult != null && siblingResult.second.isOk) { + loadMetadata(sibling, siblingResult.second.getOrThrow()) + } else { + siblingResults.forEach { result -> + result.second.getError()?.let { error -> logcat { error.asLog() } } + } + // Keep the good primary metadata but do not repeatedly prompt just because an + // optional sibling is corrupt or was encrypted inconsistently. + PassSecretsMapStore.skip(primaryFile) + } + } + } - PassSecretsMapStore.put(File(fullPath), mappings) - mapLoaded = true onSuccess(lastResult.first) setResult(RESULT_OK) finish() } else { passphrases.values.forEach { it?.wipe() } val incorrectPassphrase = - results + primaryResults .filter { result -> if (result.second.getError() is IncorrectPassphraseException) { persistentPassphrases.edit { remove(result.first) } @@ -84,16 +101,45 @@ class PassSecretsMapUnlockActivity : BasePGPActivity() { } } + private suspend fun decryptFile( + file: File, + passphrases: Map, + identifiers: List, + ) = + withContext(dispatcherProvider.io()) { + val message = file.readBytes().inputStream() + repository.decrypt(passphrases, identifiers, message, ByteArrayOutputStream()) + } + + private fun loadMetadata(file: File, decryptedOutput: ByteArrayOutputStream) { + val decryptedBytes = decryptedOutput.toByteArray() + decryptedOutput.wipe() + try { + when (file.name) { + PassSecretsMapStore.MAP_FILE_NAME -> + PassSecretsMapStore.putMap(file, PassSecretsMapStore.parse(decryptedBytes.decodeToString())) + PassSecretsMapStore.MASK_FILE_NAME -> + PassSecretsMapStore.putMask( + file, + PassSecretsMapStore.parseMask(decryptedBytes.decodeToString()), + ) + else -> error("Unsupported Pass-Secrets metadata file: $file") + } + } finally { + decryptedBytes.wipe() + } + } + override fun onDestroy() { - if (!mapLoaded) PassSecretsMapStore.skip(File(fullPath)) + if (!metadataLoaded) PassSecretsMapStore.skip(File(fullPath)) super.onDestroy() } companion object { - fun newIntent(context: Context, mapFile: File, repositoryRoot: File): Intent { + fun newIntent(context: Context, metadataFile: File, repositoryRoot: File): Intent { return Intent(context, PassSecretsMapUnlockActivity::class.java).apply { - putExtra(EXTRA_FILE_PATH, mapFile.absolutePath) + putExtra(EXTRA_FILE_PATH, metadataFile.absolutePath) putExtra(EXTRA_REPO_PATH, repositoryRoot.absolutePath) } } From 93abc9a604048c251391bd65aa9c7ec06cae5bae Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:40:23 +0200 Subject: [PATCH 15/31] feat: search Pass-Secrets mask aliases --- .../data/password/PasswordItem.kt | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt index 93d6254575..6e5827b8cf 100644 --- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -18,6 +18,7 @@ data class PasswordItem( val file: File, val rootDir: File, val mappedName: String? = null, + val aliases: List = emptyList(), ) : Comparable { val physicalName = name.replace("\\.gpg$".toRegex(), "") @@ -29,7 +30,13 @@ data class PasswordItem( val longName = PasswordRepository.getLongName(fullPathToParent, rootDir.absolutePath, toString()) - val searchableName = if (mappedName != null) "$longName $physicalLongName" else physicalLongName + val searchableName = + buildList { + add(physicalLongName) + if (mappedName != null) add(longName) + addAll(aliases) + } + .joinToString(" ") fun matchesSearch(filter: String): Boolean = searchableName.contains(filter, ignoreCase = true) @@ -42,10 +49,15 @@ data class PasswordItem( } if (regex.containsMatchIn(physicalPath)) return true - return mappedName - ?.split(Regex("[\\s/]+")) - ?.filter { it.isNotBlank() } - ?.any { token -> regex.containsMatchIn("$token.gpg") } == true + val logicalTokens = + buildList { + mappedName?.split(Regex("[\\s/]+"))?.filterTo(this) { it.isNotBlank() } + aliases.forEach { alias -> + add(alias) + if ('@' in alias) add(alias.substringAfterLast('@')) + } + } + return logicalTokens.any { token -> regex.containsMatchIn("$token.gpg") } } override fun equals(other: Any?): Boolean { @@ -103,8 +115,9 @@ data class PasswordItem( parent: PasswordItem, rootDir: File, mappedName: String? = null, + aliases: List = emptyList(), ): PasswordItem { - return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir, mappedName) + return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir, mappedName, aliases) } @JvmStatic @@ -113,8 +126,9 @@ data class PasswordItem( file: File, rootDir: File, mappedName: String? = null, + aliases: List = emptyList(), ): PasswordItem { - return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir, mappedName) + return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir, mappedName, aliases) } @JvmStatic From 4c6f178c0c042b79006aca577ddd4058748a9e64 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:41:26 +0200 Subject: [PATCH 16/31] feat: include Pass-Secrets aliases in search --- .../util/viewmodel/SearchableRepositoryViewModel.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index c90ca71a23..823e35e3c8 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -61,7 +61,13 @@ private fun File.toPasswordItem(): PasswordItem { return if (isFile) { if (name == ".gpg-id") PasswordItem.newGpgIdItem(name, this, root) else if (extension == "gpg") - PasswordItem.newPassword(name, this, root, PassSecretsMapStore.mappedName(this, root)) + PasswordItem.newPassword( + name, + this, + root, + PassSecretsMapStore.mappedName(this, root), + PassSecretsMapStore.aliases(this, root), + ) else PasswordItem.newOtherItem(name, this, root) } else PasswordItem.newCategory(name, this, root) } @@ -411,7 +417,9 @@ private object PasswordItemDiffCallback : DiffUtil.ItemCallback() oldItem.file.absolutePath == newItem.file.absolutePath override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = - oldItem.file == newItem.file && oldItem.mappedName == newItem.mappedName + oldItem.file == newItem.file && + oldItem.mappedName == newItem.mappedName && + oldItem.aliases == newItem.aliases } open class SearchableRepositoryAdapter( From d78a44f6e5ab617fffbb011a1d8d9feeb1bba392 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:42:03 +0200 Subject: [PATCH 17/31] feat: edit Pass-Secrets entries by logical name --- .../passwordstore/ui/crypto/DecryptActivity.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt index 48e0854c98..9970f227ff 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt @@ -19,12 +19,14 @@ import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.password.FieldItem import app.passwordstore.databinding.DecryptLayoutBinding +import app.passwordstore.passsecrets.PassSecretsMapStore import app.passwordstore.ui.adapters.FieldItemAdapter import app.passwordstore.util.crypto.AESEncryption import app.passwordstore.util.extensions.enableEdgeToEdgeView import app.passwordstore.util.extensions.getString import app.passwordstore.util.extensions.snackbar import app.passwordstore.util.extensions.toCharArray +import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.extensions.viewBinding import app.passwordstore.util.extensions.wipe import app.passwordstore.util.settings.PreferenceKeys @@ -47,6 +49,9 @@ class DecryptActivity : BasePGPActivity() { private var itemsAdapter: FieldItemAdapter? = null private val binding by viewBinding(DecryptLayoutBinding::inflate) + private val passSecretsName by unsafeLazy { + PassSecretsMapStore.mappedName(File(fullPath), File(repoPath)) + } // temporarily AES-encrypted password entry private var encryptedEntryChars: CharArray? = null // AES encrypted password entry @@ -56,14 +61,15 @@ class DecryptActivity : BasePGPActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) supportActionBar?.setDisplayHomeAsUpEnabled(true) - title = name + val displayName = passSecretsName ?: name + title = displayName with(binding) { enableEdgeToEdgeView(root) setContentView(root) passwordCategory.text = relativeParentPath - passwordFile.text = name + passwordFile.text = displayName passwordFile.setOnLongClickListener { - copyTextToClipboard(name.toCharArray(), isSensitive = false) + copyTextToClipboard(displayName.toCharArray(), isSensitive = false) true } fab.setOnClickListener { copyPassword() } @@ -188,7 +194,8 @@ class DecryptActivity : BasePGPActivity() { intent.action = Intent.ACTION_VIEW intent.putExtra(EXTRA_FILE_PATH, Paths.get(fullPath).parent.pathString) intent.putExtra(EXTRA_REPO_PATH, repoPath) - intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name) + intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, passSecretsName ?: name) + intent.putExtra(PasswordCreationActivity.EXTRA_PHYSICAL_FILE_NAME, name) intent.putExtra(PasswordCreationActivity.EXTRA_ENTRY, encrypted) intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true) startActivity(intent) From b4876de410cc814aca9f9063cbe5f795b2a14b21 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:45:42 +0200 Subject: [PATCH 18/31] fix: disambiguate Pass-Secrets metadata versions --- .../passsecrets/PassSecretsMapStore.kt | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt index d27adee30f..2c98a8e0c0 100644 --- a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt @@ -91,7 +91,10 @@ object PassSecretsMapStore { } fun serializeMask(associations: List): String { - val values = associations.distinct().sortedWith(compareBy { it.alias }.thenBy { it.directory }) + val values = + associations + .distinct() + .sortedWith(compareBy { it.alias }.thenBy { it.directory }) return values.joinToString(separator = "\n", postfix = if (values.isEmpty()) "" else "\n") { association -> "${association.alias} = ${association.directory}" @@ -109,7 +112,7 @@ object PassSecretsMapStore { synchronized(lock) { val identityKey = identity.key() val loaded = loadedMaps[identityKey] ?: return null - if (loaded.version != mapFile.version()) { + if (loaded.version != mapFile.fileVersion()) { loadedMaps.remove(identityKey) gatedVersions.remove(identityKey) return null @@ -121,8 +124,8 @@ object PassSecretsMapStore { /** Resolve any `.mask.gpg` aliases applying to this password's physical directory. */ fun aliases(file: File, repositoryRoot: File): List { if (!isPasswordFile(file)) return emptyList() - val identity = findNearestIdentity(file.parentFile ?: return emptyList(), repositoryRoot) - ?: return emptyList() + val identity = + findNearestIdentity(file.parentFile ?: return emptyList(), repositoryRoot) ?: return emptyList() val maskFile = File(identity, MASK_FILE_NAME) if (!maskFile.isFile) return emptyList() val relativeDirectory = @@ -139,7 +142,7 @@ object PassSecretsMapStore { synchronized(lock) { val identityKey = identity.key() val loaded = loadedMasks[identityKey] ?: return emptyList() - if (loaded.version != maskFile.version()) { + if (loaded.version != maskFile.fileVersion()) { loadedMasks.remove(identityKey) gatedVersions.remove(identityKey) return emptyList() @@ -167,7 +170,7 @@ object PassSecretsMapStore { synchronized(lock) { val identityKey = identity.key() - val version = identity.version() + val version = identity.metadataVersion() if (isCurrent(identityKey, metadata)) return null val gatedVersion = gatedVersions[identityKey] @@ -198,8 +201,8 @@ object PassSecretsMapStore { val identity = metadataFile.parentFile ?: return false synchronized(lock) { return when (metadataFile.name) { - MAP_FILE_NAME -> loadedMaps[identity.key()]?.version == metadataFile.version() - MASK_FILE_NAME -> loadedMasks[identity.key()]?.version == metadataFile.version() + MAP_FILE_NAME -> loadedMaps[identity.key()]?.version == metadataFile.fileVersion() + MASK_FILE_NAME -> loadedMasks[identity.key()]?.version == metadataFile.fileVersion() else -> false } } @@ -209,7 +212,7 @@ object PassSecretsMapStore { fun putMap(mapFile: File, values: Map) { val identity = mapFile.parentFile ?: return synchronized(lock) { - loadedMaps[identity.key()] = LoadedMap(mapFile.version(), values.toMap()) + loadedMaps[identity.key()] = LoadedMap(mapFile.fileVersion(), values.toMap()) gatedVersions.remove(identity.key()) } } @@ -218,7 +221,7 @@ object PassSecretsMapStore { fun putMask(maskFile: File, associations: List) { val identity = maskFile.parentFile ?: return synchronized(lock) { - loadedMasks[identity.key()] = LoadedMask(maskFile.version(), associations.toList()) + loadedMasks[identity.key()] = LoadedMask(maskFile.fileVersion(), associations.toList()) gatedVersions.remove(identity.key()) } } @@ -231,7 +234,7 @@ object PassSecretsMapStore { val identity = mapFile.parentFile ?: return null synchronized(lock) { val loaded = loadedMaps[identity.key()] ?: return null - if (loaded.version != mapFile.version()) return null + if (loaded.version != mapFile.fileVersion()) return null return loaded.values.toMap() } } @@ -241,7 +244,7 @@ object PassSecretsMapStore { val identity = maskFile.parentFile ?: return null synchronized(lock) { val loaded = loadedMasks[identity.key()] ?: return null - if (loaded.version != maskFile.version()) return null + if (loaded.version != maskFile.fileVersion()) return null return loaded.associations.toList() } } @@ -249,7 +252,7 @@ object PassSecretsMapStore { /** Suppress repeated automatic prompts after cancellation/failure for this metadata version. */ fun skip(metadataFile: File) { val identity = metadataFile.parentFile ?: return - synchronized(lock) { gatedVersions[identity.key()] = identity.version() } + synchronized(lock) { gatedVersions[identity.key()] = identity.metadataVersion() } } /** Forget all decrypted labels, aliases and prompt gates, e.g. when the screen locks. */ @@ -371,15 +374,13 @@ object PassSecretsMapStore { if (source.isFile) { if (!isPasswordFile(source)) return false val sourceIdentity = findNearestIdentity(source.parentFile ?: return false, repositoryRoot) - val targetIdentity = - findNearestIdentity(destination.parentFile ?: return false, repositoryRoot) + val targetIdentity = findNearestIdentity(destination.parentFile ?: return false, repositoryRoot) return !sameIdentity(sourceIdentity, targetIdentity) } if (!source.isDirectory || File(source, GPG_ID_FILE_NAME).isFile) return false val sourceIdentity = findNearestIdentity(source, repositoryRoot) - val targetIdentity = - findNearestIdentity(destination.parentFile ?: return false, repositoryRoot) + val targetIdentity = findNearestIdentity(destination.parentFile ?: return false, repositoryRoot) if (sameIdentity(sourceIdentity, targetIdentity)) return false return source @@ -392,9 +393,9 @@ object PassSecretsMapStore { private fun isCurrent(identityKey: String, metadata: MetadataFiles): Boolean { val mapCurrent = - metadata.mapFile == null || loadedMaps[identityKey]?.version == metadata.mapFile.version() + metadata.mapFile == null || loadedMaps[identityKey]?.version == metadata.mapFile.fileVersion() val maskCurrent = - metadata.maskFile == null || loadedMasks[identityKey]?.version == metadata.maskFile.version() + metadata.maskFile == null || loadedMasks[identityKey]?.version == metadata.maskFile.fileVersion() return mapCurrent && maskCurrent } @@ -448,12 +449,12 @@ object PassSecretsMapStore { return MetadataFiles(map, mask) } - private fun File.version() = FileVersion(lastModified = lastModified(), length = length()) + private fun File.fileVersion() = FileVersion(lastModified = lastModified(), length = length()) - private fun File.version(): IdentityVersion = + private fun File.metadataVersion(): IdentityVersion = IdentityVersion( - map = File(this, MAP_FILE_NAME).takeIf { it.isFile }?.version(), - mask = File(this, MASK_FILE_NAME).takeIf { it.isFile }?.version(), + map = File(this, MAP_FILE_NAME).takeIf { it.isFile }?.fileVersion(), + mask = File(this, MASK_FILE_NAME).takeIf { it.isFile }?.fileVersion(), ) private fun File.key(): String = From c072611f2a8bba74cbe2f583a4ba6c4c6ac2a8ac Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:48:14 +0200 Subject: [PATCH 19/31] feat: make Pass-Secrets password writes transactional --- .../passsecrets/PassSecretsMutationService.kt | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt new file mode 100644 index 0000000000..32fd4b2cd7 --- /dev/null +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt @@ -0,0 +1,291 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.passsecrets + +import app.passwordstore.passsecrets.PassSecretsMapWriter.Update +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.extensions.wipe +import dagger.Reusable +import java.io.File +import java.io.IOException +import javax.inject.Inject +import kotlinx.coroutines.withContext + +/** Coordinates physical password mutations with their encrypted Pass-Secrets metadata. */ +@Reusable +class PassSecretsMutationService +@Inject +constructor( + private val writer: PassSecretsMapWriter, + private val dispatcherProvider: DispatcherProvider, +) { + + class MetadataLockedException(val metadataFile: File) : + IllegalStateException("Pass-Secrets metadata must be unlocked first: $metadataFile") + + class ReencryptionRequiredException(source: File, destination: File) : + IllegalStateException( + "Moving $source to $destination crosses a .gpg-id boundary and requires re-encryption" + ) + + data class PasswordWritePlan( + val sourceFile: File?, + val destinationFile: File, + val logicalName: String, + val mappedDestination: Boolean, + val updates: List, + val rollbackUpdates: List, + ) + + data class FileMovePlan( + val source: File, + val destination: File, + val updates: List, + val rollbackUpdates: List, + ) + + fun requiredMetadataForPasswordWrite( + sourceFile: File?, + destinationDirectory: File, + repositoryRoot: File, + ): List { + val sourceMap = + sourceFile?.parentFile?.let { PassSecretsMapStore.mapFileForDirectory(it, repositoryRoot) } + val targetMap = PassSecretsMapStore.mapFileForDirectory(destinationDirectory, repositoryRoot) + return listOfNotNull(sourceMap, targetMap) + .distinctBy { it.canonicalPath } + .filterNot(PassSecretsMapStore::isLoaded) + } + + fun planPasswordWrite( + sourceFile: File?, + destinationDirectory: File, + requestedName: String, + repositoryRoot: File, + ): PasswordWritePlan { + require(destinationDirectory.isDirectory) { "Password destination is not a directory" } + require(requestedName.isNotBlank()) { "Password name must not be blank" } + + val sourceMapFile = + sourceFile?.parentFile?.let { PassSecretsMapStore.mapFileForDirectory(it, repositoryRoot) } + val destinationMapFile = + PassSecretsMapStore.mapFileForDirectory(destinationDirectory, repositoryRoot) + + sourceMapFile?.requireLoaded() + destinationMapFile?.requireLoaded() + + val sameMap = + sourceMapFile != null && + destinationMapFile != null && + sourceMapFile.canonicalPath == destinationMapFile.canonicalPath + + val physicalName = + if (destinationMapFile != null) { + if (sourceFile != null && sameMap) sourceFile.nameWithoutExtension + else PassSecretsMapStore.generateCodename(destinationDirectory) + } else { + require('/' !in requestedName && '\\' !in requestedName) { + "A normal pass filename cannot contain path separators" + } + requestedName + } + + val destinationFile = File(destinationDirectory, "$physicalName.gpg") + if ( + destinationFile.exists() && + (sourceFile == null || destinationFile.canonicalPath != sourceFile.canonicalPath) + ) { + throw IOException("Password destination already exists: $destinationFile") + } + + val oldMaps = linkedMapOf>>() + listOfNotNull(sourceMapFile, destinationMapFile).forEach { mapFile -> + val snapshot = mapFile.requireMapSnapshot() + oldMaps[mapFile.canonicalPath] = mapFile to snapshot + } + val newMaps = oldMaps.mapValuesTo(linkedMapOf()) { (_, fileAndValues) -> fileAndValues.second } + + if (sourceFile != null && sourceMapFile != null) { + val sourceIdentity = requireNotNull(sourceMapFile.parentFile) + val sourceKey = + requireNotNull(PassSecretsMapStore.passwordRelativePath(sourceFile, sourceIdentity)) + newMaps[sourceMapFile.canonicalPath] = + PassSecretsMapStore.mapAfterDelete( + newMaps.getValue(sourceMapFile.canonicalPath), + sourceKey, + isDirectory = false, + ) + } + + if (destinationMapFile != null) { + val destinationIdentity = requireNotNull(destinationMapFile.parentFile) + val destinationKey = + requireNotNull( + PassSecretsMapStore.passwordRelativePath(destinationFileForLookup(destinationFile), destinationIdentity) + ) + newMaps[destinationMapFile.canonicalPath] = + newMaps.getValue(destinationMapFile.canonicalPath) + (destinationKey to requestedName) + } + + val updates = + oldMaps.mapNotNull { (key, fileAndValues) -> + val (file, oldValues) = fileAndValues + val newValues = newMaps.getValue(key) + if (oldValues == newValues) null else Update.Secrets(file, newValues) + } + val rollbackUpdates = + oldMaps.mapNotNull { (key, fileAndValues) -> + val (file, oldValues) = fileAndValues + if (oldValues == newMaps.getValue(key)) null else Update.Secrets(file, oldValues) + } + + return PasswordWritePlan( + sourceFile = sourceFile, + destinationFile = destinationFile, + logicalName = requestedName, + mappedDestination = destinationMapFile != null, + updates = updates, + rollbackUpdates = rollbackUpdates, + ) + } + + suspend fun commitPasswordWrite(plan: PasswordWritePlan, encryptedBytes: ByteArray) { + withContext(dispatcherProvider.io()) { + val source = plan.sourceFile + val destination = plan.destinationFile + val sameFile = source?.canonicalPath == destination.canonicalPath + val originalDestination = if (sameFile && destination.isFile) destination.readBytes() else null + + try { + destination.parentFile?.mkdirs() + destination.writeBytes(encryptedBytes) + writer.persist(plan.updates) + + if (!sameFile && source != null && source.exists() && !source.delete()) { + try { + writer.persist(plan.rollbackUpdates) + } finally { + destination.delete() + } + throw IOException("Could not remove the old password after a mapped move: $source") + } + } catch (error: Throwable) { + if (sameFile && originalDestination != null) { + destination.writeBytes(originalDestination) + } else if (!sameFile) { + destination.delete() + } + throw error + } finally { + originalDestination?.wipe() + } + } + } + + fun requiredMetadataForMove(source: File, destination: File, repositoryRoot: File): List { + if (PassSecretsMapStore.moveRequiresReencryption(source, destination, repositoryRoot)) { + throw ReencryptionRequiredException(source, destination) + } + val ownerDirectory = source.parentFile ?: return emptyList() + val identity = PassSecretsMapStore.identityForDirectory(ownerDirectory, repositoryRoot) + ?: return emptyList() + val destinationIdentity = + PassSecretsMapStore.identityForDirectory(destination.parentFile ?: return emptyList(), repositoryRoot) + if (identity.canonicalPath != destinationIdentity?.canonicalPath) return emptyList() + + return PassSecretsMapStore.metadataFilesForDirectory(ownerDirectory, repositoryRoot) + .existingFiles + .filterNot(PassSecretsMapStore::isLoaded) + } + + fun planMove(source: File, destination: File, repositoryRoot: File): FileMovePlan { + if (PassSecretsMapStore.moveRequiresReencryption(source, destination, repositoryRoot)) { + throw ReencryptionRequiredException(source, destination) + } + val sourceParent = source.parentFile ?: return FileMovePlan(source, destination, emptyList(), emptyList()) + val sourceIdentity = PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) + ?: return FileMovePlan(source, destination, emptyList(), emptyList()) + val destinationIdentity = + PassSecretsMapStore.identityForDirectory(destination.parentFile ?: sourceParent, repositoryRoot) + if (sourceIdentity.canonicalPath != destinationIdentity?.canonicalPath) { + // A self-contained nested identity can move without re-encrypting its contents. Its own maps + // are relative to its root and therefore remain unchanged. Parent aliases are intentionally + // not guessed across trust boundaries. + return FileMovePlan(source, destination, emptyList(), emptyList()) + } + + val metadata = PassSecretsMapStore.metadataFilesForDirectory(sourceParent, repositoryRoot) + metadata.existingFiles.forEach(File::requireLoaded) + + val sourceRelative = relativeEntryPath(source, sourceIdentity) + val destinationRelative = relativeEntryPath(destination, sourceIdentity) + val updates = mutableListOf() + val rollback = mutableListOf() + + metadata.mapFile?.let { mapFile -> + val old = mapFile.requireMapSnapshot() + val new = PassSecretsMapStore.mapAfterMove(old, sourceRelative, destinationRelative, source.isDirectory) + if (new != old) { + updates += Update.Secrets(mapFile, new) + rollback += Update.Secrets(mapFile, old) + } + } + metadata.maskFile?.let { maskFile -> + val old = maskFile.requireMaskSnapshot() + val new = + if (source.isDirectory) + PassSecretsMapStore.maskAfterMove(old, sourceRelative, destinationRelative) + else old + if (new != old) { + updates += Update.Mask(maskFile, new) + rollback += Update.Mask(maskFile, old) + } + } + return FileMovePlan(source, destination, updates, rollback) + } + + suspend fun commitMove(plan: FileMovePlan) { + withContext(dispatcherProvider.io()) { + if (!plan.source.renameTo(plan.destination)) { + throw IOException("Could not move ${plan.source} to ${plan.destination}") + } + try { + writer.persist(plan.updates) + } catch (error: Throwable) { + if (!plan.destination.renameTo(plan.source)) { + throw IOException( + "Pass-Secrets metadata update failed and filesystem move could not be rolled back", + error, + ) + } + throw error + } + } + } + + private fun File.requireLoaded() { + if (!PassSecretsMapStore.isLoaded(this)) throw MetadataLockedException(this) + } + + private fun File.requireMapSnapshot(): Map { + return PassSecretsMapStore.mapSnapshot(this) ?: throw MetadataLockedException(this) + } + + private fun File.requireMaskSnapshot(): List { + return PassSecretsMapStore.maskSnapshot(this) ?: throw MetadataLockedException(this) + } + + private fun relativeEntryPath(file: File, identity: File): String { + val relative = file.relativeTo(identity).invariantSeparatorsPath + return if (file.isFile || file.name.endsWith(".gpg")) relative.removeSuffix(".gpg") else relative + } + + // `passwordRelativePath` deliberately requires an existing file. During planning the destination + // does not exist yet, so create a lightweight File view whose path is used by the same rules. + private fun destinationFileForLookup(destination: File): File = + object : File(destination.absolutePath) { + override fun isFile(): Boolean = true + } +} From 8b004b863d8ed88c778ebda0282368434e2e2cc1 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:49:17 +0200 Subject: [PATCH 20/31] feat: stage Pass-Secrets deletes and moves safely --- .../passsecrets/PassSecretsMutationService.kt | 169 ++++++++++++++---- 1 file changed, 133 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt index 32fd4b2cd7..1c4b94193f 100644 --- a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt @@ -10,6 +10,7 @@ import app.passwordstore.util.extensions.wipe import dagger.Reusable import java.io.File import java.io.IOException +import java.util.UUID import javax.inject.Inject import kotlinx.coroutines.withContext @@ -30,6 +31,9 @@ constructor( "Moving $source to $destination crosses a .gpg-id boundary and requires re-encryption" ) + class ProtectedIdentityMarkerException(file: File) : + IllegalStateException("Cannot mutate $file independently from its Pass-Secrets identity") + data class PasswordWritePlan( val sourceFile: File?, val destinationFile: File, @@ -46,6 +50,12 @@ constructor( val rollbackUpdates: List, ) + data class DeletePlan( + val targets: List, + val updates: List, + val rollbackUpdates: List, + ) + fun requiredMetadataForPasswordWrite( sourceFile: File?, destinationDirectory: File, @@ -102,15 +112,13 @@ constructor( val oldMaps = linkedMapOf>>() listOfNotNull(sourceMapFile, destinationMapFile).forEach { mapFile -> - val snapshot = mapFile.requireMapSnapshot() - oldMaps[mapFile.canonicalPath] = mapFile to snapshot + oldMaps[mapFile.canonicalPath] = mapFile to mapFile.requireMapSnapshot() } - val newMaps = oldMaps.mapValuesTo(linkedMapOf()) { (_, fileAndValues) -> fileAndValues.second } + val newMaps = oldMaps.mapValuesTo(linkedMapOf()) { (_, pair) -> pair.second } if (sourceFile != null && sourceMapFile != null) { val sourceIdentity = requireNotNull(sourceMapFile.parentFile) - val sourceKey = - requireNotNull(PassSecretsMapStore.passwordRelativePath(sourceFile, sourceIdentity)) + val sourceKey = requireNotNull(PassSecretsMapStore.passwordRelativePath(sourceFile, sourceIdentity)) newMaps[sourceMapFile.canonicalPath] = PassSecretsMapStore.mapAfterDelete( newMaps.getValue(sourceMapFile.canonicalPath), @@ -121,25 +129,21 @@ constructor( if (destinationMapFile != null) { val destinationIdentity = requireNotNull(destinationMapFile.parentFile) - val destinationKey = - requireNotNull( - PassSecretsMapStore.passwordRelativePath(destinationFileForLookup(destinationFile), destinationIdentity) - ) + val destinationKey = passwordRelativePath(destinationFile, destinationIdentity) newMaps[destinationMapFile.canonicalPath] = newMaps.getValue(destinationMapFile.canonicalPath) + (destinationKey to requestedName) } - val updates = - oldMaps.mapNotNull { (key, fileAndValues) -> - val (file, oldValues) = fileAndValues - val newValues = newMaps.getValue(key) - if (oldValues == newValues) null else Update.Secrets(file, newValues) - } - val rollbackUpdates = - oldMaps.mapNotNull { (key, fileAndValues) -> - val (file, oldValues) = fileAndValues - if (oldValues == newMaps.getValue(key)) null else Update.Secrets(file, oldValues) + val updates = mutableListOf() + val rollback = mutableListOf() + oldMaps.forEach { (key, pair) -> + val (file, oldValues) = pair + val newValues = newMaps.getValue(key) + if (oldValues != newValues) { + updates += Update.Secrets(file, newValues) + rollback += Update.Secrets(file, oldValues) } + } return PasswordWritePlan( sourceFile = sourceFile, @@ -147,7 +151,7 @@ constructor( logicalName = requestedName, mappedDestination = destinationMapFile != null, updates = updates, - rollbackUpdates = rollbackUpdates, + rollbackUpdates = rollback, ) } @@ -185,26 +189,30 @@ constructor( } fun requiredMetadataForMove(source: File, destination: File, repositoryRoot: File): List { + if (PassSecretsMapStore.isProtectedIdentityMarker(source)) { + throw ProtectedIdentityMarkerException(source) + } if (PassSecretsMapStore.moveRequiresReencryption(source, destination, repositoryRoot)) { throw ReencryptionRequiredException(source, destination) } - val ownerDirectory = source.parentFile ?: return emptyList() - val identity = PassSecretsMapStore.identityForDirectory(ownerDirectory, repositoryRoot) + val sourceParent = source.parentFile ?: return emptyList() + val sourceIdentity = PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) ?: return emptyList() val destinationIdentity = PassSecretsMapStore.identityForDirectory(destination.parentFile ?: return emptyList(), repositoryRoot) - if (identity.canonicalPath != destinationIdentity?.canonicalPath) return emptyList() + if (sourceIdentity.canonicalPath != destinationIdentity?.canonicalPath) return emptyList() - return PassSecretsMapStore.metadataFilesForDirectory(ownerDirectory, repositoryRoot) + return PassSecretsMapStore.metadataFilesForDirectory(sourceParent, repositoryRoot) .existingFiles .filterNot(PassSecretsMapStore::isLoaded) } fun planMove(source: File, destination: File, repositoryRoot: File): FileMovePlan { - if (PassSecretsMapStore.moveRequiresReencryption(source, destination, repositoryRoot)) { - throw ReencryptionRequiredException(source, destination) + requiredMetadataForMove(source, destination, repositoryRoot).firstOrNull()?.let { + throw MetadataLockedException(it) } - val sourceParent = source.parentFile ?: return FileMovePlan(source, destination, emptyList(), emptyList()) + val sourceParent = source.parentFile + ?: return FileMovePlan(source, destination, emptyList(), emptyList()) val sourceIdentity = PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) ?: return FileMovePlan(source, destination, emptyList(), emptyList()) val destinationIdentity = @@ -217,8 +225,6 @@ constructor( } val metadata = PassSecretsMapStore.metadataFilesForDirectory(sourceParent, repositoryRoot) - metadata.existingFiles.forEach(File::requireLoaded) - val sourceRelative = relativeEntryPath(source, sourceIdentity) val destinationRelative = relativeEntryPath(destination, sourceIdentity) val updates = mutableListOf() @@ -226,7 +232,8 @@ constructor( metadata.mapFile?.let { mapFile -> val old = mapFile.requireMapSnapshot() - val new = PassSecretsMapStore.mapAfterMove(old, sourceRelative, destinationRelative, source.isDirectory) + val new = + PassSecretsMapStore.mapAfterMove(old, sourceRelative, destinationRelative, source.isDirectory) if (new != old) { updates += Update.Secrets(mapFile, new) rollback += Update.Secrets(mapFile, old) @@ -265,6 +272,99 @@ constructor( } } + fun requiredMetadataForDelete(targets: List, repositoryRoot: File): List { + val metadata = linkedMapOf() + targets.forEach { target -> + if (PassSecretsMapStore.isProtectedIdentityMarker(target)) { + throw ProtectedIdentityMarkerException(target) + } + val parent = target.parentFile ?: return@forEach + PassSecretsMapStore.metadataFilesForDirectory(parent, repositoryRoot) + .existingFiles + .forEach { file -> metadata[file.canonicalPath] = file } + } + return metadata.values.filterNot(PassSecretsMapStore::isLoaded) + } + + fun planDelete(targets: List, repositoryRoot: File): DeletePlan { + requiredMetadataForDelete(targets, repositoryRoot).firstOrNull()?.let { + throw MetadataLockedException(it) + } + val oldUpdates = linkedMapOf() + val newUpdates = linkedMapOf() + + targets.forEach { target -> + val parent = target.parentFile ?: return@forEach + val identity = PassSecretsMapStore.identityForDirectory(parent, repositoryRoot) ?: return@forEach + val relativePath = relativeEntryPath(target, identity) + val metadata = PassSecretsMapStore.metadataFilesForDirectory(parent, repositoryRoot) + + metadata.mapFile?.let { mapFile -> + val key = mapFile.canonicalPath + val old = + (oldUpdates[key] as? Update.Secrets)?.values ?: mapFile.requireMapSnapshot().also { + oldUpdates[key] = Update.Secrets(mapFile, it) + } + val current = (newUpdates[key] as? Update.Secrets)?.values ?: old + newUpdates[key] = + Update.Secrets( + mapFile, + PassSecretsMapStore.mapAfterDelete(current, relativePath, target.isDirectory), + ) + } + metadata.maskFile?.let { maskFile -> + val key = maskFile.canonicalPath + val old = + (oldUpdates[key] as? Update.Mask)?.associations ?: maskFile.requireMaskSnapshot().also { + oldUpdates[key] = Update.Mask(maskFile, it) + } + val current = (newUpdates[key] as? Update.Mask)?.associations ?: old + newUpdates[key] = + Update.Mask( + maskFile, + PassSecretsMapStore.maskAfterDelete(current, relativePath, target.isDirectory), + ) + } + } + + val updates = + newUpdates.filter { (key, value) -> value != oldUpdates[key] }.values.toList() + val rollback = + oldUpdates.filter { (key, value) -> value != newUpdates[key] }.values.toList() + return DeletePlan(targets.distinctBy { it.canonicalPath }, updates, rollback) + } + + suspend fun commitDelete(plan: DeletePlan) { + withContext(dispatcherProvider.io()) { + val staged = mutableListOf>() + try { + plan.targets.forEach { target -> + if (!target.exists()) return@forEach + val parent = requireNotNull(target.parentFile) + val temporary = File(parent, ".aps-delete-${UUID.randomUUID()}-${target.name}") + if (!target.renameTo(temporary)) { + throw IOException("Could not stage $target for deletion") + } + staged += target to temporary + } + + writer.persist(plan.updates) + staged.forEach { (_, temporary) -> + if (!temporary.deleteRecursively()) { + throw IOException("Could not remove staged deleted entry: $temporary") + } + } + } catch (error: Throwable) { + val metadataCommitted = staged.any { (_, temporary) -> !temporary.exists() } + if (metadataCommitted) runCatching { writer.persist(plan.rollbackUpdates) } + staged.asReversed().forEach { (original, temporary) -> + if (temporary.exists()) temporary.renameTo(original) + } + throw error + } + } + } + private fun File.requireLoaded() { if (!PassSecretsMapStore.isLoaded(this)) throw MetadataLockedException(this) } @@ -282,10 +382,7 @@ constructor( return if (file.isFile || file.name.endsWith(".gpg")) relative.removeSuffix(".gpg") else relative } - // `passwordRelativePath` deliberately requires an existing file. During planning the destination - // does not exist yet, so create a lightweight File view whose path is used by the same rules. - private fun destinationFileForLookup(destination: File): File = - object : File(destination.absolutePath) { - override fun isFile(): Boolean = true - } + private fun passwordRelativePath(file: File, identity: File): String { + return file.relativeTo(identity).invariantSeparatorsPath.removeSuffix(".gpg") + } } From 2a896ff19f4d2c58621b45a460d1a0edc576e201 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:50:53 +0200 Subject: [PATCH 21/31] feat: make password create and edit Pass-Secrets aware --- .../ui/crypto/PasswordCreationActivity.kt | 265 ++++++++++-------- 1 file changed, 150 insertions(+), 115 deletions(-) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt index eed4e0d722..47f4452751 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt @@ -32,6 +32,8 @@ import app.passwordstore.data.passfile.splitToCharArrayListAt import app.passwordstore.data.passfile.trimEnd import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.databinding.PasswordCreationActivityBinding +import app.passwordstore.passsecrets.PassSecretsMapStore +import app.passwordstore.passsecrets.PassSecretsMutationService import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment import app.passwordstore.ui.dialogs.OtpImportDialogFragment import app.passwordstore.ui.dialogs.PasswordGeneratorDialogFragment @@ -68,15 +70,14 @@ import com.google.zxing.qrcode.QRCodeReader import dagger.hilt.android.AndroidEntryPoint import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.File import java.io.IOException import java.nio.CharBuffer import java.nio.file.Paths import javax.inject.Inject -import kotlin.io.path.absolutePathString import kotlin.io.path.createDirectories import kotlin.io.path.exists import kotlin.io.path.pathString -import kotlin.io.path.writeBytes import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import logcat.LogPriority.ERROR @@ -88,14 +89,30 @@ class PasswordCreationActivity : BasePGPActivity() { private val binding by viewBinding(PasswordCreationActivityBinding::inflate) @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + @Inject lateinit var passSecretsMutationService: PassSecretsMutationService private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } + private val suggestedPhysicalName by unsafeLazy { + intent.getStringExtra(EXTRA_PHYSICAL_FILE_NAME) ?: suggestedName + } private val suggestedEntryChars by unsafeLazy { intent.getCharArrayExtra(EXTRA_ENTRY) } private val shouldGeneratePassword by unsafeLazy { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) } private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) } private var copy: Boolean = false + private var pendingSave = false + + private val passSecretsUnlockAction = + registerForActivityResult(StartActivityForResult()) { result -> + if (!pendingSave) return@registerForActivityResult + if (result.resultCode == RESULT_OK) { + updateMappedSuggestedNameAfterUnlock() + continueSave() + } else { + pendingSave = false + } + } private val otpImportAction = registerForActivityResult(StartActivityForResult()) { result -> @@ -128,7 +145,6 @@ class PasswordCreationActivity : BasePGPActivity() { @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri) } val intArray = IntArray(bitmap.width * bitmap.height) - // copy pixel data from the Bitmap into the 'intArray' array bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) @@ -260,9 +276,6 @@ class PasswordCreationActivity : BasePGPActivity() { } else if (suggestedName != null) username.requestFocus() } - // Allow the user to quickly switch between storing the username as the filename or - // in the encrypted extras. This only makes sense if the directory structure is - // FileBased. if ( suggestedName == null && AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == @@ -272,15 +285,11 @@ class PasswordCreationActivity : BasePGPActivity() { visibility = View.VISIBLE setOnClickListener { if (isChecked) { - // User wants to enable username encryption, so we use the filename - // as username and insert it into the username input field. val login = filename.text.toString() filename.text?.clear() username.setText(login) usernameInputLayout.apply { visibility = View.VISIBLE } } else { - // User wants to disable username encryption, so we take the username - // from the username text field and insert it into the filename input field. val login = username.text.toString() username.text?.clear() filename.setText(login) @@ -325,23 +334,62 @@ class PasswordCreationActivity : BasePGPActivity() { setResult(RESULT_CANCELED) onBackPressedDispatcher.onBackPressed() } - R.id.save_password -> { - copy = false - requireKeysExist { - requireEncryptionKeysExist(binding.directory.text.toString()) { ids -> encrypt(ids) } - } - } - R.id.save_and_copy_password -> { - copy = true - requireKeysExist { - requireEncryptionKeysExist(binding.directory.text.toString()) { ids -> encrypt(ids) } - } - } + R.id.save_password -> beginSave(copyPassword = false) + R.id.save_and_copy_password -> beginSave(copyPassword = true) else -> return super.onOptionsItemSelected(item) } return true } + private fun beginSave(copyPassword: Boolean) { + copy = copyPassword + pendingSave = true + continueSave() + } + + private fun continueSave() { + if (!pendingSave) return + val repositoryRoot = File(repoPath) + val destinationDirectory = selectedDirectory() + val sourceFile = sourcePasswordFile() + val requiredMetadata = + passSecretsMutationService.requiredMetadataForPasswordWrite( + sourceFile, + destinationDirectory, + repositoryRoot, + ) + val nextMetadata = requiredMetadata.firstOrNull() + if (nextMetadata != null) { + passSecretsUnlockAction.launch( + PassSecretsMapUnlockActivity.newIntent(this, nextMetadata, repositoryRoot) + ) + return + } + + pendingSave = false + requireKeysExist { + requireEncryptionKeysExist(binding.directory.text.toString()) { ids -> encrypt(ids) } + } + } + + private fun updateMappedSuggestedNameAfterUnlock() { + if (!editing) return + val source = sourcePasswordFile() ?: return + val mappedName = PassSecretsMapStore.mappedName(source, File(repoPath)) ?: return + if (binding.filename.text?.toString() == suggestedName) binding.filename.setText(mappedName) + } + + private fun sourcePasswordFile(): File? { + if (!editing) return null + val physicalName = suggestedPhysicalName ?: return null + return File(fullPath.trimEnd('/'), "$physicalName.gpg") + } + + private fun selectedDirectory(): File { + val relative = binding.directory.text.toString().trim().trim('/') + return File(repoPath, relative) + } + private fun generatePassword() { supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { requestKey, @@ -367,14 +415,13 @@ class PasswordCreationActivity : BasePGPActivity() { isEnabled = hasUsernameInFileName xor usernameIsEncrypted isChecked = usernameIsEncrypted } - // Use PasswordEntry to parse extras for OTP val entry = passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".toCharArray()) val hasTotp = entry.hasTotp() entry.clear() otpImportButton.isVisible = !hasTotp } - /** Encrypts the password and the extra content */ + /** Encrypts the password and extra content, then atomically updates Pass-Secrets metadata. */ private fun encrypt(identifiers: List) { with(binding) { val editName = filename.text.toString().trim() @@ -382,11 +429,14 @@ class PasswordCreationActivity : BasePGPActivity() { val editPass = password.text?.let { CharArray(it.length) { i -> it[i] } } ?: charArrayOf() var editExtra = extraContent.text?.let { CharArray(it.length) { i -> it[i] } } ?: charArrayOf() + val destinationDirectory = selectedDirectory() + val mappedDestination = + PassSecretsMapStore.mapFileForDirectory(destinationDirectory, File(repoPath)) != null if (editName.isEmpty()) { snackbar(message = resources.getString(R.string.file_toast_text)) return@with - } else if (editName.contains('/')) { + } else if (!mappedDestination && (editName.contains('/') || editName.contains('\\'))) { snackbar(message = resources.getString(R.string.invalid_filename_text)) return@with } @@ -404,15 +454,14 @@ class PasswordCreationActivity : BasePGPActivity() { return@with } - // fix extra content formatting if (!editExtra.isEmpty()) { editExtra = editExtra.let { - val extraLines = it.splitToCharArrayListAt('\n').map { it.trimEnd() } - it?.wipe() - val editExtra = extraLines.joinToCharArray('\n')?.trimEnd() - val editExtraPlusLineFeed = editExtra?.let { it + '\n' } - editExtra?.wipe() - editExtraPlusLineFeed ?: charArrayOf() + val extraLines = it.splitToCharArrayListAt('\n').map { line -> line.trimEnd() } + it.wipe() + val trimmed = extraLines.joinToCharArray('\n')?.trimEnd() + val withLineFeed = trimmed?.let { value -> value + '\n' } + trimmed?.wipe() + withLineFeed ?: charArrayOf() } } @@ -421,25 +470,27 @@ class PasswordCreationActivity : BasePGPActivity() { clearTimer = copyPasswordToClipboard(editPass.copyOf(editPass.size)) } - // pass enters the key ID into `.gpg-id`. - val gpgIdentifiers = getPGPIdentifiers(directory.text.toString()) - if (gpgIdentifiers.isNullOrEmpty()) return@with - - val path = run { // password item's full file path string - val editRelativePath = directory.text.toString().trim() - val passwordDirectory = Paths.get(repoPath, editRelativePath.trim('/')) - passwordDirectory.createDirectories() // ensure destination dir exists - if (!passwordDirectory.exists()) { // should not happen - snackbar(message = "Failed to create directory ${editRelativePath.trimEnd('/')}") - return - } - - "${passwordDirectory.pathString}/$editName.gpg" + destinationDirectory.toPath().createDirectories() + if (!destinationDirectory.toPath().exists()) { + snackbar(message = "Failed to create directory ${destinationDirectory.path}") + return@with } lifecycleScope.launch(dispatcherProvider.main()) { runCatching { - val contentChars = (editPass + editUsername + '\n' + editExtra) + val plan = + passSecretsMutationService.planPasswordWrite( + sourcePasswordFile(), + destinationDirectory, + editName, + File(repoPath), + ) + if (!plan.destinationFile.isInsideRepository()) { + snackbar(message = getString(R.string.message_error_destination_outside_repo)) + return@runCatching + } + + val contentChars = editPass + editUsername + '\n' + editExtra val contentBytes = contentChars.toByteArray() contentChars.wipe() @@ -456,7 +507,6 @@ class PasswordCreationActivity : BasePGPActivity() { if (result.isErr) throw result.unwrapError() if (succeededUserEmails.isNullOrEmpty()) throw UnusableKeyException - var unknownKeyCount = 0 val failedUserEmails = identifiers .map { id -> @@ -469,51 +519,32 @@ class PasswordCreationActivity : BasePGPActivity() { } } .distinct() - .filter { it !in succeededUserEmails ?: emptyList() } - - val passwordFile = Paths.get(path) - // If we're not editing, this file should not already exist! - // Additionally, if we were editing and the incoming and outgoing - // file paths differ, it means we renamed. Ensure that the target - // doesn't already exist to prevent an accidental overwrite. - if ( - (!editing || - (editing && - "${fullPath.trimEnd('/')}/$suggestedName.gpg" != - passwordFile.absolutePathString())) && passwordFile.exists() - ) { - snackbar(message = getString(R.string.password_creation_duplicate_error)) - return@runCatching + .filter { it !in succeededUserEmails } + + val encryptedOutput = result.getOrThrow() + val encryptedBytes = encryptedOutput.toByteArray() + encryptedOutput.wipe() + try { + passSecretsMutationService.commitPasswordWrite(plan, encryptedBytes) + } finally { + encryptedBytes.wipe() } - if (!passwordFile.toFile().isInsideRepository()) { - snackbar(message = getString(R.string.message_error_destination_outside_repo)) - return@runCatching - } - - withContext(dispatcherProvider.io()) { - passwordFile.writeBytes(result.getOrThrow().toByteArray()) - } - - // create/update timestamp on the current password file val preference = getSharedPreferences("recent_password_history", 0) preference.edit { - suggestedName?.let { oldFile -> - val oldFilePathHash = "${fullPath.trimEnd('/')}/$oldFile.gpg".base64() - remove(oldFilePathHash) - } + sourcePasswordFile()?.let { oldFile -> remove(oldFile.absolutePath.base64()) } putString( - passwordFile.absolutePathString().base64(), + plan.destinationFile.absolutePath.base64(), System.currentTimeMillis().toString(), ) } val returnIntent = Intent() - returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) + returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, plan.destinationFile.absolutePath) returnIntent.putExtra(RETURN_EXTRA_NAME, editName) returnIntent.putExtra( RETURN_EXTRA_LONG_NAME, - PasswordRepository.getLongName(fullPath, repoPath, editName), + PasswordRepository.getLongName(destinationDirectory.absolutePath, repoPath, editName), ) if (shouldGeneratePassword) { @@ -527,57 +558,60 @@ class PasswordCreationActivity : BasePGPActivity() { val username = entry.username?.let { it.copyOf(it.size) } - ?: directoryStructure.getUsernameFor(passwordFile.toFile()) + ?: directoryStructure.getUsernameFor(plan.destinationFile) returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) entry.clear() } - editPass?.wipe() - editUsername?.wipe() - editExtra?.wipe() + editPass.wipe() + editUsername.wipe() + editExtra.wipe() val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text + val physicalLongName = + PasswordRepository.getLongName( + destinationDirectory.absolutePath, + repoPath, + plan.destinationFile.nameWithoutExtension, + ) lifecycleScope.launch { - commitChange( - resources.getString( - commitMessageRes, - PasswordRepository.getLongName(fullPath, repoPath, editName), + commitChange(resources.getString(commitMessageRes, physicalLongName)).onOk { + setResult(RESULT_OK, returnIntent) + val dialog = + MaterialAlertDialogBuilder(this@PasswordCreationActivity) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + var messageText = + getString( + R.string.password_creation_file_encryption_succeeded_ids_message, + succeededUserEmails.joinToString(), ) - ) - .onOk { - setResult(RESULT_OK, returnIntent) - val dialog = - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - var messageText = + if (failedUserEmails.isNotEmpty()) { + dialog.setTitle(R.string.password_creation_file_encryption_partial_success_title) + messageText += getString( - R.string.password_creation_file_encryption_succeeded_ids_message, - succeededUserEmails.joinToString(), + R.string.password_creation_file_encryption_failed_ids_message, + failedUserEmails.joinToString(), ) - if (!failedUserEmails.isEmpty()) { - dialog.setTitle(R.string.password_creation_file_encryption_partial_success_title) - messageText += - getString( - R.string.password_creation_file_encryption_failed_ids_message, - failedUserEmails.joinToString(), - ) - } else { - val title = - if (editing) - getString(R.string.password_creation_edit_file_encryption_success_title) - else getString(R.string.password_creation_new_file_encryption_success_title) - dialog.setTitle(title) - } - dialog.setMessage(messageText) - dialog.show() + } else { + val title = + if (editing) + getString(R.string.password_creation_edit_file_encryption_success_title) + else getString(R.string.password_creation_new_file_encryption_success_title) + dialog.setTitle(title) } + dialog.setMessage(messageText) + dialog.show() + } } } .onErr { e -> logcat(ERROR) { e.asLog() } + editPass.wipe() + editUsername.wipe() + editExtra.wipe() setResult(RESULT_CANCELED) val errMessage = when (e) { @@ -593,7 +627,7 @@ class PasswordCreationActivity : BasePGPActivity() { .setTitle(getString(R.string.error)) .setMessage(errMessage) .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + .setPositiveButton(android.R.string.ok, null) .show() } } @@ -613,6 +647,7 @@ class PasswordCreationActivity : BasePGPActivity() { const val RETURN_EXTRA_USERNAME = "USERNAME" const val RETURN_EXTRA_PASSWORD = "PASSWORD" const val EXTRA_FILE_NAME = "EXTRA_FILENAME" + const val EXTRA_PHYSICAL_FILE_NAME = "EXTRA_PHYSICAL_FILENAME" const val EXTRA_ENTRY = "EXTRA_ENTRY" const val EXTRA_GENERATE_PASSWORD = "EXTRA_GENERATE_PASSWORD" const val EXTRA_EDITING = "EXTRA_EDITING" From 971fec3ba67e1b376ffdd10798360594372376d4 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:52:32 +0200 Subject: [PATCH 22/31] feat: support transactional batch Pass-Secrets moves --- .../passsecrets/PassSecretsMutationService.kt | 249 +++++++++++------- 1 file changed, 156 insertions(+), 93 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt index 1c4b94193f..9a4d752071 100644 --- a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt @@ -44,8 +44,7 @@ constructor( ) data class FileMovePlan( - val source: File, - val destination: File, + val moves: List>, val updates: List, val rollbackUpdates: List, ) @@ -56,6 +55,8 @@ constructor( val rollbackUpdates: List, ) + private data class StagedMove(val source: File, val destination: File, val backup: File?) + fun requiredMetadataForPasswordWrite( sourceFile: File?, destinationDirectory: File, @@ -188,93 +189,146 @@ constructor( } } - fun requiredMetadataForMove(source: File, destination: File, repositoryRoot: File): List { - if (PassSecretsMapStore.isProtectedIdentityMarker(source)) { - throw ProtectedIdentityMarkerException(source) - } - if (PassSecretsMapStore.moveRequiresReencryption(source, destination, repositoryRoot)) { - throw ReencryptionRequiredException(source, destination) + fun requiredMetadataForMoves( + moves: List>, + repositoryRoot: File, + ): List { + val metadata = linkedMapOf() + moves.forEach { (source, destination) -> + if (PassSecretsMapStore.isProtectedIdentityMarker(source)) { + throw ProtectedIdentityMarkerException(source) + } + if (PassSecretsMapStore.moveRequiresReencryption(source, destination, repositoryRoot)) { + throw ReencryptionRequiredException(source, destination) + } + val sourceParent = source.parentFile ?: return@forEach + val sourceIdentity = PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) + ?: return@forEach + val destinationIdentity = + PassSecretsMapStore.identityForDirectory(destination.parentFile ?: return@forEach, repositoryRoot) + if (sourceIdentity.canonicalPath != destinationIdentity?.canonicalPath) { + // Moving an entire nested identity is safe cryptographically because its .gpg-id moves + // with it, but parent Pass-Secrets metadata could reference that directory. Refuse when + // such parent metadata exists rather than silently leaving stale associations. + val parentMetadata = + PassSecretsMapStore.metadataFilesForDirectory(sourceParent, repositoryRoot).existingFiles + if (parentMetadata.isNotEmpty()) throw ReencryptionRequiredException(source, destination) + return@forEach + } + PassSecretsMapStore.metadataFilesForDirectory(sourceParent, repositoryRoot) + .existingFiles + .forEach { file -> metadata[file.canonicalPath] = file } } - val sourceParent = source.parentFile ?: return emptyList() - val sourceIdentity = PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) - ?: return emptyList() - val destinationIdentity = - PassSecretsMapStore.identityForDirectory(destination.parentFile ?: return emptyList(), repositoryRoot) - if (sourceIdentity.canonicalPath != destinationIdentity?.canonicalPath) return emptyList() - - return PassSecretsMapStore.metadataFilesForDirectory(sourceParent, repositoryRoot) - .existingFiles - .filterNot(PassSecretsMapStore::isLoaded) + return metadata.values.filterNot(PassSecretsMapStore::isLoaded) } - fun planMove(source: File, destination: File, repositoryRoot: File): FileMovePlan { - requiredMetadataForMove(source, destination, repositoryRoot).firstOrNull()?.let { + fun requiredMetadataForMove(source: File, destination: File, repositoryRoot: File): List = + requiredMetadataForMoves(listOf(source to destination), repositoryRoot) + + fun planMoves(moves: List>, repositoryRoot: File): FileMovePlan { + requiredMetadataForMoves(moves, repositoryRoot).firstOrNull()?.let { throw MetadataLockedException(it) } - val sourceParent = source.parentFile - ?: return FileMovePlan(source, destination, emptyList(), emptyList()) - val sourceIdentity = PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) - ?: return FileMovePlan(source, destination, emptyList(), emptyList()) - val destinationIdentity = - PassSecretsMapStore.identityForDirectory(destination.parentFile ?: sourceParent, repositoryRoot) - if (sourceIdentity.canonicalPath != destinationIdentity?.canonicalPath) { - // A self-contained nested identity can move without re-encrypting its contents. Its own maps - // are relative to its root and therefore remain unchanged. Parent aliases are intentionally - // not guessed across trust boundaries. - return FileMovePlan(source, destination, emptyList(), emptyList()) - } - - val metadata = PassSecretsMapStore.metadataFilesForDirectory(sourceParent, repositoryRoot) - val sourceRelative = relativeEntryPath(source, sourceIdentity) - val destinationRelative = relativeEntryPath(destination, sourceIdentity) - val updates = mutableListOf() - val rollback = mutableListOf() + val normalizedMoves = moves.distinctBy { (source, _) -> source.canonicalPath } + val original = linkedMapOf() + val current = linkedMapOf() + + normalizedMoves.forEach { (source, destination) -> + val sourceParent = source.parentFile ?: return@forEach + val sourceIdentity = PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) + ?: return@forEach + val destinationIdentity = + PassSecretsMapStore.identityForDirectory(destination.parentFile ?: sourceParent, repositoryRoot) + if (sourceIdentity.canonicalPath != destinationIdentity?.canonicalPath) return@forEach + + val sourceRelative = relativeEntryPath(source, sourceIdentity) + val destinationRelative = relativeEntryPath(destination, sourceIdentity) + val metadata = PassSecretsMapStore.metadataFilesForDirectory(sourceParent, repositoryRoot) - metadata.mapFile?.let { mapFile -> - val old = mapFile.requireMapSnapshot() - val new = - PassSecretsMapStore.mapAfterMove(old, sourceRelative, destinationRelative, source.isDirectory) - if (new != old) { - updates += Update.Secrets(mapFile, new) - rollback += Update.Secrets(mapFile, old) + metadata.mapFile?.let { mapFile -> + val key = mapFile.canonicalPath + val old = + (original[key] as? Update.Secrets)?.values ?: mapFile.requireMapSnapshot().also { + original[key] = Update.Secrets(mapFile, it) + } + val values = (current[key] as? Update.Secrets)?.values ?: old + current[key] = + Update.Secrets( + mapFile, + PassSecretsMapStore.mapAfterMove( + values, + sourceRelative, + destinationRelative, + source.isDirectory, + ), + ) } - } - metadata.maskFile?.let { maskFile -> - val old = maskFile.requireMaskSnapshot() - val new = - if (source.isDirectory) - PassSecretsMapStore.maskAfterMove(old, sourceRelative, destinationRelative) - else old - if (new != old) { - updates += Update.Mask(maskFile, new) - rollback += Update.Mask(maskFile, old) + metadata.maskFile?.let { maskFile -> + val key = maskFile.canonicalPath + val old = + (original[key] as? Update.Mask)?.associations ?: maskFile.requireMaskSnapshot().also { + original[key] = Update.Mask(maskFile, it) + } + val values = (current[key] as? Update.Mask)?.associations ?: old + current[key] = + Update.Mask( + maskFile, + if (source.isDirectory) + PassSecretsMapStore.maskAfterMove(values, sourceRelative, destinationRelative) + else values, + ) } } - return FileMovePlan(source, destination, updates, rollback) + + return FileMovePlan( + moves = normalizedMoves, + updates = current.filter { (key, value) -> value != original[key] }.values.toList(), + rollbackUpdates = original.filter { (key, value) -> value != current[key] }.values.toList(), + ) } - suspend fun commitMove(plan: FileMovePlan) { + fun planMove(source: File, destination: File, repositoryRoot: File): FileMovePlan = + planMoves(listOf(source to destination), repositoryRoot) + + suspend fun commitMoves(plan: FileMovePlan) { withContext(dispatcherProvider.io()) { - if (!plan.source.renameTo(plan.destination)) { - throw IOException("Could not move ${plan.source} to ${plan.destination}") - } + val staged = mutableListOf() try { + plan.moves.forEach { (source, destination) -> + destination.parentFile?.mkdirs() + val backup = + if (destination.exists()) { + File(destination.parentFile, ".aps-move-backup-${UUID.randomUUID()}-${destination.name}") + .also { backupFile -> + if (!destination.renameTo(backupFile)) { + throw IOException("Could not stage existing destination $destination") + } + } + } else null + if (!source.renameTo(destination)) { + backup?.renameTo(destination) + throw IOException("Could not move $source to $destination") + } + staged += StagedMove(source, destination, backup) + } + writer.persist(plan.updates) + staged.forEach { it.backup?.deleteRecursively() } } catch (error: Throwable) { - if (!plan.destination.renameTo(plan.source)) { - throw IOException( - "Pass-Secrets metadata update failed and filesystem move could not be rolled back", - error, - ) + staged.asReversed().forEach { move -> + if (move.destination.exists()) move.destination.renameTo(move.source) + move.backup?.takeIf { it.exists() }?.renameTo(move.destination) } throw error } } } + suspend fun commitMove(plan: FileMovePlan) = commitMoves(plan) + fun requiredMetadataForDelete(targets: List, repositoryRoot: File): List { val metadata = linkedMapOf() - targets.forEach { target -> + normalizeDeleteTargets(targets).forEach { target -> if (PassSecretsMapStore.isProtectedIdentityMarker(target)) { throw ProtectedIdentityMarkerException(target) } @@ -287,13 +341,14 @@ constructor( } fun planDelete(targets: List, repositoryRoot: File): DeletePlan { - requiredMetadataForDelete(targets, repositoryRoot).firstOrNull()?.let { + val normalizedTargets = normalizeDeleteTargets(targets) + requiredMetadataForDelete(normalizedTargets, repositoryRoot).firstOrNull()?.let { throw MetadataLockedException(it) } - val oldUpdates = linkedMapOf() - val newUpdates = linkedMapOf() + val original = linkedMapOf() + val current = linkedMapOf() - targets.forEach { target -> + normalizedTargets.forEach { target -> val parent = target.parentFile ?: return@forEach val identity = PassSecretsMapStore.identityForDirectory(parent, repositoryRoot) ?: return@forEach val relativePath = relativeEntryPath(target, identity) @@ -302,36 +357,36 @@ constructor( metadata.mapFile?.let { mapFile -> val key = mapFile.canonicalPath val old = - (oldUpdates[key] as? Update.Secrets)?.values ?: mapFile.requireMapSnapshot().also { - oldUpdates[key] = Update.Secrets(mapFile, it) + (original[key] as? Update.Secrets)?.values ?: mapFile.requireMapSnapshot().also { + original[key] = Update.Secrets(mapFile, it) } - val current = (newUpdates[key] as? Update.Secrets)?.values ?: old - newUpdates[key] = + val values = (current[key] as? Update.Secrets)?.values ?: old + current[key] = Update.Secrets( mapFile, - PassSecretsMapStore.mapAfterDelete(current, relativePath, target.isDirectory), + PassSecretsMapStore.mapAfterDelete(values, relativePath, target.isDirectory), ) } metadata.maskFile?.let { maskFile -> val key = maskFile.canonicalPath val old = - (oldUpdates[key] as? Update.Mask)?.associations ?: maskFile.requireMaskSnapshot().also { - oldUpdates[key] = Update.Mask(maskFile, it) + (original[key] as? Update.Mask)?.associations ?: maskFile.requireMaskSnapshot().also { + original[key] = Update.Mask(maskFile, it) } - val current = (newUpdates[key] as? Update.Mask)?.associations ?: old - newUpdates[key] = + val values = (current[key] as? Update.Mask)?.associations ?: old + current[key] = Update.Mask( maskFile, - PassSecretsMapStore.maskAfterDelete(current, relativePath, target.isDirectory), + PassSecretsMapStore.maskAfterDelete(values, relativePath, target.isDirectory), ) } } - val updates = - newUpdates.filter { (key, value) -> value != oldUpdates[key] }.values.toList() - val rollback = - oldUpdates.filter { (key, value) -> value != newUpdates[key] }.values.toList() - return DeletePlan(targets.distinctBy { it.canonicalPath }, updates, rollback) + return DeletePlan( + targets = normalizedTargets, + updates = current.filter { (key, value) -> value != original[key] }.values.toList(), + rollbackUpdates = original.filter { (key, value) -> value != current[key] }.values.toList(), + ) } suspend fun commitDelete(plan: DeletePlan) { @@ -347,21 +402,29 @@ constructor( } staged += target to temporary } - writer.persist(plan.updates) - staged.forEach { (_, temporary) -> - if (!temporary.deleteRecursively()) { - throw IOException("Could not remove staged deleted entry: $temporary") - } - } } catch (error: Throwable) { - val metadataCommitted = staged.any { (_, temporary) -> !temporary.exists() } - if (metadataCommitted) runCatching { writer.persist(plan.rollbackUpdates) } staged.asReversed().forEach { (original, temporary) -> if (temporary.exists()) temporary.renameTo(original) } throw error } + + // At this point the original paths are gone and encrypted metadata has committed. Cleanup is + // best effort: a failed unlink leaves only a hidden, unreferenced ciphertext tombstone rather + // than making the logical deletion inconsistent. + staged.forEach { (_, temporary) -> temporary.deleteRecursively() } + } + } + + private fun normalizeDeleteTargets(targets: List): List { + val unique = targets.distinctBy { it.canonicalPath } + return unique.filter { candidate -> + unique.none { other -> + other != candidate && + other.isDirectory && + candidate.canonicalPath.startsWith(other.canonicalPath.trimEnd(File.separatorChar) + File.separator) + } } } From 49639892cae77dd9eabce1986740837628ef6fb3 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:52:41 +0200 Subject: [PATCH 23/31] feat: add Pass-Secrets mutation errors --- app/src/main/res/values/pass_secrets_strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 app/src/main/res/values/pass_secrets_strings.xml diff --git a/app/src/main/res/values/pass_secrets_strings.xml b/app/src/main/res/values/pass_secrets_strings.xml new file mode 100644 index 0000000000..375dde4587 --- /dev/null +++ b/app/src/main/res/values/pass_secrets_strings.xml @@ -0,0 +1,6 @@ + + + This move crosses a Pass-Secrets encryption identity. Move a single password by editing it and selecting the destination so it can be re-encrypted safely. + The .gpg-id file belongs to a Pass-Secrets identity and cannot be moved or deleted independently. + Could not update Pass-Secrets metadata: %1$s + From 0ec710e956258929c265841526c25d4605178158 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sat, 5 Sep 2026 16:54:36 +0200 Subject: [PATCH 24/31] feat: make move delete and rename Pass-Secrets aware --- .../ui/passwords/PasswordStore.kt | 521 +++++++++--------- 1 file changed, 275 insertions(+), 246 deletions(-) diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt index 067d7a581f..b0f2896860 100644 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt @@ -27,8 +27,10 @@ import app.passwordstore.R import app.passwordstore.data.password.PasswordItem import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.databinding.ActivityPwdstoreBinding +import app.passwordstore.passsecrets.PassSecretsMutationService import app.passwordstore.ui.crypto.BasePGPActivity import app.passwordstore.ui.crypto.DecryptActivity +import app.passwordstore.ui.crypto.PassSecretsMapUnlockActivity import app.passwordstore.ui.crypto.PasswordCreationActivity import app.passwordstore.ui.dialogs.FolderCreationDialogFragment import app.passwordstore.ui.folderselect.SelectFolderActivity @@ -39,7 +41,6 @@ import app.passwordstore.ui.settings.SettingsActivity import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.extensions.base64 import app.passwordstore.util.extensions.commitChange -import app.passwordstore.util.extensions.contains import app.passwordstore.util.extensions.enableEdgeToEdgeView import app.passwordstore.util.extensions.getString import app.passwordstore.util.extensions.isInsideRepository @@ -75,11 +76,20 @@ const val PASSWORD_FRAGMENT_TAG = "PasswordsList" class PasswordStore : BaseGitActivity() { @Inject lateinit var shortcutHandler: ShortcutHandler + @Inject lateinit var passSecretsMutationService: PassSecretsMutationService private lateinit var searchItem: MenuItem private val settings by lazy { sharedPrefs } private val binding by viewBinding(ActivityPwdstoreBinding::inflate) private val model: SearchableRepositoryViewModel by viewModels() + private var pendingPassSecretsOperation: (() -> Unit)? = null + + private val passSecretsUnlockAction = + registerForActivityResult(StartActivityForResult()) { result -> + val operation = pendingPassSecretsOperation + pendingPassSecretsOperation = null + if (result.resultCode == RESULT_OK) operation?.invoke() + } private val gpgKeySelectAction = registerForActivityResult(StartActivityForResult()) { result -> @@ -99,9 +109,7 @@ class PasswordStore : BaseGitActivity() { private val listRefreshAction = registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - refreshPasswordList() - } + if (result.resultCode == RESULT_OK) refreshPasswordList() } private val passwordMoveAction = @@ -117,97 +125,62 @@ class PasswordStore : BaseGitActivity() { "'SELECTED_FOLDER_PATH' intent extra must be set" } ) - val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath if (!target.isDirectory) { logcat(ERROR) { "Tried moving passwords to a non-existing folder." } return@registerForActivityResult } - logcat { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" } - logcat { filesToMove.joinToString(", ") } + val moves = + filesToMove + .map { File(it) } + .filter { source -> source.exists() } + .map { source -> source to File(target, source.name) } + .filter { (source, destination) -> source.canonicalPath != destination.canonicalPath } + if (moves.isEmpty()) { + getPasswordFragment()?.dismissActionMode() + return@registerForActivityResult + } - lifecycleScope.launch(dispatcherProvider.io()) { - for (file in filesToMove) { - val source = File(file) - if (!source.exists()) { - logcat(ERROR) { "Tried moving something that appears non-existent." } - continue - } - val destinationFile = File(target.absolutePath + "/" + source.name) - val basename = source.nameWithoutExtension - val sourceLongName = - PasswordRepository.getLongName( - requireNotNull(source.parent) { "$file has no parent" }, - repositoryPath, - basename, - ) - val destinationLongName = - PasswordRepository.getLongName(target.absolutePath, repositoryPath, basename) - if (destinationFile.exists()) { - logcat(ERROR) { "Trying to move a file that already exists." } - withContext(dispatcherProvider.main()) { - MaterialAlertDialogBuilder(this@PasswordStore) - .setTitle(resources.getString(R.string.password_exists_title)) - .setMessage( - resources.getString( - R.string.password_exists_message, - destinationLongName, - sourceLongName, - ) - ) - .setPositiveButton(R.string.dialog_ok) { _, _ -> - launch(dispatcherProvider.io()) { moveFile(source, destinationFile) } - } - .setNegativeButton(R.string.dialog_cancel, null) - .show() - } - } else { - launch(dispatcherProvider.io()) { moveFile(source, destinationFile) } - } - } - when (filesToMove.size) { - 1 -> { - val source = File(filesToMove[0]) - val basename = source.nameWithoutExtension - val sourceLongName = - PasswordRepository.getLongName( - requireNotNull(source.parent) { "$basename has no parent" }, - repositoryPath, - basename, - ) - val destinationLongName = - PasswordRepository.getLongName(target.absolutePath, repositoryPath, basename) - withContext(dispatcherProvider.main()) { - commitChange( - resources.getString( - R.string.git_commit_move_text, - sourceLongName, - destinationLongName, - ) - ) - updateFabSync() - } - } - else -> { - val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath - val relativePath = - PasswordRepository.getRelativePath("${target.absolutePath}/", repoPath) - withContext(dispatcherProvider.main()) { - commitChange( - resources.getString(R.string.git_commit_move_multiple_text, relativePath) - ) - updateFabSync() - } - } + logcat { "Moving passwords to ${target.absolutePath}" } + logcat { moves.joinToString(", ") { (source, _) -> source.absolutePath } } + + val conflict = + moves.firstOrNull { (source, destination) -> + destination.exists() && source.canonicalPath != destination.canonicalPath } + if (conflict != null) { + val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath + val (source, destination) = conflict + val sourceLongName = + PasswordRepository.getLongName( + requireNotNull(source.parent), + repositoryPath, + source.nameWithoutExtension, + ) + val destinationLongName = + PasswordRepository.getLongName( + requireNotNull(destination.parent), + repositoryPath, + destination.nameWithoutExtension, + ) + MaterialAlertDialogBuilder(this) + .setTitle(resources.getString(R.string.password_exists_title)) + .setMessage( + resources.getString( + R.string.password_exists_message, + destinationLongName, + sourceLongName, + ) + ) + .setPositiveButton(R.string.dialog_ok) { _, _ -> performPasswordMoves(moves, target) } + .setNegativeButton(R.string.dialog_cancel, null) + .show() + } else { + performPasswordMoves(moves, target) } - getPasswordFragment()?.dismissActionMode() - getPasswordFragment()?.scrollToOnNextRefresh(File(target, File(filesToMove[0]).name)) - refreshPasswordList(target) } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - // open search view on search key, or Ctr+F if ( (keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && !searchItem.isActionViewExpanded @@ -216,7 +189,6 @@ class PasswordStore : BaseGitActivity() { return true } - // open search view on any printable character and query for it val c = event.unicodeChar.toChar() val printable = isPrintable(c) if (printable && !searchItem.isActionViewExpanded) { @@ -237,9 +209,7 @@ class PasswordStore : BaseGitActivity() { this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (getPasswordFragment()?.onBackPressedInActivity() != true) { - finishAndRemoveTask() - } + if (getPasswordFragment()?.onBackPressedInActivity() != true) finishAndRemoveTask() } }, ) @@ -264,9 +234,7 @@ class PasswordStore : BaseGitActivity() { checkLocalRepository() refreshPasswordList() if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) { - if (!searchItem.isActionViewExpanded) { - searchItem.expandActionView() - } + if (!searchItem.isActionViewExpanded) searchItem.expandActionView() } } @@ -282,8 +250,6 @@ class PasswordStore : BaseGitActivity() { } override fun onPrepareOptionsMenu(menu: Menu): Boolean { - // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so - // we can get by without any noticeable difference in performance. invalidateOptionsMenu() searchItem = menu.findItem(R.id.action_search) val searchView = searchItem.actionView as SearchView @@ -300,8 +266,6 @@ class PasswordStore : BaseGitActivity() { if (settings.getString(PreferenceKeys.SEARCH_FILTER_MODE, "exact") == "fuzzy") FilterMode.Fuzzy else FilterMode.Exact - // List the contents of the current directory if the user enters a blank - // search term. if (filter.isEmpty()) model.navigateTo(newDirectory = model.currentDir.value, pushPreviousLocation = false) else model.search(filter, filterMode = filterMode) @@ -310,8 +274,6 @@ class PasswordStore : BaseGitActivity() { } ) - // When using the support library, the setOnActionExpandListener() method is - // static and accepts the MenuItem object as an argument searchItem.setOnActionExpandListener( object : OnActionExpandListener { override fun onMenuItemActionCollapse(item: MenuItem): Boolean { @@ -319,9 +281,7 @@ class PasswordStore : BaseGitActivity() { return true } - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - return true - } + override fun onMenuItemActionExpand(item: MenuItem): Boolean = true } ) if ( @@ -345,30 +305,16 @@ class PasswordStore : BaseGitActivity() { .onErr { e -> e.printStackTrace() } } R.id.git_push -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.PUSH) - } + if (!PasswordRepository.isInitialized) initBefore.show() else runGitOperation(GitOp.PUSH) } R.id.git_pull -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.PULL) - } + if (!PasswordRepository.isInitialized) initBefore.show() else runGitOperation(GitOp.PULL) } R.id.git_sync -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.SYNC) - } + if (!PasswordRepository.isInitialized) initBefore.show() else runGitOperation(GitOp.SYNC) } R.id.refresh -> refreshPasswordList() - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - } + android.R.id.home -> onBackPressedDispatcher.onBackPressed() else -> return super.onOptionsItemSelected(item) } return true @@ -394,7 +340,6 @@ class PasswordStore : BaseGitActivity() { private fun checkLocalRepository(localDir: File?) { if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) { - // do not push the fragment if we already have it if ( getPasswordFragment() == null || settings.getBoolean(PreferenceKeys.REPO_CHANGED, false) ) { @@ -402,11 +347,7 @@ class PasswordStore : BaseGitActivity() { val args = Bundle() args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) - // if the activity was started from the autofill settings, the - // intent is to match a clicked pwd with app. pass this to fragment - if (intent.getBooleanExtra("matchWith", false)) { - args.putBoolean("matchWith", true) - } + if (intent.getBooleanExtra("matchWith", false)) args.putBoolean("matchWith", true) supportActionBar?.apply { show() setDisplayHomeAsUpEnabled(false) @@ -427,8 +368,6 @@ class PasswordStore : BaseGitActivity() { Intent(authDecryptIntent).setComponent(ComponentName(this, DecryptActivity::class.java)) startActivity(decryptIntent) - - // Adds shortcut shortcutHandler.addDynamicShortcut(item, authDecryptIntent) } @@ -464,55 +403,122 @@ class PasswordStore : BaseGitActivity() { fun deletePasswords(selectedItems: List) { var size = 0 - selectedItems.forEach { - if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size + selectedItems.forEach { item -> + if (item.file.isFile) size++ else size += item.file.listFilesRecursively().size } - if (size == 0) { // delete empty directory trees without confirmation - selectedItems.map { item -> item.file.deleteRecursively() } - refreshPasswordList() + if (size == 0) { + performDelete(selectedItems) return } MaterialAlertDialogBuilder(this) .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size)) .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ -> - val filesToDelete = arrayListOf() - selectedItems.forEach { item -> - if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively()) - else filesToDelete.add(item.file) - } - // remove to-be-deleted files from history - val preference = getSharedPreferences("recent_password_history", 0) - preference.edit { - filesToDelete.forEach { file -> - remove(file.absolutePath.base64()) - } - } - selectedItems.map { item -> item.file.deleteRecursively() } - refreshPasswordList() - AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) - val fmt = - selectedItems.joinToString(separator = ", ") { item -> - item.file.toRelativeString(PasswordRepository.getRepositoryDirectory()) - } - lifecycleScope.launch { - commitChange(resources.getString(R.string.git_commit_remove_text, fmt)) - updateFabSync() - } + performDelete(selectedItems) } .setNegativeButton(resources.getString(R.string.dialog_no), null) .show() } + private fun performDelete(selectedItems: List) { + val targets = selectedItems.map { it.file } + val repositoryRoot = PasswordRepository.getRepositoryDirectory() + withUnlockedPassSecretsMetadata( + required = { + passSecretsMutationService.requiredMetadataForDelete(targets, repositoryRoot) + }, + action = { + lifecycleScope.launch { + try { + val filesToDelete = + targets.flatMap { target -> + if (target.isDirectory) target.listFilesRecursively() else listOf(target) + } + val plan = passSecretsMutationService.planDelete(targets, repositoryRoot) + withContext(dispatcherProvider.io()) { passSecretsMutationService.commitDelete(plan) } + + val preference = getSharedPreferences("recent_password_history", 0) + preference.edit { + filesToDelete.forEach { file -> remove(file.absolutePath.base64()) } + } + AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) + + val fmt = + targets.joinToString(separator = ", ") { target -> + target.toRelativeString(repositoryRoot) + } + commitChange(resources.getString(R.string.git_commit_remove_text, fmt)) + updateFabSync() + getPasswordFragment()?.dismissActionMode() + refreshPasswordList() + } catch (error: Throwable) { + showPassSecretsMutationError(error) + } + } + }, + ) + } + fun movePasswords(values: List) { val intent = Intent(this, SelectFolderActivity::class.java) val fileLocations = values.map { it.file.absolutePath }.toTypedArray() intent.putExtra("Files", fileLocations) val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath val relPath = PasswordRepository.getRelativePath(currentDir.absolutePath, repoPath) - if (!relPath.isEmpty()) intent.putExtra(PasswordStore.REQUEST_ARG_PATH, relPath) + if (!relPath.isEmpty()) intent.putExtra(REQUEST_ARG_PATH, relPath) passwordMoveAction.launch(intent) } + private fun performPasswordMoves(moves: List>, target: File) { + val repositoryRoot = PasswordRepository.getRepositoryDirectory() + withUnlockedPassSecretsMetadata( + required = { passSecretsMutationService.requiredMetadataForMoves(moves, repositoryRoot) }, + action = { + lifecycleScope.launch { + try { + val sourceDestinationMap = buildSourceDestinationMap(moves) + val plan = passSecretsMutationService.planMoves(moves, repositoryRoot) + withContext(dispatcherProvider.io()) { passSecretsMutationService.commitMoves(plan) } + updateHistoryAfterMove(sourceDestinationMap) + AutofillMatcher.updateMatches(applicationContext, sourceDestinationMap) + + val repositoryPath = repositoryRoot.absolutePath + if (moves.size == 1) { + val (source, destination) = moves.single() + val sourceLongName = + PasswordRepository.getLongName( + requireNotNull(source.parent), + repositoryPath, + source.nameWithoutExtension, + ) + val destinationLongName = + PasswordRepository.getLongName( + requireNotNull(destination.parent), + repositoryPath, + destination.nameWithoutExtension, + ) + commitChange( + resources.getString( + R.string.git_commit_move_text, + sourceLongName, + destinationLongName, + ) + ) + } else { + val relativePath = PasswordRepository.getRelativePath("${target.absolutePath}/", repositoryPath) + commitChange(resources.getString(R.string.git_commit_move_multiple_text, relativePath)) + } + updateFabSync() + getPasswordFragment()?.dismissActionMode() + getPasswordFragment()?.scrollToOnNextRefresh(moves.first().second) + refreshPasswordList(target) + } catch (error: Throwable) { + showPassSecretsMutationError(error) + } + } + }, + ) + } + enum class CategoryRenameError(val resource: Int) { None(0), EmptyField(R.string.message_category_error_empty_field), @@ -520,18 +526,6 @@ class PasswordStore : BaseGitActivity() { DestinationOutsideRepo(R.string.message_error_destination_outside_repo), } - /** - * Prompt the user with a new category name to assign, if the new category forms/leads a path - * (i.e. contains "/"), intermediate directories will be created and new category will be placed - * inside. - * - * @param oldCategory The category to change its name - * @param error Determines whether to show an error to the user in the alert dialog, this error - * may be due to the new category the user entered already exists or the field was empty or the - * destination path is outside the repository - * @see [CategoryRenameError] - * @see [isInsideRepository] - */ private fun renameCategory( oldCategory: PasswordItem, error: CategoryRenameError = CategoryRenameError.None, @@ -540,9 +534,7 @@ class PasswordStore : BaseGitActivity() { val newCategoryEditText = view.findViewById(R.id.folder_name_text) val folderNameViewContainer = view.findViewById(R.id.folder_name_container) - if (error != CategoryRenameError.None) { - folderNameViewContainer.error = getString(error.resource) - } + if (error != CategoryRenameError.None) folderNameViewContainer.error = getString(error.resource) val dialog = MaterialAlertDialogBuilder(this) @@ -557,34 +549,7 @@ class PasswordStore : BaseGitActivity() { newCategoryEditText.text.isNullOrBlank() -> renameCategory(oldCategory, CategoryRenameError.EmptyField) newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists) - else -> - lifecycleScope.launch(dispatcherProvider.io()) { - moveFile(oldCategory.file, newCategory) - - // associate the new category with the last category's timestamp in - // history - val preference = getSharedPreferences("recent_password_history", 0) - val timestamp = preference.getString(oldCategory.file.absolutePath.base64()) - if (timestamp != null) { - preference.edit { - remove(oldCategory.file.absolutePath.base64()) - putString(newCategory.absolutePath.base64(), timestamp) - } - } - - withContext(dispatcherProvider.main()) { - commitChange( - resources.getString( - R.string.git_commit_move_text, - oldCategory.name, - newCategory.name, - ) - ) - updateFabSync() - } - - refreshPasswordList() - } + else -> performCategoryRename(oldCategory, newCategory) } } .setNegativeButton(R.string.dialog_cancel, null) @@ -594,9 +559,117 @@ class PasswordStore : BaseGitActivity() { dialog.show() } + private fun performCategoryRename(oldCategory: PasswordItem, newCategory: File) { + val repositoryRoot = PasswordRepository.getRepositoryDirectory() + withUnlockedPassSecretsMetadata( + required = { + passSecretsMutationService.requiredMetadataForMove( + oldCategory.file, + newCategory, + repositoryRoot, + ) + }, + action = { + lifecycleScope.launch { + try { + val sourceDestinationMap = + buildSourceDestinationMap(listOf(oldCategory.file to newCategory)) + val categoryPreference = getSharedPreferences("recent_password_history", 0) + val categoryTimestamp = categoryPreference.getString(oldCategory.file.absolutePath.base64()) + val plan = + passSecretsMutationService.planMove(oldCategory.file, newCategory, repositoryRoot) + withContext(dispatcherProvider.io()) { passSecretsMutationService.commitMove(plan) } + updateHistoryAfterMove(sourceDestinationMap) + if (categoryTimestamp != null) { + categoryPreference.edit { + remove(oldCategory.file.absolutePath.base64()) + putString(newCategory.absolutePath.base64(), categoryTimestamp) + } + } + AutofillMatcher.updateMatches(applicationContext, sourceDestinationMap) + + commitChange( + resources.getString( + R.string.git_commit_move_text, + oldCategory.name, + newCategory.name, + ) + ) + updateFabSync() + refreshPasswordList(newCategory) + } catch (error: Throwable) { + showPassSecretsMutationError(error) + } + } + }, + ) + } + fun renameCategory(categories: List) { - for (oldCategory in categories) { - renameCategory(oldCategory) + for (oldCategory in categories) renameCategory(oldCategory) + } + + private fun withUnlockedPassSecretsMetadata(required: () -> List, action: () -> Unit) { + try { + val nextMetadata = required().firstOrNull() + if (nextMetadata == null) { + pendingPassSecretsOperation = null + action() + return + } + pendingPassSecretsOperation = { withUnlockedPassSecretsMetadata(required, action) } + passSecretsUnlockAction.launch( + PassSecretsMapUnlockActivity.newIntent( + this, + nextMetadata, + PasswordRepository.getRepositoryDirectory(), + ) + ) + } catch (error: Throwable) { + pendingPassSecretsOperation = null + showPassSecretsMutationError(error) + } + } + + private fun showPassSecretsMutationError(error: Throwable) { + val message = + when (error) { + is PassSecretsMutationService.ReencryptionRequiredException -> + getString(R.string.pass_secrets_reencryption_required) + is PassSecretsMutationService.ProtectedIdentityMarkerException -> + getString(R.string.pass_secrets_identity_marker_protected) + else -> getString(R.string.pass_secrets_mutation_error, error.message ?: error.toString()) + } + MaterialAlertDialogBuilder(this) + .setTitle(R.string.error) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + private fun buildSourceDestinationMap(moves: List>): Map { + return buildMap { + moves.forEach { (source, destination) -> + if (source.isDirectory) { + source.listFilesRecursively().forEach { child -> + put(child, destination.resolve(child.relativeTo(source))) + } + } else { + put(source, destination) + } + } + } + } + + private fun updateHistoryAfterMove(sourceDestinationMap: Map) { + val preference = getSharedPreferences("recent_password_history", 0) + preference.edit { + sourceDestinationMap.forEach { (source, destination) -> + val sourceHash = source.absolutePath.base64() + val timestamp = preference.getString(sourceHash) + remove(sourceHash) + if (timestamp != null) putString(destination.absolutePath.base64(), timestamp) + } } } @@ -604,12 +677,6 @@ class PasswordStore : BaseGitActivity() { runOnUiThread { getPasswordFragment()?.updateFabSync() } } - /** - * Refreshes the password list by re-executing the last navigation or search action, preserving - * the navigation stack and scroll position. If the current directory no longer exists, navigation - * is reset to the repository root. If the optional [target] argument is provided, it will be - * entered if it is a directory or scrolled into view if it is a file. - */ fun refreshPasswordList(target: File? = null) { val relativeTargetPath = target?.let { require(it.isInsideRepository()) { "Trying to access target outside the repository" } @@ -622,7 +689,7 @@ class PasswordStore : BaseGitActivity() { relativeTargetPath.trim('/').split('/').forEach { item -> val file = File(model.currentDir.value, item) if (file.isDirectory) { - if (file.equals(model.currentDir.value)) model.forceRefresh() + if (file == model.currentDir.value) model.forceRefresh() else model.navigateTo(file, pushPreviousLocation = true) } else getPasswordFragment()?.scrollToOnNextRefresh(file) } @@ -638,48 +705,10 @@ class PasswordStore : BaseGitActivity() { private val currentDir: File get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory() - private suspend fun moveFile(source: File, destinationFile: File) { - val sourceDestinationMap = - if (source.isDirectory) { - destinationFile.mkdirs() - // Recursively list all files (not directories) below `source`, then - // obtain the corresponding target file by resolving the relative path - // starting at the destination folder. - source.listFilesRecursively().associateWith { - destinationFile.resolve(it.relativeTo(source)) - } - } else { - mapOf(source to destinationFile) - } - if (!source.renameTo(destinationFile)) { - logcat(ERROR) { "Something went wrong while moving $source to $destinationFile." } - withContext(dispatcherProvider.main()) { - MaterialAlertDialogBuilder(this@PasswordStore) - .setTitle(R.string.password_move_error_title) - .setMessage(getString(R.string.password_move_error_message, source, destinationFile)) - .setCancelable(true) - .setPositiveButton(android.R.string.ok, null) - .show() - } - } else { - // update timestamp cache with the new file locations - val preference = getSharedPreferences("recent_password_history", 0) - preference.edit { - sourceDestinationMap.forEach { (src, dest) -> - val srcPathHash = src.absolutePath.base64() - val timestamp = preference.getString(srcPathHash) - remove(srcPathHash) - putString(dest.absolutePath.base64(), timestamp) - } - } - AutofillMatcher.updateMatches(this, sourceDestinationMap) - } - } - fun matchPasswordWithApp(item: PasswordItem) { val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath val path = - PasswordRepository.getRelativePath(item.file.absolutePath, repoPath + "/").replace(".gpg", "") + PasswordRepository.getRelativePath(item.file.absolutePath, "$repoPath/").replace(".gpg", "") val data = Intent() data.putExtra("path", path) setResult(RESULT_OK, data) From e3872a2283868b85c2a6e7ff29585cf0715d0888 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Sat, 5 Sep 2026 14:56:37 +0000 Subject: [PATCH 25/31] fix: resolve substringBefore Regex compilation error and apply formatting --- .../data/password/PasswordItem.kt | 26 +++---- .../passsecrets/PassSecretsMapStore.kt | 39 +++++----- .../passsecrets/PassSecretsMapWriter.kt | 10 ++- .../passsecrets/PassSecretsMutationService.kt | 74 ++++++++++++------- .../ui/crypto/PassSecretsMapUnlockActivity.kt | 5 +- .../ui/crypto/PasswordCreationActivity.kt | 2 - .../ui/passwords/PasswordStore.kt | 24 +++--- 7 files changed, 103 insertions(+), 77 deletions(-) diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt index 6e5827b8cf..51d64526c6 100644 --- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -30,13 +30,12 @@ data class PasswordItem( val longName = PasswordRepository.getLongName(fullPathToParent, rootDir.absolutePath, toString()) - val searchableName = - buildList { - add(physicalLongName) - if (mappedName != null) add(longName) - addAll(aliases) - } - .joinToString(" ") + val searchableName = buildList { + add(physicalLongName) + if (mappedName != null) add(longName) + addAll(aliases) + } + .joinToString(" ") fun matchesSearch(filter: String): Boolean = searchableName.contains(filter, ignoreCase = true) @@ -49,14 +48,13 @@ data class PasswordItem( } if (regex.containsMatchIn(physicalPath)) return true - val logicalTokens = - buildList { - mappedName?.split(Regex("[\\s/]+"))?.filterTo(this) { it.isNotBlank() } - aliases.forEach { alias -> - add(alias) - if ('@' in alias) add(alias.substringAfterLast('@')) - } + val logicalTokens = buildList { + mappedName?.split(Regex("[\\s/]+"))?.filterTo(this) { it.isNotBlank() } + aliases.forEach { alias -> + add(alias) + if ('@' in alias) add(alias.substringAfterLast('@')) } + } return logicalTokens.any { token -> regex.containsMatchIn("$token.gpg") } } diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt index 2c98a8e0c0..abdf990bb4 100644 --- a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt @@ -81,13 +81,12 @@ object PassSecretsMapStore { } fun serializeMap(values: Map): String { - return values - .toSortedMap() - .entries - .joinToString(separator = "\n", postfix = if (values.isEmpty()) "" else "\n") { - (key, value) -> - "$key = $value" - } + return values.toSortedMap().entries.joinToString( + separator = "\n", + postfix = if (values.isEmpty()) "" else "\n", + ) { (key, value) -> + "$key = $value" + } } fun serializeMask(associations: List): String { @@ -104,7 +103,8 @@ object PassSecretsMapStore { /** Resolve the mapped display name for a physical password file, if its map is unlocked. */ fun mappedName(file: File, repositoryRoot: File): String? { if (!isPasswordFile(file)) return null - val identity = findNearestIdentity(file.parentFile ?: return null, repositoryRoot) ?: return null + val identity = + findNearestIdentity(file.parentFile ?: return null, repositoryRoot) ?: return null val mapFile = File(identity, MAP_FILE_NAME) if (!mapFile.isFile) return null val relativePath = passwordRelativePath(file, identity) ?: return null @@ -125,15 +125,13 @@ object PassSecretsMapStore { fun aliases(file: File, repositoryRoot: File): List { if (!isPasswordFile(file)) return emptyList() val identity = - findNearestIdentity(file.parentFile ?: return emptyList(), repositoryRoot) ?: return emptyList() + findNearestIdentity(file.parentFile ?: return emptyList(), repositoryRoot) + ?: return emptyList() val maskFile = File(identity, MASK_FILE_NAME) if (!maskFile.isFile) return emptyList() val relativeDirectory = try { - file.parentFile - ?.relativeTo(identity) - ?.invariantSeparatorsPath - ?.ifBlank { "." } + file.parentFile?.relativeTo(identity)?.invariantSeparatorsPath?.ifBlank { "." } ?: return emptyList() } catch (_: IllegalArgumentException) { return emptyList() @@ -159,9 +157,9 @@ object PassSecretsMapStore { /** * Claim this directory's nearest identity metadata for lazy unlock. * - * A nested `.gpg-id` is always a hard boundary. Both `.secrets.gpg` and `.mask.gpg` are loaded - * in one authentication session when present. The returned path is merely the primary file used - * to launch the unlock activity. + * A nested `.gpg-id` is always a hard boundary. Both `.secrets.gpg` and `.mask.gpg` are loaded in + * one authentication session when present. The returned path is merely the primary file used to + * launch the unlock activity. */ fun claimForDirectory(directory: File, repositoryRoot: File): File? { val identity = findNearestIdentity(directory, repositoryRoot) ?: return null @@ -181,7 +179,8 @@ object PassSecretsMapStore { } fun metadataFilesForDirectory(directory: File, repositoryRoot: File): MetadataFiles { - val identity = findNearestIdentity(directory, repositoryRoot) ?: return MetadataFiles(null, null) + val identity = + findNearestIdentity(directory, repositoryRoot) ?: return MetadataFiles(null, null) return identity.metadataFiles() } @@ -374,7 +373,8 @@ object PassSecretsMapStore { if (source.isFile) { if (!isPasswordFile(source)) return false val sourceIdentity = findNearestIdentity(source.parentFile ?: return false, repositoryRoot) - val targetIdentity = findNearestIdentity(destination.parentFile ?: return false, repositoryRoot) + val targetIdentity = + findNearestIdentity(destination.parentFile ?: return false, repositoryRoot) return !sameIdentity(sourceIdentity, targetIdentity) } @@ -395,7 +395,8 @@ object PassSecretsMapStore { val mapCurrent = metadata.mapFile == null || loadedMaps[identityKey]?.version == metadata.mapFile.fileVersion() val maskCurrent = - metadata.maskFile == null || loadedMasks[identityKey]?.version == metadata.maskFile.fileVersion() + metadata.maskFile == null || + loadedMasks[identityKey]?.version == metadata.maskFile.fileVersion() return mapCurrent && maskCurrent } diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt index c4f913d76d..7168ebc6f1 100644 --- a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt @@ -124,17 +124,21 @@ constructor( val identifiers = mutableListOf() gpgId.readLines().forEach { rawLine -> - val line = rawLine.substringBefore(Regex("\\s*#|!")).trim() + val line = rawLine.substringBefore('#').substringBefore('!').trim() if (line.isBlank() || line == "gpg-id") return@forEach require(!line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { "Short OpenPGP key IDs are not accepted in $gpgId" } val identifier = - requireNotNull(PGPIdentifier.fromString(line)) { "Invalid OpenPGP identifier '$line' in $gpgId" } + requireNotNull(PGPIdentifier.fromString(line)) { + "Invalid OpenPGP identifier '$line' in $gpgId" + } require(repository.hasKey(identifier)) { "OpenPGP key '$identifier' is not imported" } identifiers += identifier } - require(identifiers.isNotEmpty()) { "Pass-Secrets identity has no usable recipients: $identity" } + require(identifiers.isNotEmpty()) { + "Pass-Secrets identity has no usable recipients: $identity" + } return identifiers } diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt index 9a4d752071..f161f9d0bd 100644 --- a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt @@ -119,7 +119,8 @@ constructor( if (sourceFile != null && sourceMapFile != null) { val sourceIdentity = requireNotNull(sourceMapFile.parentFile) - val sourceKey = requireNotNull(PassSecretsMapStore.passwordRelativePath(sourceFile, sourceIdentity)) + val sourceKey = + requireNotNull(PassSecretsMapStore.passwordRelativePath(sourceFile, sourceIdentity)) newMaps[sourceMapFile.canonicalPath] = PassSecretsMapStore.mapAfterDelete( newMaps.getValue(sourceMapFile.canonicalPath), @@ -161,7 +162,8 @@ constructor( val source = plan.sourceFile val destination = plan.destinationFile val sameFile = source?.canonicalPath == destination.canonicalPath - val originalDestination = if (sameFile && destination.isFile) destination.readBytes() else null + val originalDestination = + if (sameFile && destination.isFile) destination.readBytes() else null try { destination.parentFile?.mkdirs() @@ -202,10 +204,13 @@ constructor( throw ReencryptionRequiredException(source, destination) } val sourceParent = source.parentFile ?: return@forEach - val sourceIdentity = PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) - ?: return@forEach + val sourceIdentity = + PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) ?: return@forEach val destinationIdentity = - PassSecretsMapStore.identityForDirectory(destination.parentFile ?: return@forEach, repositoryRoot) + PassSecretsMapStore.identityForDirectory( + destination.parentFile ?: return@forEach, + repositoryRoot, + ) if (sourceIdentity.canonicalPath != destinationIdentity?.canonicalPath) { // Moving an entire nested identity is safe cryptographically because its .gpg-id moves // with it, but parent Pass-Secrets metadata could reference that directory. Refuse when @@ -235,10 +240,13 @@ constructor( normalizedMoves.forEach { (source, destination) -> val sourceParent = source.parentFile ?: return@forEach - val sourceIdentity = PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) - ?: return@forEach + val sourceIdentity = + PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) ?: return@forEach val destinationIdentity = - PassSecretsMapStore.identityForDirectory(destination.parentFile ?: sourceParent, repositoryRoot) + PassSecretsMapStore.identityForDirectory( + destination.parentFile ?: sourceParent, + repositoryRoot, + ) if (sourceIdentity.canonicalPath != destinationIdentity?.canonicalPath) return@forEach val sourceRelative = relativeEntryPath(source, sourceIdentity) @@ -248,9 +256,10 @@ constructor( metadata.mapFile?.let { mapFile -> val key = mapFile.canonicalPath val old = - (original[key] as? Update.Secrets)?.values ?: mapFile.requireMapSnapshot().also { - original[key] = Update.Secrets(mapFile, it) - } + (original[key] as? Update.Secrets)?.values + ?: mapFile.requireMapSnapshot().also { + original[key] = Update.Secrets(mapFile, it) + } val values = (current[key] as? Update.Secrets)?.values ?: old current[key] = Update.Secrets( @@ -266,9 +275,10 @@ constructor( metadata.maskFile?.let { maskFile -> val key = maskFile.canonicalPath val old = - (original[key] as? Update.Mask)?.associations ?: maskFile.requireMaskSnapshot().also { - original[key] = Update.Mask(maskFile, it) - } + (original[key] as? Update.Mask)?.associations + ?: maskFile.requireMaskSnapshot().also { + original[key] = Update.Mask(maskFile, it) + } val values = (current[key] as? Update.Mask)?.associations ?: old current[key] = Update.Mask( @@ -298,7 +308,10 @@ constructor( destination.parentFile?.mkdirs() val backup = if (destination.exists()) { - File(destination.parentFile, ".aps-move-backup-${UUID.randomUUID()}-${destination.name}") + File( + destination.parentFile, + ".aps-move-backup-${UUID.randomUUID()}-${destination.name}", + ) .also { backupFile -> if (!destination.renameTo(backupFile)) { throw IOException("Could not stage existing destination $destination") @@ -333,9 +346,10 @@ constructor( throw ProtectedIdentityMarkerException(target) } val parent = target.parentFile ?: return@forEach - PassSecretsMapStore.metadataFilesForDirectory(parent, repositoryRoot) - .existingFiles - .forEach { file -> metadata[file.canonicalPath] = file } + PassSecretsMapStore.metadataFilesForDirectory(parent, repositoryRoot).existingFiles.forEach { + file -> + metadata[file.canonicalPath] = file + } } return metadata.values.filterNot(PassSecretsMapStore::isLoaded) } @@ -350,16 +364,18 @@ constructor( normalizedTargets.forEach { target -> val parent = target.parentFile ?: return@forEach - val identity = PassSecretsMapStore.identityForDirectory(parent, repositoryRoot) ?: return@forEach + val identity = + PassSecretsMapStore.identityForDirectory(parent, repositoryRoot) ?: return@forEach val relativePath = relativeEntryPath(target, identity) val metadata = PassSecretsMapStore.metadataFilesForDirectory(parent, repositoryRoot) metadata.mapFile?.let { mapFile -> val key = mapFile.canonicalPath val old = - (original[key] as? Update.Secrets)?.values ?: mapFile.requireMapSnapshot().also { - original[key] = Update.Secrets(mapFile, it) - } + (original[key] as? Update.Secrets)?.values + ?: mapFile.requireMapSnapshot().also { + original[key] = Update.Secrets(mapFile, it) + } val values = (current[key] as? Update.Secrets)?.values ?: old current[key] = Update.Secrets( @@ -370,9 +386,10 @@ constructor( metadata.maskFile?.let { maskFile -> val key = maskFile.canonicalPath val old = - (original[key] as? Update.Mask)?.associations ?: maskFile.requireMaskSnapshot().also { - original[key] = Update.Mask(maskFile, it) - } + (original[key] as? Update.Mask)?.associations + ?: maskFile.requireMaskSnapshot().also { + original[key] = Update.Mask(maskFile, it) + } val values = (current[key] as? Update.Mask)?.associations ?: old current[key] = Update.Mask( @@ -423,7 +440,9 @@ constructor( unique.none { other -> other != candidate && other.isDirectory && - candidate.canonicalPath.startsWith(other.canonicalPath.trimEnd(File.separatorChar) + File.separator) + candidate.canonicalPath.startsWith( + other.canonicalPath.trimEnd(File.separatorChar) + File.separator + ) } } } @@ -442,7 +461,8 @@ constructor( private fun relativeEntryPath(file: File, identity: File): String { val relative = file.relativeTo(identity).invariantSeparatorsPath - return if (file.isFile || file.name.endsWith(".gpg")) relative.removeSuffix(".gpg") else relative + return if (file.isFile || file.name.endsWith(".gpg")) relative.removeSuffix(".gpg") + else relative } private fun passwordRelativePath(file: File, identity: File): String { diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt index 481fb7fc0d..f76e36f96b 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt @@ -117,7 +117,10 @@ class PassSecretsMapUnlockActivity : BasePGPActivity() { try { when (file.name) { PassSecretsMapStore.MAP_FILE_NAME -> - PassSecretsMapStore.putMap(file, PassSecretsMapStore.parse(decryptedBytes.decodeToString())) + PassSecretsMapStore.putMap( + file, + PassSecretsMapStore.parse(decryptedBytes.decodeToString()), + ) PassSecretsMapStore.MASK_FILE_NAME -> PassSecretsMapStore.putMask( file, diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt index 47f4452751..9b77b3426c 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt @@ -73,11 +73,9 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import java.nio.CharBuffer -import java.nio.file.Paths import javax.inject.Inject import kotlin.io.path.createDirectories import kotlin.io.path.exists -import kotlin.io.path.pathString import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import logcat.LogPriority.ERROR diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt index b0f2896860..9bad275a80 100644 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt @@ -144,10 +144,9 @@ class PasswordStore : BaseGitActivity() { logcat { "Moving passwords to ${target.absolutePath}" } logcat { moves.joinToString(", ") { (source, _) -> source.absolutePath } } - val conflict = - moves.firstOrNull { (source, destination) -> - destination.exists() && source.canonicalPath != destination.canonicalPath - } + val conflict = moves.firstOrNull { (source, destination) -> + destination.exists() && source.canonicalPath != destination.canonicalPath + } if (conflict != null) { val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath val (source, destination) = conflict @@ -429,10 +428,9 @@ class PasswordStore : BaseGitActivity() { action = { lifecycleScope.launch { try { - val filesToDelete = - targets.flatMap { target -> - if (target.isDirectory) target.listFilesRecursively() else listOf(target) - } + val filesToDelete = targets.flatMap { target -> + if (target.isDirectory) target.listFilesRecursively() else listOf(target) + } val plan = passSecretsMutationService.planDelete(targets, repositoryRoot) withContext(dispatcherProvider.io()) { passSecretsMutationService.commitDelete(plan) } @@ -504,8 +502,11 @@ class PasswordStore : BaseGitActivity() { ) ) } else { - val relativePath = PasswordRepository.getRelativePath("${target.absolutePath}/", repositoryPath) - commitChange(resources.getString(R.string.git_commit_move_multiple_text, relativePath)) + val relativePath = + PasswordRepository.getRelativePath("${target.absolutePath}/", repositoryPath) + commitChange( + resources.getString(R.string.git_commit_move_multiple_text, relativePath) + ) } updateFabSync() getPasswordFragment()?.dismissActionMode() @@ -575,7 +576,8 @@ class PasswordStore : BaseGitActivity() { val sourceDestinationMap = buildSourceDestinationMap(listOf(oldCategory.file to newCategory)) val categoryPreference = getSharedPreferences("recent_password_history", 0) - val categoryTimestamp = categoryPreference.getString(oldCategory.file.absolutePath.base64()) + val categoryTimestamp = + categoryPreference.getString(oldCategory.file.absolutePath.base64()) val plan = passSecretsMutationService.planMove(oldCategory.file, newCategory, repositoryRoot) withContext(dispatcherProvider.io()) { passSecretsMutationService.commitMove(plan) } From 8c78424348d599991784c84107120637d46cd742 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Sat, 5 Sep 2026 15:16:37 +0000 Subject: [PATCH 26/31] fix: replace runCatching with try-catch in PassSecretsMapWriter Replace stdlib runCatching with explicit try-catch for IOException to satisfy slack-lint DenyListedApi rule that flags runCatching in coroutine contexts due to CancellationException handling. --- .../app/passwordstore/passsecrets/PassSecretsMapWriter.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt index 7168ebc6f1..68f4d2cafc 100644 --- a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt @@ -14,6 +14,7 @@ import com.github.michaelbull.result.unwrapError import dagger.Reusable import java.io.ByteArrayOutputStream import java.io.File +import java.io.IOException import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption @@ -156,7 +157,7 @@ constructor( } private fun restoreBestEffort(stagedUpdate: StagedUpdate) { - runCatching { + try { val target = stagedUpdate.update.file val previous = stagedUpdate.originalCiphertext if (previous == null) { @@ -170,6 +171,6 @@ constructor( restore.delete() } } - } + } catch (_: IOException) {} } } From d371515d428f5af6508fa72bb4719a2c655a50a0 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Sat, 5 Sep 2026 15:35:11 +0000 Subject: [PATCH 27/31] fix: suppress DiffUtilEquals lint warning for List comparison The aliases List comparison in areContentsTheSame is safe because Kotlin's List implements equals() with element-wise comparison. --- .../util/viewmodel/SearchableRepositoryViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index 823e35e3c8..3e8b2e5761 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -4,6 +4,7 @@ */ package app.passwordstore.util.viewmodel +import android.annotation.SuppressLint import android.app.Application import android.content.SharedPreferences import android.os.Parcelable @@ -416,6 +417,7 @@ private object PasswordItemDiffCallback : DiffUtil.ItemCallback() override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem.file.absolutePath == newItem.file.absolutePath + @SuppressLint("DiffUtilEquals") override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem.file == newItem.file && oldItem.mappedName == newItem.mappedName && From 11e47fb7fd80327b66b1c908311cd502f3bf1060 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Sat, 5 Sep 2026 15:41:22 +0000 Subject: [PATCH 28/31] fix: add lazy messages to requireNotNull calls in PassSecretsMutationService Satisfies slack-lint ExceptionMessage rule requiring lazyMessage param. --- .../passsecrets/PassSecretsMutationService.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt index f161f9d0bd..e8ce10db2b 100644 --- a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt +++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt @@ -118,9 +118,11 @@ constructor( val newMaps = oldMaps.mapValuesTo(linkedMapOf()) { (_, pair) -> pair.second } if (sourceFile != null && sourceMapFile != null) { - val sourceIdentity = requireNotNull(sourceMapFile.parentFile) + val sourceIdentity = requireNotNull(sourceMapFile.parentFile) { "Source map has no parent" } val sourceKey = - requireNotNull(PassSecretsMapStore.passwordRelativePath(sourceFile, sourceIdentity)) + requireNotNull(PassSecretsMapStore.passwordRelativePath(sourceFile, sourceIdentity)) { + "Cannot resolve relative path for $sourceFile" + } newMaps[sourceMapFile.canonicalPath] = PassSecretsMapStore.mapAfterDelete( newMaps.getValue(sourceMapFile.canonicalPath), @@ -130,7 +132,8 @@ constructor( } if (destinationMapFile != null) { - val destinationIdentity = requireNotNull(destinationMapFile.parentFile) + val destinationIdentity = + requireNotNull(destinationMapFile.parentFile) { "Destination map has no parent" } val destinationKey = passwordRelativePath(destinationFile, destinationIdentity) newMaps[destinationMapFile.canonicalPath] = newMaps.getValue(destinationMapFile.canonicalPath) + (destinationKey to requestedName) @@ -412,7 +415,7 @@ constructor( try { plan.targets.forEach { target -> if (!target.exists()) return@forEach - val parent = requireNotNull(target.parentFile) + val parent = requireNotNull(target.parentFile) { "Delete target has no parent" } val temporary = File(parent, ".aps-delete-${UUID.randomUUID()}-${target.name}") if (!target.renameTo(temporary)) { throw IOException("Could not stage $target for deletion") From e126f95debac38333866233f90882f3b1b2ff192 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Sat, 5 Sep 2026 15:47:15 +0000 Subject: [PATCH 29/31] fix: add lazy messages to requireNotNull calls in PasswordStore Satisfies slack-lint ExceptionMessage rule requiring lazyMessage param. --- .../java/app/passwordstore/ui/passwords/PasswordStore.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt index 9bad275a80..10a5236f41 100644 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt @@ -152,13 +152,13 @@ class PasswordStore : BaseGitActivity() { val (source, destination) = conflict val sourceLongName = PasswordRepository.getLongName( - requireNotNull(source.parent), + requireNotNull(source.parent) { "Move source has no parent" }, repositoryPath, source.nameWithoutExtension, ) val destinationLongName = PasswordRepository.getLongName( - requireNotNull(destination.parent), + requireNotNull(destination.parent) { "Move destination has no parent" }, repositoryPath, destination.nameWithoutExtension, ) @@ -484,13 +484,13 @@ class PasswordStore : BaseGitActivity() { val (source, destination) = moves.single() val sourceLongName = PasswordRepository.getLongName( - requireNotNull(source.parent), + requireNotNull(source.parent) { "Move source has no parent" }, repositoryPath, source.nameWithoutExtension, ) val destinationLongName = PasswordRepository.getLongName( - requireNotNull(destination.parent), + requireNotNull(destination.parent) { "Move destination has no parent" }, repositoryPath, destination.nameWithoutExtension, ) From 76d0e2671f7956cccf8a7ad9c5a6f795bff772b5 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Sat, 5 Sep 2026 15:52:41 +0000 Subject: [PATCH 30/31] fix: remove unused password_move_error string resources These strings were used by the old moveFile function which was replaced by the PassSecrets-aware move implementation. --- app/src/main/res/values-ca/strings.xml | 2 -- app/src/main/res/values-de/strings.xml | 2 -- app/src/main/res/values-es/strings.xml | 2 -- app/src/main/res/values-fr/strings.xml | 2 -- app/src/main/res/values-gl/strings.xml | 2 -- app/src/main/res/values-it/strings.xml | 2 -- app/src/main/res/values-ko-rKR/strings.xml | 2 -- app/src/main/res/values-pl-rPL/strings.xml | 2 -- app/src/main/res/values-pt-rBR/strings.xml | 2 -- app/src/main/res/values-ru/strings.xml | 2 -- app/src/main/res/values-tr/strings.xml | 2 -- app/src/main/res/values-zh-rCN/strings.xml | 2 -- app/src/main/res/values/strings.xml | 2 -- 13 files changed, 26 deletions(-) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 574956d336..d9838f488c 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -29,8 +29,6 @@ Elimina La contrasenya ja existeix. Aquesta acció sobreescriurà %1$s amb %2$s. - Error al moure contrasenyes - No s\'ha pogut moure %1$s a %2$s Afegeix contrasenya generada per %1$s usant Android Password Store Edita contrasenya per %1$s usant Android Password Store. Suprimeix %1$s del magatzem. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1c74a595e9..a5d3ebc4ca 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -34,8 +34,6 @@ Fehler Passwort existiert bereits! Dies wird %1$s mit %2$s überschreiben. - Fehler beim Verschieben von Passwörtern - Verschieben von %1$s nach %2$s fehlgeschlagen. Füge erstelltes Passwort für %1$s mittels Android Password Store hinzu. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 649ddd267d..c1504b2cbb 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -35,8 +35,6 @@ Eliminar ¡La contraseña ya existe! Esto sobrescribirá %1$s con %2$s. - Error al mover contraseñas - No se pudo mover %1$s a %2$s Agregue la contraseña generada para %1$s usando Android Password Store. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6c7e315917..f175e35039 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -28,8 +28,6 @@ Supprimer Le mot de passe existe! Cela écrasera %1$s avec %2$s. - Erreur lors du déplacement des mots de passe - Impossible de déplacer %1$s vers %2$s Ajout par Android Password Store d\'un mot de passe pour %1$s. Modification par Android Password Store du mot de passe pour %1$s. diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index b7fb810a3f..ce4e3c6ded 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -28,8 +28,6 @@ Eliminar O contrasinal xa existe! Vas sobrescribir %1$s con %2$s. - Erro ao mover os contrasinais - Non se mudou %1$s por %2$s Engadir o contrasinal creado para %1$s usando Android Password Store. Editar contrasinal para %1$s usando Android Password Store. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ef1c2a9736..860a537c0c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -28,8 +28,6 @@ Elimina La password esiste già! Questo sovrascriverà %1$s con %2$s. - Errore spostando le password - Impossibile spostare %1$s a %2$s Aggiungi password generata per %1$s usando Android Password Store. Modifica password per %1$s usando Android Password Store. diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 85b2ae89a1..0a4762195b 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -26,8 +26,6 @@ 삭제 이미 추가된 비밀번호입니다! %1$s 내용이 %2$s 로 덮어씌워집니다. - 비밀번호를 옮기는 중 문제가 발생하였습니다. - %1$s 를 %2$s 로 옮기는데 실패하였습니다. Android Password Store 를 이용해 %1$s 의 비밀번호를 생성하였습니다. Android Password Store 를 이용해 %1$s 의 비밀번호를 변경하였습니다. diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 0610c2f92a..d0c089a670 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -27,8 +27,6 @@ Usuń Hasło już istnieje! %1$s zostanie nadpisane przez %2$s. - Błąd podczas przenoszenia haseł - Nie udało się przenieść %1$s do %2$s Dodaj wygenerowane hasło dla %1$s przy użyciu Android Password Store. Edytuj hasło dla %1$s za pomocą Android Password Store. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index fb7441153d..47304f79ff 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -28,8 +28,6 @@ Excluir Senha já existe! Isso irá sobrescrever %1$s com %2$s. - Erro ao mover senhas - Falha ao mover %1$s para %2$s Adicionar senha gerada para %1$s usando o Android Password Store. Editar a senha para %1$s usando o Android Password Store. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index daeaca0b2a..9883721689 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -32,8 +32,6 @@ Удалить Пароль уже существует! Это перезапишет %1$s на %2$s - Ошибка при перемещении паролей - Не удалось переместить %1$s в %2$s Добавлен пароль %1$s из хранилища. Отредактирован %1$s из хранилища. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index be653d69a7..cbc80316c4 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -34,8 +34,6 @@ Sil Şifre zaten mevcut! Bu işlem, %1$s üzerine %2$s yazacaktır. - Şifreleri taşırken hata oluştu - %1$s, %2$s konumuna taşınamadı %1$s için oluşturulan şifre Android Password Store kullanılarak eklendi. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0a7e470805..f7dd8a532f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -27,8 +27,6 @@ 删除 密码已经存在! 这将用%2$s覆盖%1$s - 移动密码时出错 - 未能将%1$s移动到%2$s 使用Android密码仓库为%1$s生成密码 使用Android密码仓库编辑%1$s的密码。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36a1ab5d42..9174fa4130 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,8 +35,6 @@ Error Password already exists! This will overwrite %1$s with %2$s. - Error while moving passwords - Failed to move %1$s to %2$s Add generated password for %1$s using Android Password Store. From 2e3e65429bfb691bd014e80d8186bee541f699f3 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Sat, 5 Sep 2026 15:56:35 +0000 Subject: [PATCH 31/31] fix: remove unused password_creation_duplicate_error string resource --- app/src/main/res/values-ca/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-gl/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-ko-rKR/strings.xml | 1 - app/src/main/res/values-pl-rPL/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - 13 files changed, 13 deletions(-) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index d9838f488c..ee460b7528 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -250,7 +250,6 @@ L\'emplenament automàtic distingirà subdominis d\'aquests dominis company.com\npersonal.com No s\'ha pogut escriute el fitxer de contrasenya al magatzem, prova un altre cop. - El fitxer ja existeix, usa un nom diferent Afegeix OTP S\'ha importat al configuració OTP correctament No s\'ha pogut importar la configuració OTP diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a5d3ebc4ca..6908e15e42 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -331,7 +331,6 @@ Fehler beim Speichern der Passwortdatei im Password Store, bitte versuchen Sie es erneut. Verschlüsseln fehlgeschlagen. Überprüfen Sie Ablaufdatum und Widerrufstatus der PGP-Schlüssel mithilfe eines externen Programms. Keine passenden PGP-Schlüssel gefunden. - Datei existiert bereits, bitte verwenden Sie einen anderen Dateinamen. OTP hinzufügen TOTP-Konfiguration erfolgreich importiert Import der TOTP-Konfiguration fehlgeschlagen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c1504b2cbb..abdcd125a5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -293,7 +293,6 @@ No se pudo escribir el archivo de contraseña en el almacén, inténtelo de nuevo. - El archivo ya existe, por favor use un nombre diferente Agregar TOTP Configuración de TOTP importada correctamente No se pudo importar la configuración de TOTP diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f175e35039..fedee3d4ef 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -244,7 +244,6 @@ societe.com\npersonnel.com Impossible d\'écrire le fichier de mot de passe dans le magasin, veuillez réessayer. - Le fichier existe déjà, veuillez utiliser un autre nom Ajouter OTP Configuration TOTP importée avec succès Impossible d\'importer la configuration TOTP diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index ce4e3c6ded..8abfe32de4 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -252,7 +252,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid empresa.com\npersoal.com Fallo ó escribir o ficheiro de contrasinal no almacén, inténtao outra vez. - Xa existe o ficheiro, usa un nome diferente Engade OTP Importouse correctamente a configuración TOTP Fallou a importación da configuración TOTP diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 860a537c0c..48d8ddddc9 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -239,7 +239,6 @@ company.com\npersonal.com Impossibile scrivere il file delle password al negozio, sei pregato di riprovare. - Il file esiste già, sei pregato di usare un nome differente Aggiungi OTP Configurazione TOTP importata correttamente Impossibile importare la configurazione di TOTP diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 0a4762195b..7733b121ff 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -251,7 +251,6 @@ username@example.com:… company.com\npersonal.com 스토어에 비밀번호 파일을 작성하지 못했습니다. 다시 시도해 주세요. - 파일이 이미 존재합니다. 다른 이름을 사용하세요 OTP 추가 TOTP 구성을 성공적으로 가져왔습니다 TOTP 구성을 가져오지 못했습니다. diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index d0c089a670..85cf3d1b88 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -246,7 +246,6 @@ Autouzupełnianie rozróżni subdomeny tych domen Nie udało się zapisać pliku z hasłem do repozytorium, spróbuj ponownie. - Plik o tej nazwie już istnieje, użyj innej nazwy Dodaj OTP Pomyślnie zaimportowano konfigurację TOTP Znaleziono plik .gpg-id, ale zawiera on nieprawidłowy lub nieznany identyfikator klucza. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 47304f79ff..c2da2db0f3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -250,7 +250,6 @@ company.com\npersonal.com Falha ao armazenar o arquivo de senha. Por favor, tente novamente. - O arquivo já existe, por favor use um nome diferente Adicionar OTP Configuração TOTP importada com sucesso Falha ao importar a configuração TOTP diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9883721689..41f45bf3c3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -248,7 +248,6 @@ company.com\npersonal.com Не удалось записать файл пароля в хранилище, пожалуйста, повторите попытку. - Файл с таким названием уже существует! Используйте другое имя Добавить OTP Конфигурация TOTP успешно импортирована Не удалось импортировать конфигурацию TOTP diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index cbc80316c4..1bf6b3e2e0 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -288,7 +288,6 @@ Şifre dosyası depoya yazılamadı, lütfen tekrar deneyin. - Dosya zaten mevcut, lütfen farklı bir ad kullanın OTP ekle TOTP yapılandırması başarıyla içe aktarıldı TOTP yapılandırması içe aktarılamadı diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f7dd8a532f..3d929f49b3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -254,7 +254,6 @@ personal.com 将密码文件写入存储失败,请重试 - 文件已存在,请使用其他名称 添加 OTP 导入TOTP配置成功 导入TOTP配置失败 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9174fa4130..d616321c7f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -345,7 +345,6 @@ Failed to write password file to the store, please try again. Encryption failure. Check expiration date and revocation status of the PGP key(s) using an external utility. No matching PGP keys found. - File already exists, please use a different name. Add OTP Successfully imported TOTP configuration Failed to import TOTP configuration