BIT-621: Add URI matching for autofill (#842)

This commit is contained in:
Lucas Kivi 2024-01-29 15:33:27 -06:00 committed by Álison Fernandes
parent 0e5e6b4444
commit 0c6ea8d18d
19 changed files with 26674 additions and 95 deletions

View file

@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor
import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessorImpl
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProviderImpl
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@ -60,10 +61,12 @@ object AutofillModule {
@Provides
fun providesAutofillCipherProvider(
authRepository: AuthRepository,
cipherMatchingManager: CipherMatchingManager,
vaultRepository: VaultRepository,
): AutofillCipherProvider =
AutofillCipherProviderImpl(
authRepository = authRepository,
cipherMatchingManager = cipherMatchingManager,
vaultRepository = vaultRepository,
)

View file

@ -4,7 +4,7 @@ import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.platform.util.takeIfUriMatches
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import kotlinx.coroutines.flow.first
@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.first
*/
class AutofillCipherProviderImpl(
private val authRepository: AuthRepository,
private val cipherMatchingManager: CipherMatchingManager,
private val vaultRepository: VaultRepository,
) : AutofillCipherProvider {
private val activeUserId: String? get() = authRepository.activeUserId
@ -35,7 +36,8 @@ class AutofillCipherProviderImpl(
return cipherViews
.mapNotNull { cipherView ->
cipherView
.takeIf { cipherView.type == CipherType.CARD }
// We only care about non-deleted card ciphers.
.takeIf { cipherView.type == CipherType.CARD && cipherView.deletedDate == null }
?.let { nonNullCipherView ->
AutofillCipher.Card(
name = nonNullCipherView.name,
@ -54,26 +56,25 @@ class AutofillCipherProviderImpl(
uri: String,
): List<AutofillCipher.Login> {
val cipherViews = getUnlockedCiphersOrNull() ?: return emptyList()
// We only care about non-deleted login ciphers.
val loginCiphers = cipherViews
.filter { it.type == CipherType.LOGIN && it.deletedDate == null }
return cipherViews
.mapNotNull { cipherView ->
cipherView
.takeIf { cipherView.type == CipherType.LOGIN }
// TODO: Get global URI matching value from settings repo and
// TODO: perform more complex URI matching here (BIT-1461).
?.takeIfUriMatches(
uri = uri,
return cipherMatchingManager
// Filter for ciphers that match the uri in some way.
.filterCiphersForMatches(
ciphers = loginCiphers,
matchUri = uri,
)
?.let { nonNullCipherView ->
.map { cipherView ->
AutofillCipher.Login(
name = nonNullCipherView.name,
password = nonNullCipherView.login?.password.orEmpty(),
subtitle = nonNullCipherView.subtitle.orEmpty(),
username = nonNullCipherView.login?.username.orEmpty(),
name = cipherView.name,
password = cipherView.login?.password.orEmpty(),
subtitle = cipherView.subtitle.orEmpty(),
username = cipherView.login?.username.orEmpty(),
)
}
}
}
/**
* Get available [CipherView]s if possible.

View file

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.platform.manager.ciphermatching
import com.bitwarden.core.CipherView
/**
* A manager for matching ciphers based on special criteria.
*/
interface CipherMatchingManager {
/**
* Filter [ciphers] for entries that match the [matchUri] in some fashion.
*/
suspend fun filterCiphersForMatches(
ciphers: List<CipherView>,
matchUri: String,
): List<CipherView>
}

View file

@ -0,0 +1,274 @@
package com.x8bit.bitwarden.data.platform.manager.ciphermatching
import android.content.Context
import com.bitwarden.core.CipherView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.getDomainOrNull
import com.x8bit.bitwarden.data.platform.util.getHostWithPortOrNull
import com.x8bit.bitwarden.data.platform.util.getWebHostFromAndroidUriOrNull
import com.x8bit.bitwarden.data.platform.util.isAndroidApp
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.util.toSdkUriMatchType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
import kotlin.text.Regex
import kotlin.text.RegexOption
import kotlin.text.isNullOrBlank
import kotlin.text.lowercase
import kotlin.text.matches
import kotlin.text.startsWith
/**
* The default [CipherMatchingManager] implementation. This class is responsible for matching
* ciphers based on special criteria.
*/
class CipherMatchingManagerImpl(
private val context: Context,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
) : CipherMatchingManager {
override suspend fun filterCiphersForMatches(
ciphers: List<CipherView>,
matchUri: String,
): List<CipherView> {
val equivalentDomainsData = vaultRepository
.domainsStateFlow
.mapNotNull { it.data }
.first()
val isAndroidApp = matchUri.isAndroidApp()
val defaultUriMatchType = settingsRepository.defaultUriMatchType.toSdkUriMatchType()
val domain = matchUri
.getDomainOrNull(context = context)
?.lowercase()
// Retrieve domains that are considered equivalent to the specified matchUri for cipher
// comparison. If a cipher doesn't have a URI matching the matchUri, but matches a domain in
// matchingDomains, it's considered a match.
val matchingDomains = getMatchingDomains(
domainsData = equivalentDomainsData,
isAndroidApp = isAndroidApp,
matchDomain = domain,
matchUri = matchUri,
)
val exactMatchingCiphers = mutableListOf<CipherView>()
val fuzzyMatchingCiphers = mutableListOf<CipherView>()
ciphers
.forEach { cipherView ->
val matchResult = checkForCipherMatch(
cipherView = cipherView,
context = context,
defaultUriMatchType = defaultUriMatchType,
isAndroidApp = isAndroidApp,
matchUri = matchUri,
matchingDomains = matchingDomains,
)
when (matchResult) {
MatchResult.EXACT -> exactMatchingCiphers.add(cipherView)
MatchResult.FUZZY -> fuzzyMatchingCiphers.add(cipherView)
MatchResult.NONE -> Unit
}
}
return exactMatchingCiphers + fuzzyMatchingCiphers
}
}
/**
* Returns a list of domains that match the specified domain. If the domain is contained within
* the [DomainsData], this will return all matching domains. Otherwise, it will return
* [matchDomain] or [matchUri] depending on [isAndroidApp].
*/
private fun getMatchingDomains(
domainsData: DomainsData,
isAndroidApp: Boolean,
matchDomain: String?,
matchUri: String,
): MatchingDomains {
val androidAppWebHost = matchUri.getWebHostFromAndroidUriOrNull()
val equivalentDomainsList = domainsData
.equivalentDomains
.plus(
elements = domainsData
.globalEquivalentDomains
.map { it.domains },
)
val exactMatchDomains = mutableListOf<String>()
val fuzzyMatchDomains = mutableListOf<String>()
equivalentDomainsList
.forEach { equivalentDomains ->
when {
isAndroidApp && equivalentDomains.contains(matchUri) -> {
exactMatchDomains.addAll(equivalentDomains)
}
isAndroidApp && equivalentDomains.contains(androidAppWebHost) -> {
fuzzyMatchDomains.addAll(equivalentDomains)
}
!isAndroidApp && equivalentDomains.contains(matchDomain) -> {
exactMatchDomains.addAll(equivalentDomains)
}
}
}
// If there are no equivalent domains, add a version of the original URI to the list.
when {
exactMatchDomains.isEmpty() && isAndroidApp -> exactMatchDomains.add(matchUri)
exactMatchDomains.isEmpty() && matchDomain != null -> exactMatchDomains.add(matchDomain)
}
return MatchingDomains(
exactMatches = exactMatchDomains,
fuzzyMatches = fuzzyMatchDomains,
)
}
/**
* Check to see if [cipherView] matches [matchUri] in some way. The returned [MatchResult] will
* provide details on the match quality.
*
* @param cipherView The cipher to be judged for a match.
* @param context A context for getting string resources.
* @param defaultUriMatchType The global default [UriMatchType].
* @param isAndroidApp Whether or not the [matchUri] belongs to an Android app.
* @param matchingDomains The set of domains that match the domain of [matchUri].
* @param matchUri The uri that this cipher is being matched to.
*/
@Suppress("LongParameterList")
private fun checkForCipherMatch(
cipherView: CipherView,
context: Context,
defaultUriMatchType: UriMatchType,
isAndroidApp: Boolean,
matchingDomains: MatchingDomains,
matchUri: String,
): MatchResult {
val matchResults = cipherView
.login
?.uris
?.map { loginUriView ->
loginUriView.checkForMatch(
context = context,
defaultUriMatchType = defaultUriMatchType,
isAndroidApp = isAndroidApp,
matchingDomains = matchingDomains,
matchUri = matchUri,
)
}
return matchResults
?.firstOrNull { it == MatchResult.EXACT }
?: matchResults
?.firstOrNull { it == MatchResult.FUZZY }
?: MatchResult.NONE
}
/**
* Check to see how well this [LoginUriView] matches [matchUri].
*
* @param context A context for getting app information.
* @param defaultUriMatchType The global default [UriMatchType].
* @param isAndroidApp Whether or not the [matchUri] belongs to an Android app.
* @param matchingDomains The set of domains that match the domain of [matchUri].
* @param matchUri The uri that this [LoginUriView] is being matched to.
*/
private fun LoginUriView.checkForMatch(
context: Context,
defaultUriMatchType: UriMatchType,
isAndroidApp: Boolean,
matchingDomains: MatchingDomains,
matchUri: String,
): MatchResult {
val matchType = this.match ?: defaultUriMatchType
val loginViewUri = this.uri
return if (!loginViewUri.isNullOrBlank()) {
when (matchType) {
UriMatchType.DOMAIN -> {
checkUriForDomainMatch(
context = context,
isAndroidApp = isAndroidApp,
matchingDomains = matchingDomains,
uri = loginViewUri,
)
}
UriMatchType.EXACT -> exactIfTrue(loginViewUri == matchUri)
UriMatchType.HOST -> {
val loginUriHost = loginViewUri.getHostWithPortOrNull()
val matchUriHost = matchUri.getHostWithPortOrNull()
exactIfTrue(matchUriHost != null && loginUriHost == matchUriHost)
}
UriMatchType.NEVER -> MatchResult.NONE
UriMatchType.REGULAR_EXPRESSION -> {
val pattern = Regex(loginViewUri, RegexOption.IGNORE_CASE)
exactIfTrue(matchUri.matches(pattern))
}
UriMatchType.STARTS_WITH -> exactIfTrue(matchUri.startsWith(loginViewUri))
}
} else {
MatchResult.NONE
}
}
/**
* Check to see if [uri] matches [matchingDomains] in some way.
*/
private fun checkUriForDomainMatch(
isAndroidApp: Boolean,
context: Context,
matchingDomains: MatchingDomains,
uri: String,
): MatchResult = when {
matchingDomains.exactMatches.contains(uri) -> MatchResult.EXACT
isAndroidApp && matchingDomains.fuzzyMatches.contains(uri) -> MatchResult.FUZZY
else -> {
val domain = uri
.getDomainOrNull(context = context)
?.lowercase()
// We only care about fuzzy matches if we are isAndroidApp is true because the fuzzu
// matches are generated using a app URI derived host.
when {
matchingDomains.exactMatches.contains(domain) -> MatchResult.EXACT
isAndroidApp && matchingDomains.fuzzyMatches.contains(domain) -> MatchResult.FUZZY
else -> MatchResult.NONE
}
}
}
/**
* Simple function to return [MatchResult.EXACT] if [condition] is true, and
* [MatchResult.NONE] otherwise.
*/
private fun exactIfTrue(condition: Boolean): MatchResult =
if (condition) MatchResult.EXACT else MatchResult.NONE
/**
* A convenience data class for holding domain matches.
*/
private data class MatchingDomains(
val exactMatches: List<String>,
val fuzzyMatches: List<String>,
)
/**
* A enum to represent the quality of a match.
*/
private enum class MatchResult {
EXACT,
FUZZY,
NONE,
}

View file

@ -22,11 +22,15 @@ import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.PushManagerImpl
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.platform.manager.SdkClientManagerImpl
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManagerImpl
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManagerImpl
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManagerImpl
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -48,6 +52,19 @@ object PlatformManagerModule {
fun provideAppForegroundManager(): AppForegroundManager =
AppForegroundManagerImpl()
@Provides
@Singleton
fun providesCipherMatchingManager(
@ApplicationContext context: Context,
settingsRepository: SettingsRepository,
vaultRepository: VaultRepository,
): CipherMatchingManager =
CipherMatchingManagerImpl(
context = context,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
)
@Provides
@Singleton
fun provideClock(): Clock = Clock.systemDefaultZone()

View file

@ -0,0 +1,27 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* A data class containing the result of parsing the URL.
*
* Example:
* - URL: m.google.com
* - domain: google.com
* - secondLevelDomain: google
* - subDomain: m
* - topLevelDomain: com
*
* @property secondLevelDomain The second-level domain of the URL, if it exists.
* @property subDomain The subdomain of the URL.
* @property topLevelDomain The top-level domain (TLD) of the URL.
*/
data class DomainName(
val secondLevelDomain: String?,
val subDomain: String?,
val topLevelDomain: String,
) {
/**
* The domain of the URL, constructed from the second-level and top-level domains.
*/
val domain: String
get() = "$secondLevelDomain.$topLevelDomain"
}

View file

@ -68,18 +68,3 @@ private val CardView.subtitleCardNumber: String?
*/
private val String?.isAmEx: Boolean
get() = this?.startsWith("34") == true || this?.startsWith("37") == true
/**
* Take this [CipherView] if its uri matches [uri]. Otherwise, return null.
*/
fun CipherView.takeIfUriMatches(
uri: String,
): CipherView? =
// TODO: Pass global URI matching value from settings (BIT-1461)
this
.takeIf {
// TODO: perform comprehensive URI matching (BIT-1461)
login
?.uris
?.any { it.uri == uri } == true
}

View file

@ -0,0 +1,86 @@
package com.x8bit.bitwarden.data.platform.util
import android.content.Context
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import java.net.URI
import java.net.URISyntaxException
/**
* The protocol for and Android app URI.
*/
private const val ANDROID_APP_PROTOCOL: String = "androidapp://"
/**
* Try creating a [URI] out of this [String]. If it fails, return null.
*/
fun String.toUriOrNull(): URI? =
try {
URI(this)
} catch (e: URISyntaxException) {
null
}
/**
* Whether this [String] represents an android app URI.
*/
fun String.isAndroidApp(): Boolean =
this.startsWith(ANDROID_APP_PROTOCOL)
/**
* Try and extract the web host from this [String] if it represents an Android app.
*/
fun String.getWebHostFromAndroidUriOrNull(): String? =
if (this.isAndroidApp()) {
val components = this
.replace(ANDROID_APP_PROTOCOL, "")
.split('.')
if (components.size > 1) {
"${components[1]}.${components[0]}"
} else {
null
}
} else {
null
}
/**
* Extract the domain name from this [String] if possible, otherwise return null.
*/
fun String.getDomainOrNull(context: Context): String? =
this
.toUriOrNull()
?.parseDomainOrNull(context = context)
/**
* Extract the host with port from this [String] if possible, otherwise return null.
*/
@OmitFromCoverage
fun String.getHostWithPortOrNull(): String? =
this
.toUriOrNull()
?.let { uri ->
val host = uri.host
val port = uri.port
if (host != null && port != -1) {
"$host:$port"
} else {
null
}
}
/**
* Find the indices of the last occurrences of [substring] within this [String]. Return null if no
* occurrences are found.
*/
fun String.findLastSubstringIndicesOrNull(substring: String): IntRange? {
val lastIndex = this.lastIndexOf(substring)
return if (lastIndex != -1) {
val endIndex = lastIndex + substring.length - 1
IntRange(lastIndex, endIndex)
} else {
null
}
}

View file

@ -0,0 +1,223 @@
package com.x8bit.bitwarden.data.platform.util
import android.content.Context
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.model.DomainName
import java.net.URI
/**
* A regular expression that matches IP addresses.
*/
private const val IP_REGEX: String =
"^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\." +
"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\." +
"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\." +
"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
/**
* Parses the base domain from the URL. Returns null if unavailable.
*/
fun URI.parseDomainOrNull(context: Context): String? {
val host = this?.host ?: return null
val isIpAddress = host.matches(IP_REGEX.toRegex())
return if (host == "localhost" || isIpAddress) {
host
} else {
parseDomainNameOrNullInternal(
context = context,
host = host,
)
?.domain
}
}
/**
* Parses a URL to get the breakdown of a URL's domain. Returns null if invalid.
*/
fun URI.parseDomainNameOrNull(context: Context): DomainName? =
this
// URI is a platform type and host can be null.
?.host
?.let { nonNullHost ->
parseDomainNameOrNullInternal(
context = context,
host = nonNullHost,
)
}
/**
* The internal implementation of [parseDomainNameOrNull]. This doesn't extend URI and has a
* non-null [host] parameter. Technically, URI.host could be null and we want to avoid issues with
* that.
*/
@Suppress("LongMethod")
private fun parseDomainNameOrNullInternal(
context: Context,
host: String,
): DomainName? {
val exceptionSuffixes = context
.resources
.getStringArray(R.array.exception_suffixes)
.toList()
val normalSuffixes = context
.resources
.getStringArray(R.array.normal_suffixes)
.toList()
val wildCardSuffixes = context
.resources
.getStringArray(R.array.wild_card_suffixes)
.toList()
// Split the host into parts separated by a period. Start with the last part and incrementally
// add back the earlier parts to build a list of any matching domains in the data set.
val hostParts = host
.split(".")
.reversed()
var partialDomain = ""
val ruleMatches: MutableList<SuffixMatchType> = mutableListOf()
// Check to see if this part of the host belongs to any of the suffix lists.
hostParts
.forEach { hostPart ->
partialDomain = if (partialDomain.isBlank()) {
hostPart
} else {
"$hostPart.$partialDomain"
}
when {
// Normal suffixes first.
normalSuffixes.contains(partialDomain) -> {
ruleMatches.add(
SuffixMatchType.Normal(
partialDomain = partialDomain,
),
)
}
// Then wild cards.
wildCardSuffixes.contains(partialDomain) -> {
ruleMatches.add(
SuffixMatchType.WildCard(
partialDomain = partialDomain,
),
)
}
// And finally, exceptions.
exceptionSuffixes.contains(partialDomain) -> {
ruleMatches.add(
SuffixMatchType.Exception(
partialDomain = partialDomain,
),
)
}
}
}
// Take only the largest public suffix match that occurs within our URI's host. We want the
// largest because if the URI was "airbnb.co.uk" we our list would contain "uk" and "co.uk",
// which are both valid top level domains. In this case, "uk" is just simply not the top level
// domain.
val largestMatch = ruleMatches.maxByOrNull {
it
.partialDomain
.split('.')
.size
}
// Determine the position of the top level domain within the host.
val tldRange: IntRange? = when (largestMatch) {
is SuffixMatchType.Exception,
is SuffixMatchType.Normal,
-> {
host.findLastSubstringIndicesOrNull(largestMatch.partialDomain)
}
is SuffixMatchType.WildCard -> {
// This gets the last portion of the top level domain.
val nonWildcardTldIndex = host.lastIndexOf(".${largestMatch.partialDomain}")
if (nonWildcardTldIndex != -1) {
val nonWildcardTld = host.substring(0, nonWildcardTldIndex)
// But we need to also match the wildcard portion.
val dotIndex = nonWildcardTld.lastIndexOf(".")
if (dotIndex != -1) {
IntRange(dotIndex + 1, nonWildcardTldIndex - 1)
} else {
null
}
} else {
null
}
}
null -> null
}
return tldRange
?.first
?.let { firstIndex ->
val topLevelDomain = host.substring(firstIndex)
// Parse the remaining parts prior to the TLD.
// - If there's 0 parts left, there is just a TLD and no domain or subdomain.
// - If there's 1 part, it's the domain, and there is no subdomain.
// - If there's 2+ parts, the last part is the domain, the other parts (combined) are
// the subdomain.
val possibleSubDomainAndDomain = if (firstIndex > 0) {
host.substring(0, firstIndex - 1)
} else {
null
}
val subDomainAndDomainParts = possibleSubDomainAndDomain?.split(".")
val secondLevelDomain = subDomainAndDomainParts?.lastOrNull()
val subDomain = subDomainAndDomainParts
?.dropLast(1)
?.joinToString(separator = ".")
// joinToString leaves white space if called on an empty list.
// So only take the string if it wasn't empty after the dropLast(1).
.takeIf { (subDomainAndDomainParts?.size ?: 0) > 1 }
DomainName(
secondLevelDomain = secondLevelDomain,
topLevelDomain = topLevelDomain,
subDomain = subDomain,
)
}
}
/**
* A type of domain suffix match.
*/
private sealed class SuffixMatchType {
/**
* The partial domain that was actually matched.
*/
abstract val partialDomain: String
/**
* The match occurred with an exception suffix that starts with '!'.
*/
data class Exception(
override val partialDomain: String,
) : SuffixMatchType()
/**
* The match occurred with a normal suffix.
*/
data class Normal(
override val partialDomain: String,
) : SuffixMatchType()
/**
* The match occurred with a wildcard suffix that starts with '*'.
*/
data class WildCard(
override val partialDomain: String,
) : SuffixMatchType()
}

View file

@ -18,3 +18,16 @@ val UriMatchType.displayLabel: Text
UriMatchType.NEVER -> R.string.never
}
.asText()
/**
* Convert this internal [UriMatchType] to the sdk model.
*/
fun UriMatchType.toSdkUriMatchType(): com.bitwarden.core.UriMatchType =
when (this) {
UriMatchType.DOMAIN -> com.bitwarden.core.UriMatchType.DOMAIN
UriMatchType.EXACT -> com.bitwarden.core.UriMatchType.EXACT
UriMatchType.HOST -> com.bitwarden.core.UriMatchType.HOST
UriMatchType.NEVER -> com.bitwarden.core.UriMatchType.NEVER
UriMatchType.REGULAR_EXPRESSION -> com.bitwarden.core.UriMatchType.REGULAR_EXPRESSION
UriMatchType.STARTS_WITH -> com.bitwarden.core.UriMatchType.STARTS_WITH
}

File diff suppressed because it is too large Load diff

View file

@ -8,11 +8,13 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProviderImpl
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.platform.util.takeIfUriMatches
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -37,6 +39,7 @@ class AutofillCipherProviderTest {
}
private val cardCipherView: CipherView = mockk {
every { card } returns cardView
every { deletedDate } returns null
every { name } returns CARD_NAME
every { type } returns CipherType.CARD
}
@ -45,6 +48,7 @@ class AutofillCipherProviderTest {
every { username } returns LOGIN_USERNAME
}
private val loginCipherView: CipherView = mockk {
every { deletedDate } returns null
every { login } returns loginView
every { name } returns LOGIN_NAME
every { type } returns CipherType.LOGIN
@ -52,6 +56,7 @@ class AutofillCipherProviderTest {
private val authRepository: AuthRepository = mockk {
every { activeUserId } returns ACTIVE_USER_ID
}
private val cipherMatchingManager: CipherMatchingManager = mockk()
private val mutableVaultStateFlow = MutableStateFlow(
VaultState(
unlockingVaultUserIds = emptySet(),
@ -73,17 +78,16 @@ class AutofillCipherProviderTest {
@BeforeEach
fun setup() {
mockkStatic(CipherView::takeIfUriMatches)
mockkStatic(CipherView::subtitle)
autofillCipherProvider = AutofillCipherProviderImpl(
authRepository = authRepository,
cipherMatchingManager = cipherMatchingManager,
vaultRepository = vaultRepository,
)
}
@AfterEach
fun teardown() {
unmockkStatic(CipherView::takeIfUriMatches)
unmockkStatic(CipherView::subtitle)
}
@ -130,11 +134,17 @@ class AutofillCipherProviderTest {
assertFalse(result.await())
}
@Suppress("MaxLineLength")
@Test
fun `getCardAutofillCiphers when unlocked should return non-null card ciphers`() =
fun `getCardAutofillCiphers when unlocked should return non-null and non-deleted card ciphers`() =
runTest {
val deletedCardCipherView: CipherView = mockk {
every { deletedDate } returns mockk()
every { type } returns CipherType.CARD
}
val cipherViews = listOf(
cardCipherView,
deletedCardCipherView,
loginCipherView,
)
mutableCiphersStateFlow.value = DataState.Loaded(
@ -168,18 +178,28 @@ class AutofillCipherProviderTest {
assertEquals(emptyList<AutofillCipher.Card>(), actual)
}
@Suppress("MaxLineLength")
@Test
fun `getLoginAutofillCiphers when unlocked should return matching login ciphers`() =
fun `getLoginAutofillCiphers when unlocked should return matched, non-deleted, login ciphers`() =
runTest {
val unmatchedLoginCipherView: CipherView = mockk {
every { takeIfUriMatches(URI) } returns null
val deletedLoginCipherView: CipherView = mockk {
every { deletedDate } returns mockk()
every { type } returns CipherType.LOGIN
}
val cipherViews = listOf(
cardCipherView,
loginCipherView,
unmatchedLoginCipherView,
deletedLoginCipherView,
)
val filteredCipherViews = listOf(
loginCipherView,
)
coEvery {
cipherMatchingManager.filterCiphersForMatches(
ciphers = filteredCipherViews,
matchUri = URI,
)
} returns filteredCipherViews
mutableCiphersStateFlow.value = DataState.Loaded(
data = cipherViews,
)
@ -191,14 +211,20 @@ class AutofillCipherProviderTest {
LOGIN_AUTOFILL_CIPHER,
)
every { loginCipherView.subtitle } returns LOGIN_SUBTITLE
every { loginCipherView.takeIfUriMatches(URI) } returns loginCipherView
// Test & Verify
// Test
val actual = autofillCipherProvider.getLoginAutofillCiphers(
uri = URI,
)
// Verify
assertEquals(expected, actual)
coVerify {
cipherMatchingManager.filterCiphersForMatches(
ciphers = filteredCipherViews,
matchUri = URI,
)
}
}
@Test

View file

@ -0,0 +1,385 @@
package com.x8bit.bitwarden.data.platform.manager.ciphermatching
import android.content.Context
import com.bitwarden.core.CipherView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.bitwarden.core.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.util.getDomainOrNull
import com.x8bit.bitwarden.data.platform.util.getHostWithPortOrNull
import com.x8bit.bitwarden.data.platform.util.getWebHostFromAndroidUriOrNull
import com.x8bit.bitwarden.data.platform.util.isAndroidApp
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class CipherMatchingManagerTest {
private lateinit var cipherMatchingManager: CipherMatchingManager
// Setup dependencies
private val context: Context = mockk()
private val settingsRepository: SettingsRepository = mockk {
every { defaultUriMatchType } returns DEFAULT_URI_MATCH_TYPE
}
private val vaultRepository: VaultRepository = mockk {
every { domainsStateFlow } returns MutableStateFlow(DataState.Loaded(DOMAINS_DATA))
}
// Setup test ciphers
private val defaultMatchLoginUriViewOne: LoginUriView = mockk {
every { match } returns null
every { uri } returns DEFAULT_LOGIN_VIEW_URI_ONE
}
private val defaultMatchLoginUriViewTwo: LoginUriView = mockk {
every { match } returns null
every { uri } returns DEFAULT_LOGIN_VIEW_URI_TWO
}
private val defaultMatchLoginUriViewThree: LoginUriView = mockk {
every { match } returns null
every { uri } returns DEFAULT_LOGIN_VIEW_URI_THREE
}
private val defaultMatchLoginUriViewFour: LoginUriView = mockk {
every { match } returns null
every { uri } returns DEFAULT_LOGIN_VIEW_URI_FOUR
}
private val defaultMatchLoginUriViewFive: LoginUriView = mockk {
every { match } returns null
every { uri } returns DEFAULT_LOGIN_VIEW_URI_FIVE
}
private val defaultMatchLoginView: LoginView = mockk {
every { uris } returns listOf(
defaultMatchLoginUriViewOne,
defaultMatchLoginUriViewTwo,
defaultMatchLoginUriViewThree,
defaultMatchLoginUriViewFour,
defaultMatchLoginUriViewFive,
)
}
private val defaultMatchCipher: CipherView = mockk {
every { login } returns defaultMatchLoginView
}
private val exactMatchLoginUriViewOne: LoginUriView = mockk {
every { match } returns UriMatchType.EXACT
every { uri } returns "google.com"
}
private val exactMatchLoginUriViewTwo: LoginUriView = mockk {
every { match } returns UriMatchType.EXACT
every { uri } returns "notExactMatch.com"
}
private val exactMatchLoginView: LoginView = mockk {
every { uris } returns listOf(
exactMatchLoginUriViewOne,
exactMatchLoginUriViewTwo,
)
}
private val exactMatchCipher: CipherView = mockk {
every { login } returns exactMatchLoginView
}
private val hostMatchLoginUriViewMatching: LoginUriView = mockk {
every { match } returns UriMatchType.HOST
every { uri } returns HOST_LOGIN_VIEW_URI_MATCHING
}
private val hostMatchLoginUriViewNotMatching: LoginUriView = mockk {
every { match } returns UriMatchType.HOST
every { uri } returns HOST_LOGIN_VIEW_URI_NOT_MATCHING
}
private val hostMatchLoginView: LoginView = mockk {
every { uris } returns listOf(
hostMatchLoginUriViewMatching,
hostMatchLoginUriViewNotMatching,
)
}
private val hostMatchCipher: CipherView = mockk {
every { login } returns hostMatchLoginView
}
private val neverMatchLoginUriView: LoginUriView = mockk {
every { match } returns UriMatchType.NEVER
every { uri } returns "google.com"
}
private val neverMatchLoginView: LoginView = mockk {
every { uris } returns listOf(neverMatchLoginUriView)
}
private val neverMatchCipher: CipherView = mockk {
every { login } returns neverMatchLoginView
}
private val regexMatchLoginUriViewMatching: LoginUriView = mockk {
every { match } returns UriMatchType.REGULAR_EXPRESSION
every { uri } returns ".*"
}
private val regexMatchLoginUriViewNotMatching: LoginUriView = mockk {
every { match } returns UriMatchType.REGULAR_EXPRESSION
every { uri } returns "$^"
}
private val regexMatchLoginView: LoginView = mockk {
every { uris } returns listOf(
regexMatchLoginUriViewMatching,
regexMatchLoginUriViewNotMatching,
)
}
private val regexMatchCipher: CipherView = mockk {
every { login } returns regexMatchLoginView
}
private val startsWithMatchLoginUriViewMatching: LoginUriView = mockk {
every { match } returns UriMatchType.STARTS_WITH
every { uri } returns "g"
}
private val startsWithMatchLoginUriViewNotMatching: LoginUriView = mockk {
every { match } returns UriMatchType.REGULAR_EXPRESSION
every { uri } returns "!!!!!!"
}
private val startsWithMatchLoginView: LoginView = mockk {
every { uris } returns listOf(
startsWithMatchLoginUriViewMatching,
startsWithMatchLoginUriViewNotMatching,
)
}
private val startsWithMatchCipher: CipherView = mockk {
every { login } returns startsWithMatchLoginView
}
private val ciphers: List<CipherView> = listOf(
defaultMatchCipher,
exactMatchCipher,
hostMatchCipher,
neverMatchCipher,
regexMatchCipher,
startsWithMatchCipher,
)
@BeforeEach
fun setup() {
mockkStatic(
String::isAndroidApp,
String::getDomainOrNull,
String::getWebHostFromAndroidUriOrNull,
)
cipherMatchingManager = CipherMatchingManagerImpl(
context = context,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
)
}
@AfterEach
fun teardown() {
unmockkStatic(
String::isAndroidApp,
String::getDomainOrNull,
String::getWebHostFromAndroidUriOrNull,
)
}
@Suppress("MaxLineLength")
@Test
fun `filterCiphersForMatches should perform cipher matching when is android app and matching URI`() =
runTest {
// Setup
val uri = "google.com"
val expected = listOf(
defaultMatchCipher,
exactMatchCipher,
hostMatchCipher,
regexMatchCipher,
startsWithMatchCipher,
)
setupMocksForMatchingCiphers(
isAndroidApp = true,
uri = uri,
)
// Test
val actual = cipherMatchingManager.filterCiphersForMatches(
ciphers = ciphers,
matchUri = uri,
)
// Verify
assertEquals(expected, actual)
}
@Suppress("MaxLineLength")
@Test
fun `filterCiphersForMatches should perform cipher matching when is android app and difficult to match URI`() =
runTest {
// Setup
val uri = "difficultToMatch.com"
// The default cipher only has a fuzzy match
// and therefore is at the end of the list.
val expected = listOf(
hostMatchCipher,
regexMatchCipher,
defaultMatchCipher,
)
setupMocksForMatchingCiphers(
isAndroidApp = true,
uri = uri,
)
// Test
val actual = cipherMatchingManager.filterCiphersForMatches(
ciphers = ciphers,
matchUri = uri,
)
// Verify
assertEquals(expected, actual)
}
@Suppress("MaxLineLength")
@Test
fun `filterCiphersForMatches should perform cipher matching when not android app and matching URI`() =
runTest {
// Setup
val uri = "google.com"
val expected = listOf(
defaultMatchCipher,
exactMatchCipher,
hostMatchCipher,
regexMatchCipher,
startsWithMatchCipher,
)
setupMocksForMatchingCiphers(
isAndroidApp = false,
uri = uri,
)
// Test
val actual = cipherMatchingManager.filterCiphersForMatches(
ciphers = ciphers,
matchUri = uri,
)
// Verify
assertEquals(expected, actual)
}
@Suppress("MaxLineLength")
@Test
fun `filterCiphersForMatches should perform cipher matching when not android app and difficult to match URI`() =
runTest {
// Setup
val uri = "difficultToMatch.com"
val expected = listOf(
hostMatchCipher,
regexMatchCipher,
)
setupMocksForMatchingCiphers(
isAndroidApp = false,
uri = uri,
)
// Test
val actual = cipherMatchingManager.filterCiphersForMatches(
ciphers = ciphers,
matchUri = uri,
)
// Verify
assertEquals(expected, actual)
}
@Test
fun `filterCiphersForMatches should skip ciphers without login details`() =
runTest {
// Setup
val uri = "noMatches.com"
val ciphers = listOf<CipherView>(
mockk {
every { login } returns null
},
)
with(uri) {
every { isAndroidApp() } returns false
every { getDomainOrNull(context = context) } returns this
every { getWebHostFromAndroidUriOrNull() } returns null
}
// Test
val actual = cipherMatchingManager.filterCiphersForMatches(
ciphers = ciphers,
matchUri = uri,
)
// Verify
assertEquals(emptyList<CipherView>(), actual)
}
/**
* Setup mocks for matching the massive list of [ciphers].
*/
private fun setupMocksForMatchingCiphers(
isAndroidApp: Boolean,
uri: String,
) {
with(uri) {
every { isAndroidApp() } returns isAndroidApp
every { getDomainOrNull(context = context) } returns this.takeIf { isAndroidApp }
every { getHostWithPortOrNull() } returns HOST_WITH_PORT
every {
getWebHostFromAndroidUriOrNull()
} returns ANDROID_APP_WEB_URL.takeIf { isAndroidApp }
}
every {
DEFAULT_LOGIN_VIEW_URI_ONE.getDomainOrNull(context = context)
} returns DEFAULT_LOGIN_VIEW_URI_ONE
every {
DEFAULT_LOGIN_VIEW_URI_TWO.getDomainOrNull(context = context)
} returns null
every {
DEFAULT_LOGIN_VIEW_URI_THREE.getDomainOrNull(context = context)
} returns uri
every {
DEFAULT_LOGIN_VIEW_URI_FOUR.getDomainOrNull(context = context)
} returns "bitwarden.com"
every {
DEFAULT_LOGIN_VIEW_URI_FIVE.getDomainOrNull(context = context)
} returns null
every { HOST_LOGIN_VIEW_URI_MATCHING.getHostWithPortOrNull() } returns HOST_WITH_PORT
every { HOST_LOGIN_VIEW_URI_NOT_MATCHING.getHostWithPortOrNull() } returns null
}
}
private const val ANDROID_APP_WEB_URL = "ANDROID_APP_WEB_URL"
private val DEFAULT_URI_MATCH_TYPE =
com.x8bit.bitwarden.data.platform.repository.model.UriMatchType.DOMAIN
private val EQUIVALENT_DOMAINS = listOf(
"google.com",
"google.co.uk",
)
private val GLOBAL_EQUIVALENT_DOMAINS_DATA = listOf(
"bitwarden.com",
"bitwarden.co.uk",
ANDROID_APP_WEB_URL,
)
private val GLOBAL_EQUIVALENT_DOMAINS = DomainsData.GlobalEquivalentDomain(
isExcluded = false,
domains = GLOBAL_EQUIVALENT_DOMAINS_DATA,
type = 0,
)
private val DOMAINS_DATA = DomainsData(
equivalentDomains = listOf(EQUIVALENT_DOMAINS),
globalEquivalentDomains = listOf(GLOBAL_EQUIVALENT_DOMAINS),
)
// Setup state for default ciphers
private const val DEFAULT_LOGIN_VIEW_URI_ONE: String = "google.com"
private const val DEFAULT_LOGIN_VIEW_URI_TWO: String = ANDROID_APP_WEB_URL
private const val DEFAULT_LOGIN_VIEW_URI_THREE: String = "DEFAULT_LOGIN_VIEW_URI_THREE"
private const val DEFAULT_LOGIN_VIEW_URI_FOUR: String = "DEFAULT_LOGIN_VIEW_URI_FOUR"
private const val DEFAULT_LOGIN_VIEW_URI_FIVE: String = "DEFAULT_LOGIN_VIEW_URI_FIVE"
// Setup state for host ciphers
private const val HOST_LOGIN_VIEW_URI_MATCHING: String = "DEFAULT_LOGIN_VIEW_URI_MATCHING"
private const val HOST_LOGIN_VIEW_URI_NOT_MATCHING: String = "DEFAULT_LOGIN_VIEW_URI_NOT_MATCHING"
private const val HOST_WITH_PORT: String = "HOST_WITH_PORT"

View file

@ -4,10 +4,7 @@ import com.bitwarden.core.CardView
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.IdentityView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.platform.util.takeIfUriMatches
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
@ -332,52 +329,4 @@ class CipherViewExtensionsTest {
// Verify
assertNull(actual)
}
@Test
fun `takeIfUriMatches should return the CipherView if the URI matches`() {
// Setup
val testUri = "com.x8bit.bitwarden"
val loginUriView: LoginUriView = mockk {
every { uri } returns testUri
}
val loginView: LoginView = mockk {
every { uris } returns listOf(loginUriView)
}
val expected: CipherView = mockk {
every { login } returns loginView
every { type } returns CipherType.IDENTITY
}
// Test
val actual = expected.takeIfUriMatches(
uri = testUri,
)
// Verify
assertEquals(expected, actual)
}
@Test
fun `takeIfUriMatches should return null if the URI doesn't match`() {
// Setup
val testUri = "com.x8bit.bitwarden"
val loginUriView: LoginUriView = mockk {
every { uri } returns "com.google"
}
val loginView: LoginView = mockk {
every { uris } returns listOf(loginUriView)
}
val cipherView: CipherView = mockk {
every { login } returns loginView
every { type } returns CipherType.IDENTITY
}
// Test
val actual = cipherView.takeIfUriMatches(
uri = testUri,
)
// Verify
assertNull(actual)
}
}

View file

@ -0,0 +1,141 @@
package com.x8bit.bitwarden.data.util
import android.content.Context
import com.x8bit.bitwarden.data.platform.util.findLastSubstringIndicesOrNull
import com.x8bit.bitwarden.data.platform.util.getDomainOrNull
import com.x8bit.bitwarden.data.platform.util.getWebHostFromAndroidUriOrNull
import com.x8bit.bitwarden.data.platform.util.isAndroidApp
import com.x8bit.bitwarden.data.platform.util.parseDomainOrNull
import com.x8bit.bitwarden.data.platform.util.toUriOrNull
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.net.URI
class StringExtensionsTest {
@AfterEach
fun teardown() {
unmockkStatic(URI::parseDomainOrNull)
}
@Test
fun `toUriOrNull should return null when uri is malformed`() {
assertNull("not a uri".toUriOrNull())
}
@Test
fun `toUriOrNull should return URI when uri is valid`() {
assertNotNull("www.google.com".toUriOrNull())
}
@Test
fun `isAndroidApp should return true when string starts with android app protocol`() {
assertTrue("androidapp://com.x8bit.bitwarden".isAndroidApp())
}
@Test
fun `isAndroidApp should return false when doesn't start with android app protocol`() {
assertFalse("com.x8bit.bitwarden".isAndroidApp())
}
@Test
fun `getWebHostFromAndroidUriOrNull should return null when not android app`() {
assertNull("com.x8bit.bitwarden".getWebHostFromAndroidUriOrNull())
}
@Test
fun `getWebHostFromAndroidUriOrNull should return null when no dot`() {
assertNull("androidapp://comx8bitbitwarden".getWebHostFromAndroidUriOrNull())
}
@Test
fun `getWebHostFromAndroidUriOrNull should return web host when has dot`() {
// Setup
val expected = "x8bit.com"
// Test
val actual = "androidapp://com.x8bit.bitwarden".getWebHostFromAndroidUriOrNull()
// Verify
assertEquals(expected, actual)
}
@Test
fun `getDomainOrNull should invoke parseDomainOrNull when URI is created`() {
// Setup
mockkStatic(URI::parseDomainOrNull)
val context: Context = mockk()
val expected = "google.com"
every {
any<URI>().parseDomainOrNull(context = context)
} returns expected
// Test
val actual = "www.google.com".getDomainOrNull(
context = context,
)
// Verify
assertEquals(expected, actual)
verify(exactly = 1) {
any<URI>().parseDomainOrNull(context = context)
}
}
@Test
fun `getDomainOrNull should not invoke parseDomainOrNull when URI is not created`() {
// Setup
mockkStatic(URI::parseDomainOrNull)
val context: Context = mockk()
// Test
val actual = "not a URI".getDomainOrNull(
context = context,
)
// Verify
assertNull(actual)
verify(exactly = 0) {
any<URI>().parseDomainOrNull(context = context)
}
}
@Test
fun `findLastSubstringIndicesOrNull should return null if substring doesn't appear`() {
// Setup
val substring = "hello"
// Test
val actual = "goodbye".findLastSubstringIndicesOrNull(
substring = substring,
)
// Verify
assertNull(actual)
}
@Test
fun `findLastSubstringIndicesOrNull should return indices of last substring appearance`() {
// Setup
val substring = "hello"
val expected = IntRange(7, 11)
// Test
val actual = "hello, hello".findLastSubstringIndicesOrNull(
substring = substring,
)
// Verify
assertEquals(expected, actual)
}
}

View file

@ -0,0 +1,342 @@
package com.x8bit.bitwarden.data.util
import android.content.Context
import android.content.res.Resources
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.model.DomainName
import com.x8bit.bitwarden.data.platform.util.parseDomainNameOrNull
import com.x8bit.bitwarden.data.platform.util.parseDomainOrNull
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.net.URI
class UriExtensionsTest {
private val resources: Resources = mockk {
every { getStringArray(R.array.exception_suffixes) } returns emptyArray()
every { getStringArray(R.array.normal_suffixes) } returns emptyArray()
every { getStringArray(R.array.wild_card_suffixes) } returns emptyArray()
}
private val context: Context = mockk {
every { this@mockk.resources } returns this@UriExtensionsTest.resources
}
@Test
fun `parseDomainOrNull should return null when host is null`() {
// Setup
val uri: URI = mockk {
every { host } returns null
}
// Test
val actual = uri.parseDomainOrNull(
context = context,
)
// Verify
assertNull(actual)
}
@Test
fun `parseDomainOrNull should return host when it is localhost`() {
// Setup
val expected = "localhost"
val uri: URI = mockk {
every { host } returns expected
}
// Test
val actual = uri.parseDomainOrNull(
context = context,
)
// Verify
assertEquals(expected, actual)
}
@Test
fun `parseDomainOrNull should return host when it is ip address`() {
// Setup
val expected = "192.168.1.1"
val uri: URI = mockk {
every { host } returns expected
}
// Test
val actual = uri.parseDomainOrNull(
context = context,
)
// Verify
assertEquals(expected, actual)
}
@Test
fun `parseDomainOrNull should return domain name when it matches exception suffix`() {
// Setup
val uri: URI = mockk()
val expected = listOf(
"example.co.uk",
"example.co.uk",
"example.uk",
null,
)
every {
resources.getStringArray(R.array.exception_suffixes)
} returns arrayOf("co.uk", "uk")
// Test & Verify
listOf(
"example.co.uk",
"sub.example.co.uk",
"sub.example.uk",
"sub.example.uk.usa",
)
.forEachIndexed { index, host ->
// Setup item test
every { uri.host } returns host
// Test
val actual = uri.parseDomainOrNull(
context = context,
)
// Verify
assertEquals(expected[index], actual)
}
}
@Test
fun `parseDomainOrNull should return domain name when it matches normal suffix`() {
// Setup
val uri: URI = mockk()
val expected = listOf(
"example.co.uk",
"example.co.uk",
"example.uk",
null,
)
every { resources.getStringArray(R.array.exception_suffixes) } returns arrayOf(
"co.uk",
"uk",
)
// Test & Verify
listOf(
"example.co.uk",
"sub.example.co.uk",
"sub.example.uk",
"sub.example.uk.usa",
)
.forEachIndexed { index, host ->
// Setup item test
every { uri.host } returns host
// Test
val actual = uri.parseDomainOrNull(
context = context,
)
// Verify
assertEquals(expected[index], actual)
}
}
@Test
fun `parseDomainOrNull should return domain name when it matches wild card suffix`() {
// Setup
val uri: URI = mockk()
val expected = listOf(
"example.foo.compute.amazonaws.com",
"example.foo.compute.amazonaws.com",
"example.foo.amazonaws.com",
null,
)
every { resources.getStringArray(R.array.wild_card_suffixes) } returns arrayOf(
"compute.amazonaws.com",
"amazonaws.com",
)
// Test & Verify
listOf(
"sub.example.foo.compute.amazonaws.com",
"bar.sub.example.foo.compute.amazonaws.com",
"bar.sub.example.foo.amazonaws.com",
"foo.sub.example.foo.amazonaws.com.usa",
)
.forEachIndexed { index, host ->
// Setup item test
every { uri.host } returns host
// Test
val actual = uri.parseDomainOrNull(
context = context,
)
// Verify
assertEquals(expected[index], actual)
}
}
@Test
fun `parseDomainNameOrNull should return null when host is null`() {
// Setup
val uri: URI = mockk {
every { host } returns null
}
// Test
val actual = uri.parseDomainNameOrNull(
context = context,
)
// Verify
assertNull(actual)
}
@Test
fun `parseDomainNameOrNull should return domain name when it matches exception suffix`() {
// Setup
val uri: URI = mockk()
val expected = listOf(
DomainName(
secondLevelDomain = "example",
subDomain = null,
topLevelDomain = "co.uk",
),
DomainName(
secondLevelDomain = "example",
subDomain = "sub",
topLevelDomain = "co.uk",
),
DomainName(
secondLevelDomain = "example",
subDomain = "sub",
topLevelDomain = "uk",
),
null,
)
every { resources.getStringArray(R.array.exception_suffixes) } returns arrayOf(
"co.uk",
"uk",
)
// Test & Verify
listOf(
"example.co.uk",
"sub.example.co.uk",
"sub.example.uk",
"sub.example.uk.usa",
)
.forEachIndexed { index, host ->
// Setup item test
every { uri.host } returns host
// Test
val actual = uri.parseDomainNameOrNull(
context = context,
)
// Verify
assertEquals(expected[index], actual)
}
}
@Test
fun `parseDomainNameOrNull should return domain name when it matches normal suffix`() {
// Setup
val uri: URI = mockk()
val expected = listOf(
DomainName(
secondLevelDomain = "example",
subDomain = null,
topLevelDomain = "co.uk",
),
DomainName(
secondLevelDomain = "example",
subDomain = "sub",
topLevelDomain = "co.uk",
),
DomainName(
secondLevelDomain = "example",
subDomain = "sub",
topLevelDomain = "uk",
),
null,
)
every { resources.getStringArray(R.array.exception_suffixes) } returns arrayOf(
"co.uk",
"uk",
)
// Test & Verify
listOf(
"example.co.uk",
"sub.example.co.uk",
"sub.example.uk",
"sub.example.uk.usa",
)
.forEachIndexed { index, host ->
// Setup item test
every { uri.host } returns host
// Test
val actual = uri.parseDomainNameOrNull(
context = context,
)
// Verify
assertEquals(expected[index], actual)
}
}
@Test
fun `parseDomainNameOrNull should return domain name when it matches wild card suffix`() {
// Setup
val uri: URI = mockk()
val expected = listOf(
DomainName(
secondLevelDomain = "example",
subDomain = "sub",
topLevelDomain = "foo.compute.amazonaws.com",
),
DomainName(
secondLevelDomain = "example",
subDomain = "bar.sub",
topLevelDomain = "foo.compute.amazonaws.com",
),
DomainName(
secondLevelDomain = "example",
subDomain = "bar.sub",
topLevelDomain = "foo.amazonaws.com",
),
null,
)
every { resources.getStringArray(R.array.wild_card_suffixes) } returns arrayOf(
"compute.amazonaws.com",
"amazonaws.com",
)
// Test & Verify
listOf(
"sub.example.foo.compute.amazonaws.com",
"bar.sub.example.foo.compute.amazonaws.com",
"bar.sub.example.foo.amazonaws.com",
"bar.sub.example.foo.amazonaws.com.usa",
)
.forEachIndexed { index, host ->
// Setup item test
every { uri.host } returns host
// Test
val actual = uri.parseDomainNameOrNull(
context = context,
)
// Verify
assertEquals(expected[index], actual)
}
}
}

View file

@ -24,4 +24,32 @@ class UriMatchTypeExtensionsTest {
)
}
}
@Test
fun `toSdkUriMatchType should return the correct value for each type`() {
assertEquals(
com.bitwarden.core.UriMatchType.DOMAIN,
UriMatchType.DOMAIN.toSdkUriMatchType(),
)
assertEquals(
com.bitwarden.core.UriMatchType.EXACT,
UriMatchType.EXACT.toSdkUriMatchType(),
)
assertEquals(
com.bitwarden.core.UriMatchType.HOST,
UriMatchType.HOST.toSdkUriMatchType(),
)
assertEquals(
com.bitwarden.core.UriMatchType.NEVER,
UriMatchType.NEVER.toSdkUriMatchType(),
)
assertEquals(
com.bitwarden.core.UriMatchType.REGULAR_EXPRESSION,
UriMatchType.REGULAR_EXPRESSION.toSdkUriMatchType(),
)
assertEquals(
com.bitwarden.core.UriMatchType.STARTS_WITH,
UriMatchType.STARTS_WITH.toSdkUriMatchType(),
)
}
}

15411
scripts/public_suffix_list.dat Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
#!/bin/bash
# This shell script is for parsing `public_suffix_list.dat` and writing its contents into a string
# array resource. We need these public suffixes for extracting a domain name from a URI. For more
# information about why that is, checkout the original PR description here:
# https://github.com/bitwarden/android/pull/842.
#
# For an updated `public_suffix_list.dat` file, go here: https://publicsuffix.org/list/index.html
RAW_PREFIXES_DATA_PATH="public_suffix_list.dat"
RESOURCE_OUTPUT_PATH="../app/src/main/res/values/public_suffix_list.xml"
EXCEPTION_SUFFIXES_STRING_ARRAY_HEADER=" <string-array name=\"exception_suffixes\">\n"
NORMAL_SUFFIXES_STRING_ARRAY_HEADER=" <string-array name=\"normal_suffixes\">\n"
WILD_CARD_SUFFIXES_STRING_ARRAY_HEADER=" <string-array name=\"wild_card_suffixes\">\n"
STRING_ARRAY_FOOTER=" </string-array>\n"
STRING_RESOURCE_FOOTER="</resources>\n"
STRING_RESOURCE_HEADER="<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n A list of valid public domain suffixes. This file is generated by running\n \`scripts/public_suffix_list_generator.sh\` \n-->\n<resources>\n"
# Setup the header of the file.
newFileContents=$STRING_RESOURCE_HEADER
exceptionSuffixes=()
normalSuffixes=()
wildcardSuffixes=()
addItemToContents() {
newFileContents+=" <item>$1</item>\n"
}
while IFS= read -r string || [[ -n "$string" ]]; do
if [[ -z "$string" || "$string" == "//"* ]]; then
# Remove comments and empty lines
continue
elif [[ "$string" == "*"* ]]; then
# Drop the "*."
wildcardSuffixes+=("${string#??}")
elif [[ "$string" == "!"* ]]; then
# Drop the "!."
exceptionSuffixes+=("${string#?}")
else
normalSuffixes+=("$string")
fi
done < "$RAW_PREFIXES_DATA_PATH"
# Add all exception suffix items to the new file contents.
if [ ${#exceptionSuffixes[@]} -gt 0 ]; then
newFileContents+=$EXCEPTION_SUFFIXES_STRING_ARRAY_HEADER
for item in "${exceptionSuffixes[@]}"; do
addItemToContents "$item"
done
newFileContents+=$STRING_ARRAY_FOOTER
fi
# Add all normal suffix items to the new file contents.
if [ ${#normalSuffixes[@]} -gt 0 ]; then
newFileContents+=$NORMAL_SUFFIXES_STRING_ARRAY_HEADER
for item in "${normalSuffixes[@]}"; do
addItemToContents "$item"
done
newFileContents+=$STRING_ARRAY_FOOTER
fi
# Add all wild card suffix items to the new file contents.
if [ ${#wildcardSuffixes[@]} -gt 0 ]; then
newFileContents+=$WILD_CARD_SUFFIXES_STRING_ARRAY_HEADER
for item in "${wildcardSuffixes[@]}"; do
addItemToContents "$item"
done
newFileContents+=$STRING_ARRAY_FOOTER
fi
# Setup the footer of the file.
newFileContents+=$STRING_RESOURCE_FOOTER
# Write the contents to the output file.
echo -e "$newFileContents" > "$RESOURCE_OUTPUT_PATH"