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 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..a033d00c0 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt @@ -0,0 +1,27 @@ +/* + * 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 { + // 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 = + 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.isEmpty() || relativePath == ".") "/" else relativePath +} 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 + } +} 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..164ba9650 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt @@ -0,0 +1,94 @@ +/* + * 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.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 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, "/ID-Pessoal/HG")) + } + + @Test + fun preservesLegacyDecryptionParentScope() { + 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 preservesAnyExplicitScopeWithoutNormalization() { + val root = createTempDirectory().toFile() + val nested = File(root, "ID-Pessoal/HG").apply { mkdirs() } + + 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 + fun recoversWhitespaceNamedDirectoryWithoutTreatingItAsRoot() { + val root = createTempDirectory().toFile() + val nested = File(root, " ").apply { mkdirs() } + + assertEquals(" ", resolveGpgIdScope(root, nested, "")) + } + + @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, "") } + } +}