Skip to content
19 changes: 14 additions & 5 deletions app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -229,7 +234,9 @@ open class BasePGPActivity : AppCompatActivity() {
subDir: String,
onKeysExist: (List<PGPIdentifier>) -> 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) {
Expand All @@ -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
Expand Down Expand Up @@ -286,7 +293,9 @@ open class BasePGPActivity : AppCompatActivity() {
subDir: String,
onKeysExist: (List<PGPIdentifier>) -> 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) {
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/app/passwordstore/ui/crypto/GpgIdScope.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
}
94 changes: 94 additions & 0 deletions app/src/test/java/app/passwordstore/ui/crypto/GpgIdScopeTest.kt
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> { resolveGpgIdScope(root, outside, "") }
}
}
Loading