diff --git a/app/build.gradle b/app/build.gradle index ac5dcb91..c966ed43 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -121,6 +121,7 @@ dependencies { // Testing-only dependencies androidTestImplementation deps.testing.junit + androidTestImplementation deps.testing.kotlin_test_junit androidTestImplementation deps.testing.androidx.runner androidTestImplementation deps.testing.androidx.rules androidTestImplementation deps.testing.androidx.junit diff --git a/app/src/androidTest/java/com/zeapo/pwdstore/StrictDomainRegexTest.kt b/app/src/androidTest/java/com/zeapo/pwdstore/StrictDomainRegexTest.kt new file mode 100644 index 00000000..c6fa1051 --- /dev/null +++ b/app/src/androidTest/java/com/zeapo/pwdstore/StrictDomainRegexTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore + +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.Test as test + +private infix fun String.matchedForDomain(domain: String) = + SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true + +class StrictDomainRegexTest { + @test fun acceptsLiteralDomain() { + assertTrue("work/example.org/john.doe@example.org.gpg" matchedForDomain "example.org") + assertTrue("example.org/john.doe@example.org.gpg" matchedForDomain "example.org") + assertTrue("example.org.gpg" matchedForDomain "example.org") + } + + @test fun acceptsSubdomains() { + assertTrue("work/www.example.org/john.doe@example.org.gpg" matchedForDomain "example.org") + assertTrue("www2.example.org/john.doe@example.org.gpg" matchedForDomain "example.org") + assertTrue("www.login.example.org.gpg" matchedForDomain "example.org") + } + + @test fun rejectsPhishingAttempts() { + assertFalse("example.org.gpg" matchedForDomain "xample.org") + assertFalse("login.example.org.gpg" matchedForDomain "xample.org") + assertFalse("example.org/john.doe@exmple.org.gpg" matchedForDomain "xample.org") + assertFalse("example.org.gpg" matchedForDomain "e/xample.org") + } + + @test fun rejectNonGpgComponentMatches() { + assertFalse("work/example.org" matchedForDomain "example.org") + } + + @test fun rejectsEmailAddresses() { + assertFalse("work/notexample.org/john.doe@example.org.gpg" matchedForDomain "example.org") + assertFalse("work/notexample.org/john.doe@www.example.org.gpg" matchedForDomain "example.org") + assertFalse("work/john.doe@www.example.org/foo.org" matchedForDomain "example.org") + } + + @test fun rejectsPathSeparators() { + assertNull(SearchableRepositoryViewModel.generateStrictDomainRegex("ex/ample.org")) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt index 992a5f79..47170717 100644 --- a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt @@ -126,6 +126,28 @@ enum class ListMode { @FlowPreview class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) { + companion object { + + fun generateStrictDomainRegex(domain: String): Regex? { + // Valid domains do not contain path separators. + if (domain.contains('/')) + return null + // Matches the start of a path component, which is either the start of the + // string or a path separator. + val prefix = """(?:^|/)""" + val escapedFilter = Regex.escape(domain.replace("/", "")) + // Matches either the filter literally or a strict subdomain of the filter term. + // We allow a lot of freedom in what a subdomain is, as long as it is not an + // email address. + val subdomain = """(?:(?:[^/@]+\.)?$escapedFilter)""" + // Matches the end of a path component, which is either the literal ".gpg" or a + // path separator. + val suffix = """(?:\.gpg|/)""" + // Match any relative path with a component that is a subdomain of the filter. + return Regex(prefix + subdomain + suffix) + } + } + private var _updateCounter = 0 private val updateCounter: Int get() = _updateCounter @@ -219,22 +241,18 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel } FilterMode.StrictDomain -> { check(searchAction.listMode == ListMode.FilesOnly) { "Searches with StrictDomain search mode can only list files" } - prefilteredResultFlow - .filter { absoluteFile -> - val file = absoluteFile.relativeTo(root) - val toMatch = - directoryStructure.getIdentifierFor(file) ?: return@filter false - // In strict domain mode, we match - // * the search term exactly, - // * subdomains of the search term, - // * or the search term plus an arbitrary protocol. - toMatch == searchAction.filter || - toMatch.endsWith(".${searchAction.filter}") || - toMatch.endsWith("://${searchAction.filter}") - } - .map { it.toPasswordItem(root) } - .toList() - .sortedWith(itemComparator) + val regex = generateStrictDomainRegex(searchAction.filter) + if (regex != null) { + prefilteredResultFlow + .filter { absoluteFile -> + regex.containsMatchIn(absoluteFile.relativeTo(root).path) + } + .map { it.toPasswordItem(root) } + .toList() + .sortedWith(itemComparator) + } else { + emptyList() + } } FilterMode.Fuzzy -> { prefilteredResultFlow diff --git a/build.gradle b/build.gradle index b8da46dc..15c49cf0 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,8 @@ subprojects { targetSdkVersion versions.targetSdk versionCode versions.versionCode versionName versions.versionName + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 diff --git a/dependencies.gradle b/dependencies.gradle index d57eeeaa..d50129a6 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -67,6 +67,7 @@ ext.deps = [ testing: [ junit: 'junit:junit:4.13', + kotlin_test_junit: 'org.jetbrains.kotlin:kotlin-test-junit:1.3.71', androidx: [ runner: 'androidx.test:runner:1.3.0-alpha05', rules: 'androidx.test:rules:1.3.0-alpha05',