From 50a62d08e78c20b354728905f19c999d15f507a2 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Thu, 24 Sep 2026 07:55:41 +0200 Subject: [PATCH 01/11] fix: preserve GPG recipient scope --- .../app/passwordstore/ui/crypto/GpgIdScope.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt diff --git a/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt b/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt new file mode 100644 index 000000000..daa59befe --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt @@ -0,0 +1,24 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.crypto + +import java.io.File + +/** Resolve the directory scope used for hierarchical `.gpg-id` lookup and creation. */ +internal fun resolveGpgIdScope(repoRoot: File, operationPath: File, subDir: String): String { + if (subDir.isNotBlank()) return subDir + + val root = repoRoot.canonicalFile + val operationDirectory = + if (operationPath.isDirectory) operationPath.canonicalFile + else operationPath.parentFile?.canonicalFile ?: root + + require(operationDirectory.toPath().startsWith(root.toPath())) { + "GPG recipient scope must stay inside the password repository" + } + + val relativePath = operationDirectory.relativeTo(root).invariantSeparatorsPath + return if (relativePath == ".") "/" else relativePath +} From fe91caed18dd27299e727d91c0542f7ffea029b6 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Thu, 24 Sep 2026 07:55:54 +0200 Subject: [PATCH 02/11] test: cover nested GPG recipient scopes --- .../passwordstore/ui/crypto/GpgIdScopeTest.kt | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt diff --git a/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt b/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt new file mode 100644 index 000000000..fffbb29fb --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.crypto + +import java.io.File +import kotlin.io.path.createDirectories +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class GpgIdScopeTest { + + @Test + fun preservesExplicitNestedScope() { + val root = createTempDirectory().toFile() + val nested = File(root, "ID-Pessoal/HG").apply { mkdirs() } + + assertEquals( + "ID-Pessoal/HG", + resolveGpgIdScope(root, nested, "ID-Pessoal/HG"), + ) + } + + @Test + fun recoversNestedScopeWhenSubDirIsLost() { + val root = createTempDirectory().toFile() + val nested = File(root, "ID-Pessoal/HG").apply { mkdirs() } + + assertEquals("ID-Pessoal/HG", resolveGpgIdScope(root, nested, "")) + } + + @Test + fun recoversParentScopeForExistingPassword() { + val root = createTempDirectory().toFile() + val nested = File(root, "ID-Pessoal/HG").apply { mkdirs() } + val password = File(nested, "example.gpg").apply { writeText("encrypted") } + + assertEquals("ID-Pessoal/HG", resolveGpgIdScope(root, password, "")) + } + + @Test + fun keepsExplicitRootScope() { + val root = createTempDirectory().toFile() + + assertEquals("/", resolveGpgIdScope(root, root, "/")) + } + + @Test + fun emptyScopeAtRepositoryRootUsesRootMarker() { + val root = createTempDirectory().toFile() + + assertEquals("/", resolveGpgIdScope(root, root, "")) + } + + @Test + fun rejectsRecoveredScopeOutsideRepository() { + val root = createTempDirectory().toFile() + val outside = createTempDirectory().resolve("entry.gpg").toFile().apply { writeText("encrypted") } + + assertFailsWith { resolveGpgIdScope(root, outside, "") } + } +} From a8bfc6624b33fff9e910e8026c0c520c30630c84 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Thu, 24 Sep 2026 07:57:33 +0200 Subject: [PATCH 03/11] fix: recover nested GPG scope during key selection --- .../ui/crypto/BasePGPActivity.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt index 1d736a3d3..b5a0aae30 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -123,7 +123,12 @@ open class BasePGPActivity : AppCompatActivity() { ?: return@registerForActivityResult val repoRoot = PasswordRepository.getRepositoryDirectory() - val subPath = data.getStringExtra("SUB_PATH") ?: return@registerForActivityResult + val subPath = + resolveGpgIdScope( + repoRoot, + File(fullPath), + data.getStringExtra("SUB_PATH").orEmpty(), + ) val gpgIdDir = File(repoRoot, subPath).let { if (it.isFile()) it.getParent() else it.getPath() } @@ -229,7 +234,9 @@ open class BasePGPActivity : AppCompatActivity() { subDir: String, onKeysExist: (List) -> Unit, ) { - val ids = getPGPIdentifiers(subDir) + val resolvedSubDir = + resolveGpgIdScope(PasswordRepository.getRepositoryDirectory(), File(fullPath), subDir) + val ids = getPGPIdentifiers(resolvedSubDir) if (ids.isNullOrEmpty()) { val (title, message) = if (ids == null) { @@ -241,7 +248,7 @@ open class BasePGPActivity : AppCompatActivity() { } openKeyManagerDialog(title, message) { val intent = PGPKeyListActivity.newIntent(this@BasePGPActivity, keySelection = true) - intent.putExtra("SUB_PATH", subDir) + intent.putExtra("SUB_PATH", resolvedSubDir) keySelectAction.launch(intent) } return @@ -286,7 +293,9 @@ open class BasePGPActivity : AppCompatActivity() { subDir: String, onKeysExist: (List) -> Unit, ) { - val ids = getPGPIdentifiers(subDir) + val resolvedSubDir = + resolveGpgIdScope(PasswordRepository.getRepositoryDirectory(), File(fullPath), subDir) + val ids = getPGPIdentifiers(resolvedSubDir) if (ids.isNullOrEmpty()) { val (title, message) = if (ids == null) { @@ -298,7 +307,7 @@ open class BasePGPActivity : AppCompatActivity() { } openKeyManagerDialog(title, message) { val intent = PGPKeyListActivity.newIntent(this@BasePGPActivity, keySelection = true) - intent.putExtra("SUB_PATH", subDir) + intent.putExtra("SUB_PATH", resolvedSubDir) keySelectAction.launch(intent) } return From 140e6c8eef41578a2b2950496c2e67c22fdaf350 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Thu, 24 Sep 2026 07:57:56 +0200 Subject: [PATCH 04/11] test: fix GPG scope test imports --- app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt b/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt index fffbb29fb..6178ded28 100644 --- a/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt +++ b/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt @@ -5,7 +5,6 @@ package app.passwordstore.ui.crypto import java.io.File -import kotlin.io.path.createDirectories import kotlin.io.path.createTempDirectory import kotlin.test.Test import kotlin.test.assertEquals From 4765d2fd1f2b896b710eb551096be8a4a2804f75 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Thu, 24 Sep 2026 07:59:30 +0200 Subject: [PATCH 05/11] style: format GPG scope tests --- .../test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt b/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt index 6178ded28..1f6766924 100644 --- a/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt +++ b/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt @@ -57,7 +57,8 @@ class GpgIdScopeTest { @Test fun rejectsRecoveredScopeOutsideRepository() { val root = createTempDirectory().toFile() - val outside = createTempDirectory().resolve("entry.gpg").toFile().apply { writeText("encrypted") } + val outside = + createTempDirectory().resolve("entry.gpg").toFile().apply { writeText("encrypted") } assertFailsWith { resolveGpgIdScope(root, outside, "") } } From 71683751cf469f8a62c4bb8bf22f8cb6412e14b8 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Thu, 24 Sep 2026 06:13:47 +0000 Subject: [PATCH 06/11] fix: handle empty relative path in GPG scope resolution --- app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt b/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt index daa59befe..4535c7963 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt @@ -20,5 +20,5 @@ internal fun resolveGpgIdScope(repoRoot: File, operationPath: File, subDir: Stri } val relativePath = operationDirectory.relativeTo(root).invariantSeparatorsPath - return if (relativePath == ".") "/" else relativePath + return if (relativePath.isBlank() || relativePath == ".") "/" else relativePath } From 11e381a3635ab91dd994d0aaa078d1285738d893 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Thu, 24 Sep 2026 08:15:14 +0200 Subject: [PATCH 07/11] fix: preserve legacy explicit gpg-id scopes --- app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt b/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt index 4535c7963..02ff6a3c5 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt @@ -8,7 +8,10 @@ import java.io.File /** Resolve the directory scope used for hierarchical `.gpg-id` lookup and creation. */ internal fun resolveGpgIdScope(repoRoot: File, operationPath: File, subDir: String): String { - if (subDir.isNotBlank()) return subDir + // Existing callers have historically passed explicit scopes such as "/", "/foo", and + // "/foo/". Preserve every explicit value exactly as supplied and recover only the invalid + // empty state that can otherwise collapse to the physical repository root. + if (subDir.isNotEmpty()) return subDir val root = repoRoot.canonicalFile val operationDirectory = From de912bf80631f6641925b72cb4b692078b4754ae Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Thu, 24 Sep 2026 08:15:28 +0200 Subject: [PATCH 08/11] test: lock down legacy gpg-id scope behavior --- .../passwordstore/ui/crypto/GpgIdScopeTest.kt | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt b/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt index 1f6766924..24c8d302e 100644 --- a/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt +++ b/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt @@ -17,34 +17,55 @@ class GpgIdScopeTest { val root = createTempDirectory().toFile() val nested = File(root, "ID-Pessoal/HG").apply { mkdirs() } - assertEquals( - "ID-Pessoal/HG", - resolveGpgIdScope(root, nested, "ID-Pessoal/HG"), - ) + assertEquals("ID-Pessoal/HG", resolveGpgIdScope(root, nested, "ID-Pessoal/HG")) } @Test - fun recoversNestedScopeWhenSubDirIsLost() { + fun preservesLegacyRootScope() { + val root = createTempDirectory().toFile() + + assertEquals("/", resolveGpgIdScope(root, root, "/")) + } + + @Test + fun preservesLegacyCreationDirectoryScope() { val root = createTempDirectory().toFile() val nested = File(root, "ID-Pessoal/HG").apply { mkdirs() } - assertEquals("ID-Pessoal/HG", resolveGpgIdScope(root, nested, "")) + assertEquals("/ID-Pessoal/HG", resolveGpgIdScope(root, nested, "/ID-Pessoal/HG")) } @Test - fun recoversParentScopeForExistingPassword() { + fun preservesLegacyDecryptionParentScope() { val root = createTempDirectory().toFile() val nested = File(root, "ID-Pessoal/HG").apply { mkdirs() } - val password = File(nested, "example.gpg").apply { writeText("encrypted") } - assertEquals("ID-Pessoal/HG", resolveGpgIdScope(root, password, "")) + assertEquals("/ID-Pessoal/HG/", resolveGpgIdScope(root, nested, "/ID-Pessoal/HG/")) } @Test - fun keepsExplicitRootScope() { + fun preservesAnyExplicitScopeWithoutNormalization() { val root = createTempDirectory().toFile() + val nested = File(root, "ID-Pessoal/HG").apply { mkdirs() } - assertEquals("/", resolveGpgIdScope(root, root, "/")) + assertEquals(" ", resolveGpgIdScope(root, nested, " ")) + } + + @Test + fun recoversNestedScopeWhenSubDirIsLost() { + val root = createTempDirectory().toFile() + val nested = File(root, "ID-Pessoal/HG").apply { mkdirs() } + + assertEquals("ID-Pessoal/HG", resolveGpgIdScope(root, nested, "")) + } + + @Test + fun recoversParentScopeForExistingPassword() { + val root = createTempDirectory().toFile() + val nested = File(root, "ID-Pessoal/HG").apply { mkdirs() } + val password = File(nested, "example.gpg").apply { writeText("encrypted") } + + assertEquals("ID-Pessoal/HG", resolveGpgIdScope(root, password, "")) } @Test From 03687d72f08afedef65cadecd344b5335916607b Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Thu, 24 Sep 2026 08:15:53 +0200 Subject: [PATCH 09/11] fix: recover only truly empty gpg-id scopes --- app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt b/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt index 02ff6a3c5..a033d00c0 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt @@ -23,5 +23,5 @@ internal fun resolveGpgIdScope(repoRoot: File, operationPath: File, subDir: Stri } val relativePath = operationDirectory.relativeTo(root).invariantSeparatorsPath - return if (relativePath.isBlank() || relativePath == ".") "/" else relativePath + return if (relativePath.isEmpty() || relativePath == ".") "/" else relativePath } From 3fbd803e3494ec50c95e356bfee6446b8f3ea5f3 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Thu, 24 Sep 2026 08:16:13 +0200 Subject: [PATCH 10/11] test: cover exact empty-scope recovery semantics --- .../java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt b/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt index 24c8d302e..164ba9650 100644 --- a/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt +++ b/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt @@ -68,6 +68,14 @@ class GpgIdScopeTest { assertEquals("ID-Pessoal/HG", resolveGpgIdScope(root, password, "")) } + @Test + fun recoversWhitespaceNamedDirectoryWithoutTreatingItAsRoot() { + val root = createTempDirectory().toFile() + val nested = File(root, " ").apply { mkdirs() } + + assertEquals(" ", resolveGpgIdScope(root, nested, "")) + } + @Test fun emptyScopeAtRepositoryRootUsesRootMarker() { val root = createTempDirectory().toFile() From ec0d169c6c95e4ce63c3a25d6444b2cbf600bdb6 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Thu, 24 Sep 2026 08:26:08 +0200 Subject: [PATCH 11/11] test: protect pre-Pass-Secrets nested identity behavior --- ...PassSecretsLegacyStoreCompatibilityTest.kt | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 app/src/test/java/app/passwordstore/passsecrets/PassSecretsLegacyStoreCompatibilityTest.kt diff --git a/app/src/test/java/app/passwordstore/passsecrets/PassSecretsLegacyStoreCompatibilityTest.kt b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsLegacyStoreCompatibilityTest.kt new file mode 100644 index 000000000..ec87d5979 --- /dev/null +++ b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsLegacyStoreCompatibilityTest.kt @@ -0,0 +1,51 @@ +/* + * 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 +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNull +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +/** Regression coverage for password-store layouts that predate Pass-Secrets integration. */ +class PassSecretsLegacyStoreCompatibilityTest { + + @get:Rule val tempFolder = TemporaryFolder() + + private lateinit var root: File + + @BeforeTest + fun setup() { + PassSecretsMapStore.clear() + root = tempFolder.newFolder("store") + } + + @AfterTest + fun tearDown() { + PassSecretsMapStore.clear() + } + + @Test + fun `independent nested identities without Pass-Secrets metadata remain untouched`() { + val hg = identity("ID-Pessoal/HG") + val vupon = identity("ID-Pessoal/Vupon") + val work = identity("ID-Work/ORGs") + + assertNull(PassSecretsMapStore.claimForDirectory(hg, root)) + assertNull(PassSecretsMapStore.claimForDirectory(vupon, root)) + assertNull(PassSecretsMapStore.claimForDirectory(work, root)) + assertFalse(File(root, ".gpg-id").exists()) + } + + private fun identity(relativePath: String): File { + val directory = File(root, relativePath).apply { mkdirs() } + File(directory, ".gpg-id").writeText("0123456789ABCDEF\n") + return directory + } +}