mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
BIT-621: Add URI matching for autofill (#842)
This commit is contained in:
parent
0e5e6b4444
commit
0c6ea8d18d
19 changed files with 26674 additions and 95 deletions
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
@ -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,24 +56,23 @@ 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,
|
||||
)
|
||||
?.let { nonNullCipherView ->
|
||||
AutofillCipher.Login(
|
||||
name = nonNullCipherView.name,
|
||||
password = nonNullCipherView.login?.password.orEmpty(),
|
||||
subtitle = nonNullCipherView.subtitle.orEmpty(),
|
||||
username = nonNullCipherView.login?.username.orEmpty(),
|
||||
)
|
||||
}
|
||||
return cipherMatchingManager
|
||||
// Filter for ciphers that match the uri in some way.
|
||||
.filterCiphersForMatches(
|
||||
ciphers = loginCiphers,
|
||||
matchUri = uri,
|
||||
)
|
||||
.map { cipherView ->
|
||||
AutofillCipher.Login(
|
||||
name = cipherView.name,
|
||||
password = cipherView.login?.password.orEmpty(),
|
||||
subtitle = cipherView.subtitle.orEmpty(),
|
||||
username = cipherView.login?.username.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
9576
app/src/main/res/values/public_suffix_list.xml
Normal file
9576
app/src/main/res/values/public_suffix_list.xml
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
|
|
|
@ -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"
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
15411
scripts/public_suffix_list.dat
Normal file
File diff suppressed because it is too large
Load diff
76
scripts/public_suffix_list_generator.sh
Normal file
76
scripts/public_suffix_list_generator.sh
Normal 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"
|
Loading…
Reference in a new issue