mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 13:28:12 -04:00
Replace Swift and Kotlin credential matching logic with Rust Core interface (#1404)
This commit is contained in:
@@ -27,9 +27,9 @@ import android.widget.RemoteViews
|
||||
import net.aliasvault.app.MainActivity
|
||||
import net.aliasvault.app.R
|
||||
import net.aliasvault.app.autofill.models.FieldType
|
||||
import net.aliasvault.app.autofill.utils.CredentialMatcher
|
||||
import net.aliasvault.app.autofill.utils.FieldFinder
|
||||
import net.aliasvault.app.autofill.utils.ImageUtils
|
||||
import net.aliasvault.app.autofill.utils.RustCredentialMatcher
|
||||
import net.aliasvault.app.utils.ItemTypeIcon
|
||||
import net.aliasvault.app.vaultstore.VaultStore
|
||||
import net.aliasvault.app.vaultstore.interfaces.CredentialOperationCallback
|
||||
@@ -151,7 +151,7 @@ class AutofillService : AutofillService() {
|
||||
|
||||
// Filter credentials based on app/website info
|
||||
val filteredByApp = if (appInfo != null) {
|
||||
CredentialMatcher.filterCredentialsByAppInfo(result, appInfo)
|
||||
RustCredentialMatcher.filterCredentialsByAppInfo(result, appInfo)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
package net.aliasvault.app.autofill.utils
|
||||
|
||||
import net.aliasvault.app.vaultstore.models.Credential
|
||||
|
||||
/**
|
||||
* Helper class to match credentials against app/website information for autofill.
|
||||
* This implementation follows the unified filtering algorithm specification defined in
|
||||
* docs/CREDENTIAL_FILTERING_SPEC.md for cross-platform consistency with iOS and Browser Extension.
|
||||
*
|
||||
* Algorithm Structure (Priority Order with Early Returns):
|
||||
* 1. PRIORITY 1: App Package Name Exact Match (e.g., com.coolblue.app)
|
||||
* 2. PRIORITY 2: URL Domain Matching (exact, subdomain, root domain)
|
||||
* 3. PRIORITY 3: Service Name Fallback (only for credentials without URLs - anti-phishing)
|
||||
* 4. PRIORITY 4: Text/Word Matching (non-URL search)
|
||||
*/
|
||||
object CredentialMatcher {
|
||||
|
||||
/**
|
||||
* Common top-level domains (TLDs) used for app package name detection.
|
||||
* When a search string starts with one of these TLDs followed by a dot (e.g., "com.coolblue.app"),
|
||||
* it's identified as a reversed domain name (app package name) rather than a regular URL.
|
||||
* This prevents false matches and enables proper package name handling.
|
||||
*/
|
||||
private val commonTlds = setOf(
|
||||
// Generic TLDs
|
||||
"com", "net", "org", "edu", "gov", "mil", "int",
|
||||
// Country code TLDs
|
||||
"nl", "de", "uk", "fr", "it", "es", "pl", "be", "ch", "at", "se", "no", "dk", "fi",
|
||||
"pt", "gr", "cz", "hu", "ro", "bg", "hr", "sk", "si", "lt", "lv", "ee", "ie", "lu",
|
||||
"us", "ca", "mx", "br", "ar", "cl", "co", "ve", "pe", "ec",
|
||||
"au", "nz", "jp", "cn", "in", "kr", "tw", "hk", "sg", "my", "th", "id", "ph", "vn",
|
||||
"za", "eg", "ng", "ke", "ug", "tz", "ma",
|
||||
"ru", "ua", "by", "kz", "il", "tr", "sa", "ae", "qa", "kw",
|
||||
// New gTLDs (common ones)
|
||||
"app", "dev", "io", "ai", "tech", "shop", "store", "online", "site", "website",
|
||||
"blog", "news", "media", "tv", "video", "music", "pro", "info", "biz", "name",
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if a string is likely an App package name (reversed domain).
|
||||
* App package names start with TLD followed by dot (e.g., "com.example", "nl.app").
|
||||
* @param text The text to check
|
||||
* @return True if it looks like an App package name
|
||||
*/
|
||||
private fun isAppPackageName(text: String): Boolean {
|
||||
if (!text.contains(".")) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (text.startsWith("http://") || text.startsWith("https://")) {
|
||||
return false
|
||||
}
|
||||
|
||||
val firstPart = text.substringBefore(".").lowercase()
|
||||
|
||||
// Check if first part is a common TLD - this indicates reversed domain (package name)
|
||||
return commonTlds.contains(firstPart)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from URL, handling both full URLs and partial domains.
|
||||
* @param urlString URL or domain string
|
||||
* @return Normalized domain without protocol or www, or empty string if not a valid URL/domain
|
||||
*/
|
||||
private fun extractDomain(urlString: String): String {
|
||||
if (urlString.isBlank()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
var domain = urlString.lowercase().trim()
|
||||
|
||||
// Remove protocol if present
|
||||
// Check if it starts with a protocol
|
||||
val hasProtocol = domain.startsWith("http://") || domain.startsWith("https://")
|
||||
|
||||
// If no protocol and starts with TLD + dot, it's likely an App package name
|
||||
// Return empty string to indicate that domain extraction has failed for this string as
|
||||
// this is most likely not a real domain that the caller expects
|
||||
if (!hasProtocol && isAppPackageName(domain)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (hasProtocol) {
|
||||
domain = domain.replace("https://", "").replace("http://", "")
|
||||
}
|
||||
|
||||
// Remove www. prefix
|
||||
domain = domain.replace("www.", "")
|
||||
|
||||
// Remove path, query, and fragment
|
||||
domain = domain.substringBefore("/").substringBefore("?").substringBefore("#")
|
||||
|
||||
// Basic domain validation - must contain at least one dot and valid characters
|
||||
// Only validate after removing path/query/fragment
|
||||
if (!domain.contains(".") || !domain.matches(Regex("^[a-z0-9.-]+$"))) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Final validation - ensure we have a valid domain structure
|
||||
if (domain.isEmpty() || domain.startsWith(".") || domain.endsWith(".") || domain.contains("..")) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract root domain from a domain string.
|
||||
* E.g., "sub.example.com" -> "example.com"
|
||||
* E.g., "sub.example.com.au" -> "example.com.au"
|
||||
* E.g., "sub.example.co.uk" -> "example.co.uk"
|
||||
*/
|
||||
private fun extractRootDomain(domain: String): String {
|
||||
val parts = domain.split(".")
|
||||
if (parts.size < 2) return domain
|
||||
|
||||
// Common two-level public TLDs
|
||||
val twoLevelTlds = setOf(
|
||||
// Australia
|
||||
"com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au",
|
||||
// United Kingdom
|
||||
"co.uk", "org.uk", "net.uk", "ac.uk", "gov.uk", "plc.uk", "ltd.uk", "me.uk",
|
||||
// Canada
|
||||
"co.ca", "net.ca", "org.ca", "gc.ca", "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", "nu.ca",
|
||||
"on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca",
|
||||
// India
|
||||
"co.in", "net.in", "org.in", "edu.in", "gov.in", "ac.in", "res.in", "gen.in", "firm.in", "ind.in",
|
||||
// Japan
|
||||
"co.jp", "ne.jp", "or.jp", "ac.jp", "ad.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp",
|
||||
// South Africa
|
||||
"co.za", "net.za", "org.za", "edu.za", "gov.za", "ac.za", "web.za",
|
||||
// New Zealand
|
||||
"co.nz", "net.nz", "org.nz", "edu.nz", "govt.nz", "ac.nz", "geek.nz", "gen.nz", "kiwi.nz", "maori.nz", "mil.nz", "school.nz",
|
||||
// Brazil
|
||||
"com.br", "net.br", "org.br", "edu.br", "gov.br", "mil.br", "art.br", "etc.br", "adv.br", "arq.br", "bio.br", "cim.br",
|
||||
"cng.br", "cnt.br", "ecn.br", "eng.br", "esp.br", "eti.br", "far.br", "fnd.br", "fot.br", "fst.br", "g12.br", "geo.br",
|
||||
"ggf.br", "jor.br", "lel.br", "mat.br", "med.br", "mus.br", "not.br", "ntr.br", "odo.br", "ppg.br", "pro.br", "psc.br",
|
||||
"psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br", "trd.br", "tur.br", "tv.br", "vet.br", "zlg.br",
|
||||
// Russia
|
||||
"com.ru", "net.ru", "org.ru", "edu.ru", "gov.ru", "int.ru", "mil.ru", "spb.ru", "msk.ru",
|
||||
// China
|
||||
"com.cn", "net.cn", "org.cn", "edu.cn", "gov.cn", "mil.cn", "ac.cn", "ah.cn", "bj.cn", "cq.cn", "fj.cn", "gd.cn", "gs.cn",
|
||||
"gz.cn", "gx.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn", "hk.cn", "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mo.cn",
|
||||
"nm.cn", "nx.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn", "sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn",
|
||||
// Mexico
|
||||
"com.mx", "net.mx", "org.mx", "edu.mx", "gob.mx",
|
||||
// Argentina
|
||||
"com.ar", "net.ar", "org.ar", "edu.ar", "gov.ar", "mil.ar", "int.ar",
|
||||
// Chile
|
||||
"com.cl", "net.cl", "org.cl", "edu.cl", "gov.cl", "mil.cl",
|
||||
// Colombia
|
||||
"com.co", "net.co", "org.co", "edu.co", "gov.co", "mil.co", "nom.co",
|
||||
// Venezuela
|
||||
"com.ve", "net.ve", "org.ve", "edu.ve", "gov.ve", "mil.ve", "web.ve",
|
||||
// Peru
|
||||
"com.pe", "net.pe", "org.pe", "edu.pe", "gob.pe", "mil.pe", "nom.pe",
|
||||
// Ecuador
|
||||
"com.ec", "net.ec", "org.ec", "edu.ec", "gov.ec", "mil.ec", "med.ec", "fin.ec", "pro.ec", "info.ec",
|
||||
// Europe
|
||||
"co.at", "or.at", "ac.at", "gv.at", "priv.at",
|
||||
"co.be", "ac.be",
|
||||
"co.dk", "ac.dk",
|
||||
"co.il", "net.il", "org.il", "ac.il", "gov.il", "idf.il", "k12.il", "muni.il",
|
||||
"co.no", "ac.no", "priv.no",
|
||||
"co.pl", "net.pl", "org.pl", "edu.pl", "gov.pl", "mil.pl", "nom.pl", "com.pl",
|
||||
"co.th", "net.th", "org.th", "edu.th", "gov.th", "mil.th", "ac.th", "in.th",
|
||||
"co.kr", "net.kr", "org.kr", "edu.kr", "gov.kr", "mil.kr", "ac.kr", "go.kr", "ne.kr", "or.kr", "pe.kr", "re.kr", "seoul.kr",
|
||||
"kyonggi.kr",
|
||||
// Others
|
||||
"co.id", "net.id", "org.id", "edu.id", "gov.id", "mil.id", "web.id", "ac.id", "sch.id",
|
||||
"co.ma", "net.ma", "org.ma", "edu.ma", "gov.ma", "ac.ma", "press.ma",
|
||||
"co.ke", "net.ke", "org.ke", "edu.ke", "gov.ke", "ac.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "sc.ke",
|
||||
"co.ug", "net.ug", "org.ug", "edu.ug", "gov.ug", "ac.ug", "sc.ug", "go.ug", "ne.ug", "or.ug",
|
||||
"co.tz", "net.tz", "org.tz", "edu.tz", "gov.tz", "ac.tz", "go.tz", "hotel.tz", "info.tz", "me.tz", "mil.tz", "mobi.tz",
|
||||
"ne.tz", "or.tz", "sc.tz", "tv.tz",
|
||||
)
|
||||
|
||||
// Check if the last two parts form a known two-level TLD
|
||||
if (parts.size >= 3) {
|
||||
val lastTwoParts = parts.takeLast(2).joinToString(".")
|
||||
if (twoLevelTlds.contains(lastTwoParts)) {
|
||||
// Take the last three parts for two-level TLDs
|
||||
return parts.takeLast(3).joinToString(".")
|
||||
}
|
||||
}
|
||||
|
||||
// Default to last two parts for regular TLDs
|
||||
return if (parts.size >= 2) {
|
||||
parts.takeLast(2).joinToString(".")
|
||||
} else {
|
||||
domain
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two domains match, supporting partial matches.
|
||||
* @param domain1 First domain
|
||||
* @param domain2 Second domain
|
||||
* @return True if domains match (including partial matches)
|
||||
*/
|
||||
private fun domainsMatch(domain1: String, domain2: String): Boolean {
|
||||
val d1 = extractDomain(domain1)
|
||||
val d2 = extractDomain(domain2)
|
||||
|
||||
// If either extracted domain is empty, early return false.
|
||||
if (d1.isEmpty() || d2.isEmpty()) return false
|
||||
|
||||
// Exact match
|
||||
if (d1 == d2) return true
|
||||
|
||||
// Check if one domain contains the other (for subdomain matching)
|
||||
if (d1.contains(d2) || d2.contains(d1)) return true
|
||||
|
||||
// Check root domain match
|
||||
val d1Root = extractRootDomain(d1)
|
||||
val d2Root = extractRootDomain(d2)
|
||||
|
||||
return d1Root == d2Root
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful words from text, removing punctuation and filtering stop words.
|
||||
* @param text Text to extract words from
|
||||
* @return List of filtered words
|
||||
*/
|
||||
private fun extractWords(text: String): List<String> {
|
||||
if (text.isBlank()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return text.lowercase()
|
||||
// Replace common separators and punctuation with spaces (including dots)
|
||||
.replace(Regex("[|,;:\\-–—/\\\\()\\[\\]{}'\" ~!@#$%^&*+=<>?.]"), " ")
|
||||
.split(Regex("\\s+"))
|
||||
.filter { word ->
|
||||
word.length > 3 // Filter out short words
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter credentials based on search text with anti-phishing protection.
|
||||
*
|
||||
* This method follows a strict priority-based algorithm with early returns:
|
||||
* 1. PRIORITY 1: App Package Name Exact Match (highest priority)
|
||||
* 2. PRIORITY 2: URL Domain Matching
|
||||
* 3. PRIORITY 3: Service Name Fallback (anti-phishing protection)
|
||||
* 4. PRIORITY 4: Text/Word Matching (lowest priority)
|
||||
*
|
||||
* @param credentials List of credentials to filter
|
||||
* @param searchText Search term (app package name, URL, or text)
|
||||
* @return Filtered list of credentials
|
||||
*
|
||||
* **Security Note**: Priority 3 only searches credentials with no service URL defined.
|
||||
* This prevents phishing attacks where a malicious site might match credentials
|
||||
* intended for a legitimate site.
|
||||
*/
|
||||
fun filterCredentialsByAppInfo(
|
||||
credentials: List<Credential>,
|
||||
searchText: String,
|
||||
): List<Credential> {
|
||||
// Early return for empty search
|
||||
if (searchText.isEmpty()) {
|
||||
return credentials
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// PRIORITY 1: App Package Name Exact Match
|
||||
// Check if search text is an app package name (e.g., com.coolblue.app)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
if (isAppPackageName(searchText)) {
|
||||
// Perform exact string match on ServiceUrl field
|
||||
val packageMatches = credentials.filter { credential ->
|
||||
val serviceUrl = credential.service.url
|
||||
!serviceUrl.isNullOrEmpty() && searchText == serviceUrl
|
||||
}
|
||||
|
||||
// EARLY RETURN if matches found
|
||||
if (packageMatches.isNotEmpty()) {
|
||||
return packageMatches
|
||||
}
|
||||
// If no matches found, continue to next priority
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// PRIORITY 2: URL Domain Matching
|
||||
// Try to extract domain from search text
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
val searchDomain = extractDomain(searchText)
|
||||
|
||||
if (searchDomain.isNotEmpty()) {
|
||||
// Valid domain extracted - perform domain matching
|
||||
val domainMatches = credentials.filter { credential ->
|
||||
val serviceUrl = credential.service.url
|
||||
!serviceUrl.isNullOrEmpty() && domainsMatch(searchText, serviceUrl)
|
||||
}
|
||||
|
||||
// EARLY RETURN if matches found
|
||||
if (domainMatches.isNotEmpty()) {
|
||||
return domainMatches
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PRIORITY 3: Service Name Fallback (Anti-Phishing Protection)
|
||||
// No domain matches found - search in service names
|
||||
// CRITICAL: Only search credentials with NO service URL defined
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
val domainParts = searchDomain.split(".")
|
||||
val domainWithoutExtension = domainParts.firstOrNull()?.lowercase() ?: searchDomain.lowercase()
|
||||
|
||||
val nameMatches = credentials.filter { credential ->
|
||||
// SECURITY: Skip credentials that have a URL defined
|
||||
if (!credential.service.url.isNullOrEmpty()) {
|
||||
return@filter false
|
||||
}
|
||||
|
||||
// Search in ServiceName and Notes using substring contains
|
||||
val serviceNameMatch = credential.service.name?.lowercase()?.contains(domainWithoutExtension) ?: false
|
||||
val notesMatch = credential.notes?.lowercase()?.contains(domainWithoutExtension) ?: false
|
||||
serviceNameMatch || notesMatch
|
||||
}
|
||||
|
||||
// Return matches from Priority 3 (don't continue to Priority 4)
|
||||
return nameMatches
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// PRIORITY 4: Text/Word Matching
|
||||
// Search text is not a URL or package name - perform text-based matching
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
val searchWords = extractWords(searchText)
|
||||
|
||||
if (searchWords.isEmpty()) {
|
||||
// If no meaningful words after extraction, fall back to simple substring contains
|
||||
val lowercasedSearch = searchText.lowercase()
|
||||
return credentials.filter { credential ->
|
||||
(credential.service.name?.lowercase()?.contains(lowercasedSearch) ?: false) ||
|
||||
(credential.username?.lowercase()?.contains(lowercasedSearch) ?: false) ||
|
||||
(credential.notes?.lowercase()?.contains(lowercasedSearch) ?: false)
|
||||
}
|
||||
}
|
||||
|
||||
// Match using extracted words - exact word matching only
|
||||
return credentials.filter { credential ->
|
||||
val serviceNameWords = credential.service.name?.let { extractWords(it) } ?: emptyList()
|
||||
val usernameWords = credential.username?.let { extractWords(it) } ?: emptyList()
|
||||
val notesWords = credential.notes?.let { extractWords(it) } ?: emptyList()
|
||||
|
||||
// Check if any search word matches any credential word exactly
|
||||
searchWords.any { searchWord ->
|
||||
serviceNameWords.contains(searchWord) ||
|
||||
usernameWords.contains(searchWord) ||
|
||||
notesWords.contains(searchWord)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package net.aliasvault.app.autofill.utils
|
||||
|
||||
import android.util.Log
|
||||
import net.aliasvault.app.vaultstore.models.Credential
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Wrapper for the Rust credential matcher using UniFFI bindings.
|
||||
*/
|
||||
object RustCredentialMatcher {
|
||||
private const val TAG = "RustCredentialMatcher"
|
||||
|
||||
/**
|
||||
* Filter credentials based on app/website info using the Rust core credential matcher.
|
||||
*
|
||||
* @param credentials List of credentials to filter
|
||||
* @param searchText Search term (app package name, URL, or text)
|
||||
* @return Filtered list of credentials
|
||||
*/
|
||||
fun filterCredentialsByAppInfo(
|
||||
credentials: List<Credential>,
|
||||
searchText: String,
|
||||
): List<Credential> {
|
||||
// Early return for empty search
|
||||
if (searchText.isEmpty()) {
|
||||
return credentials
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert credentials to JSON format expected by Rust
|
||||
val rustCredentials = JSONArray()
|
||||
val credentialMap = mutableMapOf<String, Credential>()
|
||||
|
||||
for (credential in credentials) {
|
||||
val idString = credential.id.toString()
|
||||
val credJson = JSONObject().apply {
|
||||
put("Id", idString)
|
||||
put("ServiceName", credential.service.name ?: JSONObject.NULL)
|
||||
put("ServiceUrl", credential.service.url ?: JSONObject.NULL)
|
||||
put("Username", credential.username ?: JSONObject.NULL)
|
||||
}
|
||||
rustCredentials.put(credJson)
|
||||
credentialMap[idString] = credential
|
||||
}
|
||||
|
||||
// Prepare input JSON for Rust
|
||||
val input = JSONObject().apply {
|
||||
put("credentials", rustCredentials)
|
||||
put("current_url", searchText)
|
||||
put("page_title", "")
|
||||
put("matching_mode", "default")
|
||||
}
|
||||
|
||||
// Call Rust via UniFFI
|
||||
val outputJson = uniffi.aliasvault_core.filterCredentialsJson(input.toString())
|
||||
|
||||
// Parse output
|
||||
val output = JSONObject(outputJson)
|
||||
val matchedIds = output.getJSONArray("matched_ids")
|
||||
|
||||
// If no matches found, return empty list
|
||||
if (matchedIds.length() == 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Convert matched IDs back to credentials, maintaining order
|
||||
val filtered = mutableListOf<Credential>()
|
||||
for (i in 0 until matchedIds.length()) {
|
||||
val id = matchedIds.getString(i)
|
||||
credentialMap[id]?.let { filtered.add(it) }
|
||||
}
|
||||
|
||||
return filtered
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error filtering credentials with Rust matcher: ${e.message}", e)
|
||||
// Fallback to returning all credentials on error
|
||||
return credentials
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
package net.aliasvault.app.nativevaultmanager
|
||||
|
||||
import net.aliasvault.app.autofill.utils.CredentialMatcher
|
||||
import net.aliasvault.app.vaultstore.models.Credential
|
||||
import net.aliasvault.app.vaultstore.models.Service
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlin.test.DefaultAsserter.assertEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class AutofillTest {
|
||||
private lateinit var testCredentials: List<Credential>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// Create test credentials using shared test data structure
|
||||
testCredentials = createSharedTestCredentials()
|
||||
}
|
||||
|
||||
// [#1] - Exact URL match
|
||||
@Test
|
||||
fun testExactUrlMatch() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"www.coolblue.nl",
|
||||
)
|
||||
|
||||
assertEquals(1, matches.size)
|
||||
assertEquals("Coolblue", matches[0].service.name)
|
||||
}
|
||||
|
||||
// [#2] - Base URL with path match
|
||||
@Test
|
||||
fun testBaseUrlMatch() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://gmail.com/signin",
|
||||
)
|
||||
|
||||
assertEquals(1, matches.size)
|
||||
assertEquals("Gmail", matches[0].service.name)
|
||||
}
|
||||
|
||||
// [#3] - Root domain with subdomain match
|
||||
@Test
|
||||
fun testRootDomainMatch() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://mail.google.com",
|
||||
)
|
||||
|
||||
assertEquals(1, matches.size)
|
||||
assertEquals("Google", matches[0].service.name)
|
||||
}
|
||||
|
||||
// [#4] - No matches for non-existent domain
|
||||
@Test
|
||||
fun testNoMatches() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://nonexistent.com",
|
||||
)
|
||||
|
||||
assertTrue(matches.isEmpty())
|
||||
}
|
||||
|
||||
// [#5] - Partial URL stored matches full URL search
|
||||
@Test
|
||||
fun testPartialUrlMatchWithFullUrl() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://www.dumpert.nl",
|
||||
)
|
||||
|
||||
assertEquals(1, matches.size)
|
||||
assertEquals("Dumpert", matches[0].service.name)
|
||||
}
|
||||
|
||||
// [#6] - Full URL stored matches partial URL search
|
||||
@Test
|
||||
fun testFullUrlMatchWithPartialUrl() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"coolblue.nl",
|
||||
)
|
||||
|
||||
assertEquals(1, matches.size)
|
||||
assertEquals("Coolblue", matches[0].service.name)
|
||||
}
|
||||
|
||||
// [#7] - Protocol variations (http/https/none) match
|
||||
@Test
|
||||
fun testProtocolVariations() {
|
||||
// Test that http and https variations match
|
||||
val httpsMatches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://github.com",
|
||||
)
|
||||
val httpMatches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"http://github.com",
|
||||
)
|
||||
val noProtocolMatches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"github.com",
|
||||
)
|
||||
|
||||
assertEquals(1, httpsMatches.size)
|
||||
assertEquals(1, httpMatches.size)
|
||||
assertEquals(1, noProtocolMatches.size)
|
||||
assertEquals("GitHub", httpsMatches[0].service.name)
|
||||
assertEquals("GitHub", httpMatches[0].service.name)
|
||||
assertEquals("GitHub", noProtocolMatches[0].service.name)
|
||||
}
|
||||
|
||||
// [#8] - WWW prefix variations match
|
||||
@Test
|
||||
fun testWwwVariations() {
|
||||
// Test that www variations match
|
||||
val withWww = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"www.dumpert.nl",
|
||||
)
|
||||
val withoutWww = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"dumpert.nl",
|
||||
)
|
||||
|
||||
assertEquals(1, withWww.size)
|
||||
assertEquals(1, withoutWww.size)
|
||||
assertEquals("Dumpert", withWww[0].service.name)
|
||||
assertEquals("Dumpert", withoutWww[0].service.name)
|
||||
}
|
||||
|
||||
// [#9] - Subdomain matching
|
||||
@Test
|
||||
fun testSubdomainMatching() {
|
||||
// Test subdomain matching
|
||||
val appSubdomain = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://app.example.com",
|
||||
)
|
||||
val wwwSubdomain = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://www.example.com",
|
||||
)
|
||||
val noSubdomain = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://example.com",
|
||||
)
|
||||
|
||||
assertEquals(1, appSubdomain.size)
|
||||
assertEquals("Subdomain Example", appSubdomain[0].service.name)
|
||||
assertEquals(1, wwwSubdomain.size)
|
||||
assertEquals("Subdomain Example", wwwSubdomain[0].service.name)
|
||||
assertEquals(1, noSubdomain.size)
|
||||
assertEquals("Subdomain Example", noSubdomain[0].service.name)
|
||||
}
|
||||
|
||||
// [#10] - Paths and query strings ignored
|
||||
@Test
|
||||
fun testPathAndQueryIgnored() {
|
||||
// Test that paths and query strings are ignored
|
||||
val withPath = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://github.com/user/repo",
|
||||
)
|
||||
val withQuery = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://stackoverflow.com/questions?tab=newest",
|
||||
)
|
||||
val withFragment = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://gmail.com#inbox",
|
||||
)
|
||||
|
||||
assertEquals(1, withPath.size)
|
||||
assertEquals("GitHub", withPath[0].service.name)
|
||||
assertEquals(1, withQuery.size)
|
||||
assertEquals("Stack Overflow", withQuery[0].service.name)
|
||||
assertEquals(1, withFragment.size)
|
||||
assertEquals("Gmail", withFragment[0].service.name)
|
||||
}
|
||||
|
||||
// [#11] - Complex URL variations
|
||||
@Test
|
||||
fun testComplexUrlVariations() {
|
||||
// Test complex URL matching scenario
|
||||
val complexUrl = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://www.coolblue.nl/product/12345?ref=google",
|
||||
)
|
||||
|
||||
assertEquals(1, complexUrl.size)
|
||||
assertEquals("Coolblue", complexUrl[0].service.name)
|
||||
}
|
||||
|
||||
// [#12] - Priority ordering
|
||||
@Test
|
||||
fun testPriorityOrdering() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"coolblue.nl",
|
||||
)
|
||||
|
||||
assertEquals(1, matches.size)
|
||||
assertEquals("Coolblue", matches[0].service.name)
|
||||
}
|
||||
|
||||
// [#13] - Title-only matching
|
||||
@Test
|
||||
fun testTitleOnlyMatching() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"newyorktimes",
|
||||
)
|
||||
|
||||
assertEquals(1, matches.size)
|
||||
assertEquals("Title Only newyorktimes", matches[0].service.name)
|
||||
}
|
||||
|
||||
// [#14] - Domain name part matching
|
||||
@Test
|
||||
fun testDomainNamePartMatch() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://coolblue.be",
|
||||
)
|
||||
|
||||
assertTrue(matches.isEmpty())
|
||||
}
|
||||
|
||||
// [#15] - Package name matching
|
||||
@Test
|
||||
fun testPackageNameMatch() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"com.coolblue.app",
|
||||
)
|
||||
assertEquals(1, matches.size)
|
||||
assertTrue(matches.any { it.service.name == "Coolblue App" })
|
||||
}
|
||||
|
||||
// [#16] - Invalid URL handling
|
||||
@Test
|
||||
fun testInvalidUrl() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"not a url",
|
||||
)
|
||||
|
||||
assertTrue(matches.isEmpty())
|
||||
}
|
||||
|
||||
// [#17] - Anti-phishing protection
|
||||
@Test
|
||||
fun testAntiPhishingProtection() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"https://secure-bankk.com",
|
||||
)
|
||||
assertTrue(matches.isEmpty())
|
||||
}
|
||||
|
||||
// [#18] - Ensure only full words are matched
|
||||
@Test
|
||||
fun testOnlyFullWordsMatch() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"Express Yourself App | Description",
|
||||
)
|
||||
|
||||
// The string above should not match "AliExpress" service name
|
||||
assertTrue(matches.isEmpty())
|
||||
}
|
||||
|
||||
// [#19] - Ensure separators and punctuation are stripped for matching
|
||||
@Test
|
||||
fun testSeparatorsAndPunctuationStripped() {
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
testCredentials,
|
||||
"Reddit, social media platform",
|
||||
)
|
||||
|
||||
// Should match "Reddit" even though it's followed by a comma and description
|
||||
assertEquals(1, matches.size)
|
||||
assertEquals("Reddit", matches[0].service.name)
|
||||
}
|
||||
|
||||
// [#20] - Test reversed domain (App package name) doesn't match on TLD
|
||||
@Test
|
||||
fun testReversedDomainTldCheck() {
|
||||
// Test that dumpert.nl credential doesn't match nl.marktplaats.android package
|
||||
// They both contain "nl" in the name but shouldn't match since "nl" is just a TLD
|
||||
val reversedDomainCredentials = listOf(
|
||||
createTestCredential("Dumpert.nl", "", "user@dumpert.nl"),
|
||||
createTestCredential("Marktplaats.nl", "", "user@marktplaats.nl"),
|
||||
)
|
||||
|
||||
val matches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
reversedDomainCredentials,
|
||||
"nl.marktplaats.android",
|
||||
)
|
||||
|
||||
// Should only match Marktplaats, not Dumpert (even though both have "nl")
|
||||
assertEquals(1, matches.size)
|
||||
assertEquals("Marktplaats.nl", matches[0].service.name)
|
||||
}
|
||||
|
||||
// [#21] - Test App package names are properly detected and handled
|
||||
@Test
|
||||
fun testAppPackageNameDetection() {
|
||||
val packageCredentials = listOf(
|
||||
createTestCredential("Google App", "com.google.android.googlequicksearchbox", "user@google.com"),
|
||||
createTestCredential("Facebook", "com.facebook.katana", "user@facebook.com"),
|
||||
createTestCredential("WhatsApp", "com.whatsapp", "user@whatsapp.com"),
|
||||
createTestCredential("Generic Site", "example.com", "user@example.com"),
|
||||
)
|
||||
|
||||
// Test com.google.android package matches
|
||||
val googleMatches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
packageCredentials,
|
||||
"com.google.android.googlequicksearchbox",
|
||||
)
|
||||
assertEquals(1, googleMatches.size)
|
||||
assertEquals("Google App", googleMatches[0].service.name)
|
||||
|
||||
// Test com.facebook package matches
|
||||
val facebookMatches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
packageCredentials,
|
||||
"com.facebook.katana",
|
||||
)
|
||||
assertEquals(1, facebookMatches.size)
|
||||
assertEquals("Facebook", facebookMatches[0].service.name)
|
||||
|
||||
// Test that web domain doesn't match package name
|
||||
val webMatches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
packageCredentials,
|
||||
"https://example.com",
|
||||
)
|
||||
assertEquals(1, webMatches.size)
|
||||
assertEquals("Generic Site", webMatches[0].service.name)
|
||||
}
|
||||
|
||||
// [#22] - Test multi-part TLDs like .com.au don't match incorrectly
|
||||
@Test
|
||||
fun testMultiPartTldNoFalseMatches() {
|
||||
// Create test data with different .com.au domains
|
||||
val australianCredentials = listOf(
|
||||
createTestCredential("Example Site AU", "https://example.com.au", "user@example.com.au"),
|
||||
createTestCredential("BlaBla AU", "https://blabla.blabla.com.au", "user@blabla.com.au"),
|
||||
createTestCredential("Another AU", "https://another.com.au", "user@another.com.au"),
|
||||
createTestCredential("UK Site", "https://example.co.uk", "user@example.co.uk"),
|
||||
)
|
||||
|
||||
// Test that blabla.blabla.com.au doesn't match other .com.au sites
|
||||
val blablaMatches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
australianCredentials,
|
||||
"https://blabla.blabla.com.au",
|
||||
)
|
||||
assertEquals(1, blablaMatches.size, "Should only match the exact domain, not all .com.au sites")
|
||||
assertEquals("BlaBla AU", blablaMatches[0].service.name)
|
||||
|
||||
// Test that example.com.au doesn't match blabla.blabla.com.au
|
||||
val exampleMatches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
australianCredentials,
|
||||
"https://example.com.au",
|
||||
)
|
||||
assertEquals(1, exampleMatches.size, "Should only match example.com.au")
|
||||
assertEquals("Example Site AU", exampleMatches[0].service.name)
|
||||
|
||||
// Test that .co.uk domains work correctly too
|
||||
val ukMatches = CredentialMatcher.filterCredentialsByAppInfo(
|
||||
australianCredentials,
|
||||
"https://example.co.uk",
|
||||
)
|
||||
assertEquals(1, ukMatches.size, "Should only match the .co.uk domain")
|
||||
assertEquals("UK Site", ukMatches[0].service.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the shared test credential dataset used across all platforms.
|
||||
* This ensures consistent testing across Browser Extension, iOS, and Android.
|
||||
*/
|
||||
private fun createSharedTestCredentials(): List<Credential> {
|
||||
return listOf(
|
||||
createTestCredential("Gmail", "https://gmail.com", "user@gmail.com"),
|
||||
createTestCredential("Google", "https://google.com", "user@google.com"),
|
||||
createTestCredential("Coolblue", "https://www.coolblue.nl", "user@coolblue.nl"),
|
||||
createTestCredential("Amazon", "https://amazon.com", "user@amazon.com"),
|
||||
createTestCredential("Coolblue App", "com.coolblue.app", "user@coolblue.nl"),
|
||||
createTestCredential("Dumpert", "dumpert.nl", "user@dumpert.nl"),
|
||||
createTestCredential("GitHub", "github.com", "user@github.com"),
|
||||
createTestCredential("Stack Overflow", "https://stackoverflow.com", "user@stackoverflow.com"),
|
||||
createTestCredential("Subdomain Example", "https://app.example.com", "user@example.com"),
|
||||
createTestCredential("Title Only newyorktimes", "", ""),
|
||||
createTestCredential("Bank Account", "https://secure-bank.com", "user@bank.com"),
|
||||
createTestCredential("AliExpress", "https://aliexpress.com", "user@aliexpress.com"),
|
||||
createTestCredential("Reddit", "", "user@reddit.com"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create test credentials with standardized structure.
|
||||
* @param serviceName The name of the service
|
||||
* @param serviceUrl The URL of the service
|
||||
* @param username The username for the service
|
||||
* @return A test credential matching the Android Credential type
|
||||
*/
|
||||
private fun createTestCredential(
|
||||
serviceName: String,
|
||||
serviceUrl: String,
|
||||
username: String,
|
||||
): Credential {
|
||||
return Credential(
|
||||
id = UUID.randomUUID(),
|
||||
service = Service(
|
||||
id = UUID.randomUUID(),
|
||||
name = serviceName,
|
||||
url = serviceUrl,
|
||||
logo = null,
|
||||
createdAt = Date(),
|
||||
updatedAt = Date(),
|
||||
isDeleted = false,
|
||||
),
|
||||
username = username,
|
||||
password = null,
|
||||
alias = null,
|
||||
notes = null,
|
||||
createdAt = Date(),
|
||||
updatedAt = Date(),
|
||||
isDeleted = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
5
apps/mobile-app/ios/.gitignore
vendored
5
apps/mobile-app/ios/.gitignore
vendored
@@ -35,3 +35,8 @@ project.xcworkspace
|
||||
/VaultStoreKit/RustCore/lib/
|
||||
/VaultStoreKit/RustCore/include/
|
||||
/VaultStoreKit/RustCore/.rust-core-checksum
|
||||
|
||||
/VaultUI/RustCore/Generated/
|
||||
/VaultUI/RustCore/lib/
|
||||
/VaultUI/RustCore/include/
|
||||
/VaultUI/RustCore/.rust-core-checksum
|
||||
|
||||
@@ -1850,18 +1850,36 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/VaultUI/RustCore/lib/device",
|
||||
"$(PROJECT_DIR)/VaultUI/RustCore/lib/simulator",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/VaultUI/RustCore/lib/$(RUST_LIB_PLATFORM)",
|
||||
"$(PROJECT_DIR)/VaultUI/RustCore/lib/device",
|
||||
"$(PROJECT_DIR)/VaultUI/RustCore/lib/simulator",
|
||||
);
|
||||
MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++";
|
||||
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20";
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-laliasvault_core",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.aliasvault.app.VaultUI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
"RUST_LIB_PLATFORM[sdk=iphoneos*]" = device;
|
||||
"RUST_LIB_PLATFORM[sdk=iphonesimulator*]" = simulator;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/VaultUI/RustCore/include";
|
||||
SWIFT_INSTALL_MODULE = YES;
|
||||
SWIFT_INSTALL_OBJC_HEADER = NO;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -1903,17 +1921,29 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/VaultUI/RustCore/lib/device",
|
||||
"$(PROJECT_DIR)/VaultUI/RustCore/lib/simulator",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++";
|
||||
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20";
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-laliasvault_core",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.aliasvault.app.VaultUI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
"RUST_LIB_PLATFORM[sdk=iphoneos*]" = device;
|
||||
"RUST_LIB_PLATFORM[sdk=iphonesimulator*]" = simulator;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/VaultUI/RustCore/include";
|
||||
SWIFT_INSTALL_MODULE = YES;
|
||||
SWIFT_INSTALL_OBJC_HEADER = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
26
apps/mobile-app/ios/VaultUI/RustCore/README.md
Normal file
26
apps/mobile-app/ios/VaultUI/RustCore/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Rust Core iOS Library
|
||||
|
||||
Auto-generated from `/core/rust`. Do not edit manually.
|
||||
|
||||
## Contents
|
||||
|
||||
- `lib/device/libaliasvault_core.a` - Static library for iOS devices (arm64)
|
||||
- `lib/simulator/libaliasvault_core.a` - Static library for iOS simulator (arm64 Apple Silicon)
|
||||
- `include/` - C headers and modulemap for UniFFI bindings
|
||||
- `Generated/` - Swift bindings generated by UniFFI
|
||||
|
||||
## Regenerate
|
||||
|
||||
```bash
|
||||
cd /core/rust
|
||||
./build.sh --ios
|
||||
```
|
||||
|
||||
## Xcode Integration
|
||||
|
||||
The library is automatically built by the Xcode build phase which calls:
|
||||
```bash
|
||||
../../core/rust/build.sh --ios --incremental
|
||||
```
|
||||
|
||||
Build settings use `RUST_LIB_PLATFORM` to select device vs simulator library.
|
||||
@@ -257,7 +257,7 @@ public class CredentialProviderViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func filterCredentials() {
|
||||
filteredCredentials = CredentialMatcher.filterCredentials(credentials, searchText: searchText)
|
||||
filteredCredentials = RustCredentialMatcher.filterCredentials(credentials, searchText: searchText)
|
||||
}
|
||||
|
||||
func handleSelection(username: String, password: String) {
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
import Foundation
|
||||
import VaultModels
|
||||
|
||||
/// Utility class for matching credentials against app/website information for autofill.
|
||||
/// This implementation follows the unified filtering algorithm specification defined in
|
||||
/// docs/CREDENTIAL_FILTERING_SPEC.md for cross-platform consistency with Android and Browser Extension.
|
||||
///
|
||||
/// Algorithm Structure (Priority Order with Early Returns):
|
||||
/// 1. PRIORITY 1: App Package Name Exact Match (e.g., com.coolblue.app)
|
||||
/// 2. PRIORITY 2: URL Domain Matching (exact, subdomain, root domain)
|
||||
/// 3. PRIORITY 3: Service Name Fallback (only for credentials without URLs - anti-phishing)
|
||||
/// 4. PRIORITY 4: Text/Word Matching (non-URL search)
|
||||
public class CredentialMatcher {
|
||||
// swiftlint:disable function_body_length
|
||||
|
||||
/// Common top-level domains (TLDs) used for app package name detection.
|
||||
/// When a search string starts with one of these TLDs followed by a dot (e.g., "com.coolblue.app"),
|
||||
/// it's identified as a reversed domain name (app package name) rather than a regular URL.
|
||||
/// This prevents false matches and enables proper package name handling.
|
||||
private static let commonTlds: Set<String> = [
|
||||
// Generic TLDs
|
||||
"com", "net", "org", "edu", "gov", "mil", "int",
|
||||
// Country code TLDs
|
||||
"nl", "de", "uk", "fr", "it", "es", "pl", "be", "ch", "at", "se", "no", "dk", "fi",
|
||||
"pt", "gr", "cz", "hu", "ro", "bg", "hr", "sk", "si", "lt", "lv", "ee", "ie", "lu",
|
||||
"us", "ca", "mx", "br", "ar", "cl", "co", "ve", "pe", "ec",
|
||||
"au", "nz", "jp", "cn", "in", "kr", "tw", "hk", "sg", "my", "th", "id", "ph", "vn",
|
||||
"za", "eg", "ng", "ke", "ug", "tz", "ma",
|
||||
"ru", "ua", "by", "kz", "il", "tr", "sa", "ae", "qa", "kw",
|
||||
// New gTLDs (common ones)
|
||||
"app", "dev", "io", "ai", "tech", "shop", "store", "online", "site", "website",
|
||||
"blog", "news", "media", "tv", "video", "music", "pro", "info", "biz", "name"
|
||||
]
|
||||
|
||||
/// Check if a string is likely an app package name (reversed domain).
|
||||
/// Package names start with TLD followed by dot (e.g., "com.example", "nl.app").
|
||||
/// - Parameter text: The text to check
|
||||
/// - Returns: True if it looks like an app package name
|
||||
private static func isAppPackageName(_ text: String) -> Bool {
|
||||
// Must contain a dot
|
||||
guard text.contains(".") else { return false }
|
||||
|
||||
// Must not have protocol
|
||||
if text.hasPrefix("http://") || text.hasPrefix("https://") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract first part before first dot
|
||||
let firstPart = text.components(separatedBy: ".").first?.lowercased() ?? ""
|
||||
|
||||
// Check if first part is a common TLD - indicates reversed domain (package name)
|
||||
return commonTlds.contains(firstPart)
|
||||
}
|
||||
|
||||
/// Extract domain from URL, handling both full URLs and partial domains.
|
||||
/// - Parameter urlString: URL or domain string
|
||||
/// - Returns: Normalized domain without protocol or www, or empty string if not a valid URL/domain
|
||||
private static func extractDomain(from urlString: String) -> String {
|
||||
guard !urlString.isEmpty else { return "" }
|
||||
|
||||
var domain = urlString.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Check if it starts with a protocol
|
||||
let hasProtocol = domain.hasPrefix("http://") || domain.hasPrefix("https://")
|
||||
|
||||
// If no protocol and starts with TLD + dot, it's likely an app package name
|
||||
// Return empty string to indicate that domain extraction has failed for this string
|
||||
if !hasProtocol && isAppPackageName(domain) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove protocol if present
|
||||
if hasProtocol {
|
||||
domain = domain.replacingOccurrences(of: "https://", with: "")
|
||||
domain = domain.replacingOccurrences(of: "http://", with: "")
|
||||
}
|
||||
|
||||
// Remove www. prefix
|
||||
domain = domain.replacingOccurrences(of: "www.", with: "")
|
||||
|
||||
// Remove path, query, and fragment
|
||||
if let firstSlash = domain.firstIndex(of: "/") {
|
||||
domain = String(domain[..<firstSlash])
|
||||
}
|
||||
if let firstQuestion = domain.firstIndex(of: "?") {
|
||||
domain = String(domain[..<firstQuestion])
|
||||
}
|
||||
if let firstHash = domain.firstIndex(of: "#") {
|
||||
domain = String(domain[..<firstHash])
|
||||
}
|
||||
|
||||
// Basic domain validation - must contain at least one dot and valid characters
|
||||
do {
|
||||
let domainRegex = try NSRegularExpression(pattern: "^[a-z0-9.-]+$")
|
||||
|
||||
let range = NSRange(location: 0, length: domain.utf16.count)
|
||||
if !domain.contains(".") || domainRegex.firstMatch(in: domain, options: [], range: range) == nil {
|
||||
return ""
|
||||
}
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Ensure we have a valid domain structure
|
||||
if domain.isEmpty || domain.hasPrefix(".") || domain.hasSuffix(".") || domain.contains("..") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
/// Extract root domain from a domain string.
|
||||
/// - Parameter domain: Domain string
|
||||
/// - Returns: Root domain (e.g., "sub.example.com" -> "example.com", "sub.example.com.au" -> "example.com.au", "sub.example.co.uk" -> "example.co.uk")
|
||||
private static func extractRootDomain(from domain: String) -> String {
|
||||
let parts = domain.components(separatedBy: ".")
|
||||
guard parts.count >= 2 else { return domain }
|
||||
|
||||
// Common two-level public TLDs
|
||||
let twoLevelTlds: Set<String> = [
|
||||
// Australia
|
||||
"com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au",
|
||||
// United Kingdom
|
||||
"co.uk", "org.uk", "net.uk", "ac.uk", "gov.uk", "plc.uk", "ltd.uk", "me.uk",
|
||||
// Canada
|
||||
"co.ca", "net.ca", "org.ca", "gc.ca", "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", "nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca",
|
||||
// India
|
||||
"co.in", "net.in", "org.in", "edu.in", "gov.in", "ac.in", "res.in", "gen.in", "firm.in", "ind.in",
|
||||
// Japan
|
||||
"co.jp", "ne.jp", "or.jp", "ac.jp", "ad.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp",
|
||||
// South Africa
|
||||
"co.za", "net.za", "org.za", "edu.za", "gov.za", "ac.za", "web.za",
|
||||
// New Zealand
|
||||
"co.nz", "net.nz", "org.nz", "edu.nz", "govt.nz", "ac.nz", "geek.nz", "gen.nz", "kiwi.nz", "maori.nz", "mil.nz", "school.nz",
|
||||
// Brazil
|
||||
"com.br", "net.br", "org.br", "edu.br", "gov.br", "mil.br", "art.br", "etc.br", "adv.br", "arq.br", "bio.br", "cim.br", "cng.br", "cnt.br", "ecn.br", "eng.br",
|
||||
"esp.br", "eti.br", "far.br", "fnd.br", "fot.br", "fst.br", "g12.br", "geo.br", "ggf.br", "jor.br", "lel.br", "mat.br", "med.br", "mus.br", "not.br", "ntr.br",
|
||||
"odo.br", "ppg.br", "pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br", "trd.br", "tur.br", "tv.br", "vet.br", "zlg.br",
|
||||
// Russia
|
||||
"com.ru", "net.ru", "org.ru", "edu.ru", "gov.ru", "int.ru", "mil.ru", "spb.ru", "msk.ru",
|
||||
// China
|
||||
"com.cn", "net.cn", "org.cn", "edu.cn", "gov.cn", "mil.cn", "ac.cn", "ah.cn", "bj.cn", "cq.cn", "fj.cn", "gd.cn", "gs.cn", "gz.cn", "gx.cn", "ha.cn", "hb.cn",
|
||||
"he.cn", "hi.cn", "hk.cn", "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mo.cn", "nm.cn", "nx.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn",
|
||||
"sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn",
|
||||
// Mexico
|
||||
"com.mx", "net.mx", "org.mx", "edu.mx", "gob.mx",
|
||||
// Argentina
|
||||
"com.ar", "net.ar", "org.ar", "edu.ar", "gov.ar", "mil.ar", "int.ar",
|
||||
// Chile
|
||||
"com.cl", "net.cl", "org.cl", "edu.cl", "gov.cl", "mil.cl",
|
||||
// Colombia
|
||||
"com.co", "net.co", "org.co", "edu.co", "gov.co", "mil.co", "nom.co",
|
||||
// Venezuela
|
||||
"com.ve", "net.ve", "org.ve", "edu.ve", "gov.ve", "mil.ve", "web.ve",
|
||||
// Peru
|
||||
"com.pe", "net.pe", "org.pe", "edu.pe", "gob.pe", "mil.pe", "nom.pe",
|
||||
// Ecuador
|
||||
"com.ec", "net.ec", "org.ec", "edu.ec", "gov.ec", "mil.ec", "med.ec", "fin.ec", "pro.ec", "info.ec",
|
||||
// Europe
|
||||
"co.at", "or.at", "ac.at", "gv.at", "priv.at",
|
||||
"co.be", "ac.be",
|
||||
"co.dk", "ac.dk",
|
||||
"co.il", "net.il", "org.il", "ac.il", "gov.il", "idf.il", "k12.il", "muni.il",
|
||||
"co.no", "ac.no", "priv.no",
|
||||
"co.pl", "net.pl", "org.pl", "edu.pl", "gov.pl", "mil.pl", "nom.pl", "com.pl",
|
||||
"co.th", "net.th", "org.th", "edu.th", "gov.th", "mil.th", "ac.th", "in.th",
|
||||
"co.kr", "net.kr", "org.kr", "edu.kr", "gov.kr", "mil.kr", "ac.kr", "go.kr", "ne.kr", "or.kr", "pe.kr", "re.kr", "seoul.kr", "kyonggi.kr",
|
||||
// Others
|
||||
"co.id", "net.id", "org.id", "edu.id", "gov.id", "mil.id", "web.id", "ac.id", "sch.id",
|
||||
"co.ma", "net.ma", "org.ma", "edu.ma", "gov.ma", "ac.ma", "press.ma",
|
||||
"co.ke", "net.ke", "org.ke", "edu.ke", "gov.ke", "ac.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "sc.ke",
|
||||
"co.ug", "net.ug", "org.ug", "edu.ug", "gov.ug", "ac.ug", "sc.ug", "go.ug", "ne.ug", "or.ug",
|
||||
"co.tz", "net.tz", "org.tz", "edu.tz", "gov.tz", "ac.tz", "go.tz", "hotel.tz", "info.tz", "me.tz", "mil.tz", "mobi.tz", "ne.tz", "or.tz", "sc.tz", "tv.tz"
|
||||
]
|
||||
|
||||
// Check if the last two parts form a known two-level TLD
|
||||
if parts.count >= 3 {
|
||||
let lastTwoParts = parts.suffix(2).joined(separator: ".")
|
||||
if twoLevelTlds.contains(lastTwoParts) {
|
||||
// Take the last three parts for two-level TLDs
|
||||
return parts.suffix(3).joined(separator: ".")
|
||||
}
|
||||
}
|
||||
|
||||
// Default to last two parts for regular TLDs
|
||||
if parts.count >= 2 {
|
||||
return parts.suffix(2).joined(separator: ".")
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
/// Check if two domains match, supporting partial matches.
|
||||
/// - Parameters:
|
||||
/// - domain1: First domain
|
||||
/// - domain2: Second domain
|
||||
/// - Returns: True if domains match (including partial matches)
|
||||
private static func domainsMatch(_ domain1: String, _ domain2: String) -> Bool {
|
||||
let d1 = extractDomain(from: domain1)
|
||||
let d2 = extractDomain(from: domain2)
|
||||
|
||||
// Exact match
|
||||
if d1 == d2 { return true }
|
||||
|
||||
// Check if one domain contains the other (for subdomain matching)
|
||||
if d1.contains(d2) || d2.contains(d1) { return true }
|
||||
|
||||
// Check root domain match
|
||||
let d1Root = extractRootDomain(from: d1)
|
||||
let d2Root = extractRootDomain(from: d2)
|
||||
|
||||
return d1Root == d2Root
|
||||
}
|
||||
|
||||
/// Extract meaningful words from text, removing punctuation and filtering stop words.
|
||||
/// - Parameter text: Text to extract words from
|
||||
/// - Returns: Array of filtered words
|
||||
private static func extractWords(from text: String) -> [String] {
|
||||
guard !text.isEmpty else { return [] }
|
||||
|
||||
let lowercased = text.lowercased()
|
||||
|
||||
// Replace common separators and punctuation with spaces
|
||||
let punctuationPattern = "[|,;:\\-–—/\\\\()\\[\\]{}'\" ~!@#$%^&*+=<>?]"
|
||||
let withoutPunctuation = lowercased.replacingOccurrences(
|
||||
of: punctuationPattern,
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
|
||||
// Split on whitespace and filter
|
||||
return withoutPunctuation
|
||||
.components(separatedBy: .whitespacesAndNewlines)
|
||||
.filter { word in
|
||||
word.count > 3 // Filter out short words
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter credentials based on search text with anti-phishing protection.
|
||||
///
|
||||
/// This method follows a strict priority-based algorithm with early returns:
|
||||
/// 1. PRIORITY 1: App Package Name Exact Match (highest priority)
|
||||
/// 2. PRIORITY 2: URL Domain Matching
|
||||
/// 3. PRIORITY 3: Service Name Fallback (anti-phishing protection)
|
||||
/// 4. PRIORITY 4: Text/Word Matching (lowest priority)
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - credentials: List of credentials to filter
|
||||
/// - searchText: Search term (app package name, URL, or text)
|
||||
/// - Returns: Filtered list of credentials
|
||||
///
|
||||
/// **Security Note**: Priority 3 only searches credentials with no service URL defined.
|
||||
/// This prevents phishing attacks where a malicious site might match credentials
|
||||
/// intended for a legitimate site.
|
||||
public static func filterCredentials(_ credentials: [AutofillCredential], searchText: String) -> [AutofillCredential] {
|
||||
// Early return for empty search
|
||||
if searchText.isEmpty {
|
||||
return credentials
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// PRIORITY 1: App Package Name Exact Match
|
||||
// Check if search text is an app package name (e.g., com.coolblue.app)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
if isAppPackageName(searchText) {
|
||||
// Perform exact string match on service URL field
|
||||
let packageMatches = credentials.filter { credential in
|
||||
guard let serviceUrl = credential.serviceUrl, !serviceUrl.isEmpty else { return false }
|
||||
return searchText == serviceUrl
|
||||
}
|
||||
|
||||
// EARLY RETURN if matches found
|
||||
if !packageMatches.isEmpty {
|
||||
return packageMatches
|
||||
}
|
||||
// If no matches found, continue to next priority
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// PRIORITY 2: URL Domain Matching
|
||||
// Try to extract domain from search text
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
let searchDomain = extractDomain(from: searchText)
|
||||
|
||||
if !searchDomain.isEmpty {
|
||||
// Valid domain extracted - perform domain matching
|
||||
let domainMatches = credentials.filter { credential in
|
||||
guard let serviceUrl = credential.serviceUrl, !serviceUrl.isEmpty else { return false }
|
||||
return domainsMatch(searchText, serviceUrl)
|
||||
}
|
||||
|
||||
// EARLY RETURN if matches found
|
||||
if !domainMatches.isEmpty {
|
||||
return domainMatches
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PRIORITY 3: Service Name Fallback (Anti-Phishing Protection)
|
||||
// No domain matches found - search in service names
|
||||
// CRITICAL: Only search credentials with NO service URL defined
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
let domainParts = searchDomain.components(separatedBy: ".")
|
||||
let domainWithoutExtension = domainParts.first?.lowercased() ?? searchDomain.lowercased()
|
||||
|
||||
let nameMatches = credentials.filter { credential in
|
||||
// SECURITY: Skip credentials that have a URL defined
|
||||
guard credential.serviceUrl?.isEmpty != false else { return false }
|
||||
|
||||
// Search in ServiceName and Notes using substring contains
|
||||
let serviceNameMatch = credential.serviceName?.lowercased().contains(domainWithoutExtension) ?? false
|
||||
let notesMatch = credential.notes?.lowercased().contains(domainWithoutExtension) ?? false
|
||||
return serviceNameMatch || notesMatch
|
||||
}
|
||||
|
||||
// Return matches from Priority 3 (don't continue to Priority 4)
|
||||
return nameMatches
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// PRIORITY 4: Text/Word Matching
|
||||
// Search text is not a URL or package name - perform text-based matching
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
let searchWords = extractWords(from: searchText)
|
||||
|
||||
if searchWords.isEmpty {
|
||||
// If no meaningful words after extraction, fall back to simple substring contains
|
||||
let lowercasedSearch = searchText.lowercased()
|
||||
return credentials.filter { credential in
|
||||
(credential.serviceName?.lowercased().contains(lowercasedSearch) ?? false) ||
|
||||
(credential.username?.lowercased().contains(lowercasedSearch) ?? false) ||
|
||||
(credential.notes?.lowercased().contains(lowercasedSearch) ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
// Match using extracted words - exact word matching only
|
||||
return credentials.filter { credential in
|
||||
let serviceNameWords = credential.serviceName.map { extractWords(from: $0) } ?? []
|
||||
let usernameWords = credential.username.map { extractWords(from: $0) } ?? []
|
||||
let notesWords = credential.notes.map { extractWords(from: $0) } ?? []
|
||||
|
||||
// Check if any search word matches any credential word exactly
|
||||
return searchWords.contains { searchWord in
|
||||
serviceNameWords.contains(searchWord) ||
|
||||
usernameWords.contains(searchWord) ||
|
||||
notesWords.contains(searchWord)
|
||||
}
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import VaultModels
|
||||
|
||||
/// Wrapper for the Rust credential matcher using UniFFI bindings.
|
||||
public class RustCredentialMatcher {
|
||||
|
||||
/// Filter credentials based on search text using the Rust core credential matcher.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - credentials: List of credentials to filter
|
||||
/// - searchText: Search term (app package name, URL, or text)
|
||||
/// - Returns: Filtered list of credentials
|
||||
public static func filterCredentials(_ credentials: [AutofillCredential], searchText: String) -> [AutofillCredential] {
|
||||
// Early return for empty search
|
||||
if searchText.isEmpty {
|
||||
return credentials
|
||||
}
|
||||
|
||||
do {
|
||||
// Convert AutofillCredential to the format expected by Rust
|
||||
let rustCredentials = credentials.map { credential -> [String: Any?] in
|
||||
return [
|
||||
"Id": credential.id.uuidString,
|
||||
"ServiceName": credential.serviceName,
|
||||
"ServiceUrl": credential.serviceUrl,
|
||||
"Username": credential.username
|
||||
]
|
||||
}
|
||||
|
||||
// Prepare input JSON for Rust
|
||||
let input: [String: Any] = [
|
||||
"credentials": rustCredentials,
|
||||
"current_url": searchText,
|
||||
"page_title": "",
|
||||
"matching_mode": "default"
|
||||
]
|
||||
|
||||
let inputData = try JSONSerialization.data(withJSONObject: input, options: [])
|
||||
guard let inputJson = String(data: inputData, encoding: .utf8) else {
|
||||
print("[RustCredentialMatcher] Failed to create input JSON")
|
||||
return credentials
|
||||
}
|
||||
|
||||
// Call Rust via UniFFI
|
||||
let outputJson = try filterCredentialsJson(inputJson: inputJson)
|
||||
|
||||
// Parse output
|
||||
guard let outputData = outputJson.data(using: .utf8),
|
||||
let output = try JSONSerialization.jsonObject(with: outputData) as? [String: Any],
|
||||
let matchedIds = output["matched_ids"] as? [String] else {
|
||||
print("[RustCredentialMatcher] Failed to parse output JSON")
|
||||
return credentials
|
||||
}
|
||||
|
||||
// If no matches found, return empty array
|
||||
if matchedIds.isEmpty {
|
||||
return []
|
||||
}
|
||||
|
||||
// Convert matched IDs back to UUIDs
|
||||
let matchedUUIDs = matchedIds.compactMap { UUID(uuidString: $0) }
|
||||
|
||||
// Filter and sort credentials by matched order
|
||||
let filtered = matchedUUIDs.compactMap { matchedId in
|
||||
credentials.first { $0.id == matchedId }
|
||||
}
|
||||
|
||||
return filtered
|
||||
|
||||
} catch {
|
||||
print("[RustCredentialMatcher] Error filtering credentials: \(error)")
|
||||
// Fallback to returning all credentials on error
|
||||
return credentials
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import XCTest
|
||||
@testable import VaultUI
|
||||
@testable import VaultModels
|
||||
|
||||
final class CredentialMatcherTests: XCTestCase {
|
||||
private var testCredentials: [AutofillCredential] = []
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
// Create test credentials using shared test data structure
|
||||
testCredentials = createSharedTestCredentials()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
testCredentials.removeAll()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// [#1] - Exact URL match
|
||||
func testExactUrlMatch() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "www.coolblue.nl")
|
||||
|
||||
XCTAssertEqual(matches.count, 1)
|
||||
XCTAssertEqual(matches.first?.serviceName, "Coolblue")
|
||||
}
|
||||
|
||||
// [#2] - Base URL with path match
|
||||
func testBaseUrlMatch() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://gmail.com/signin")
|
||||
|
||||
XCTAssertEqual(matches.count, 1)
|
||||
XCTAssertEqual(matches.first?.serviceName, "Gmail")
|
||||
}
|
||||
|
||||
// [#3] - Root domain with subdomain match
|
||||
func testRootDomainMatch() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://mail.google.com")
|
||||
|
||||
XCTAssertEqual(matches.count, 1)
|
||||
XCTAssertEqual(matches.first?.serviceName, "Google")
|
||||
}
|
||||
|
||||
// [#4] - No matches for non-existent domain
|
||||
func testNoMatches() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://nonexistent.com")
|
||||
|
||||
XCTAssertTrue(matches.isEmpty)
|
||||
}
|
||||
|
||||
// [#5] - Partial URL stored matches full URL search
|
||||
func testPartialUrlMatchWithFullUrl() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://www.dumpert.nl")
|
||||
|
||||
XCTAssertEqual(matches.count, 1)
|
||||
XCTAssertEqual(matches.first?.serviceName, "Dumpert")
|
||||
}
|
||||
|
||||
// [#6] - Full URL stored matches partial URL search
|
||||
func testFullUrlMatchWithPartialUrl() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "coolblue.nl")
|
||||
|
||||
XCTAssertEqual(matches.count, 1)
|
||||
XCTAssertTrue(matches.contains { $0.serviceName == "Coolblue" })
|
||||
}
|
||||
|
||||
// [#7] - Protocol variations (http/https/none) match
|
||||
func testProtocolVariations() {
|
||||
// Test that http and https variations match
|
||||
let httpsMatches = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://github.com")
|
||||
let httpMatches = CredentialMatcher.filterCredentials(testCredentials, searchText: "http://github.com")
|
||||
let noProtocolMatches = CredentialMatcher.filterCredentials(testCredentials, searchText: "github.com")
|
||||
|
||||
XCTAssertEqual(httpsMatches.count, 1)
|
||||
XCTAssertEqual(httpMatches.count, 1)
|
||||
XCTAssertEqual(noProtocolMatches.count, 1)
|
||||
XCTAssertEqual(httpsMatches.first?.serviceName, "GitHub")
|
||||
XCTAssertEqual(httpMatches.first?.serviceName, "GitHub")
|
||||
XCTAssertEqual(noProtocolMatches.first?.serviceName, "GitHub")
|
||||
}
|
||||
|
||||
// [#8] - WWW prefix variations match
|
||||
func testWwwVariations() {
|
||||
// Test that www variations match
|
||||
let withWww = CredentialMatcher.filterCredentials(testCredentials, searchText: "www.dumpert.nl")
|
||||
let withoutWww = CredentialMatcher.filterCredentials(testCredentials, searchText: "dumpert.nl")
|
||||
|
||||
XCTAssertEqual(withWww.count, 1)
|
||||
XCTAssertEqual(withoutWww.count, 1)
|
||||
XCTAssertEqual(withWww.first?.serviceName, "Dumpert")
|
||||
XCTAssertEqual(withoutWww.first?.serviceName, "Dumpert")
|
||||
}
|
||||
|
||||
// [#9] - Subdomain matching
|
||||
func testSubdomainMatching() {
|
||||
// Test subdomain matching
|
||||
let appSubdomain = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://app.example.com")
|
||||
let wwwSubdomain = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://www.example.com")
|
||||
let noSubdomain = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://example.com")
|
||||
|
||||
XCTAssertEqual(appSubdomain.count, 1)
|
||||
XCTAssertEqual(appSubdomain.first?.serviceName, "Subdomain Example")
|
||||
XCTAssertEqual(wwwSubdomain.count, 1)
|
||||
XCTAssertEqual(wwwSubdomain.first?.serviceName, "Subdomain Example")
|
||||
XCTAssertEqual(noSubdomain.count, 1)
|
||||
XCTAssertEqual(noSubdomain.first?.serviceName, "Subdomain Example")
|
||||
}
|
||||
|
||||
// [#10] - Paths and query strings ignored
|
||||
func testPathAndQueryIgnored() {
|
||||
// Test that paths and query strings are ignored
|
||||
let withPath = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://github.com/user/repo")
|
||||
let withQuery = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://stackoverflow.com/questions?tab=newest")
|
||||
let withFragment = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://gmail.com#inbox")
|
||||
|
||||
XCTAssertEqual(withPath.count, 1)
|
||||
XCTAssertEqual(withPath.first?.serviceName, "GitHub")
|
||||
XCTAssertEqual(withQuery.count, 1)
|
||||
XCTAssertEqual(withQuery.first?.serviceName, "Stack Overflow")
|
||||
XCTAssertEqual(withFragment.count, 1)
|
||||
XCTAssertEqual(withFragment.first?.serviceName, "Gmail")
|
||||
}
|
||||
|
||||
// [#11] - Complex URL variations
|
||||
func testComplexUrlVariations() {
|
||||
// Test complex URL matching scenario
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://www.coolblue.nl/product/12345?ref=google")
|
||||
|
||||
XCTAssertEqual(matches.count, 1)
|
||||
XCTAssertTrue(matches.contains { $0.serviceName == "Coolblue" })
|
||||
}
|
||||
|
||||
// [#12] - Priority ordering
|
||||
func testPriorityOrdering() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "coolblue.nl")
|
||||
|
||||
XCTAssertEqual(matches.count, 1)
|
||||
XCTAssertEqual(matches.first?.serviceName, "Coolblue")
|
||||
}
|
||||
|
||||
// [#13] - Title-only matching
|
||||
func testTitleOnlyMatching() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "newyorktimes")
|
||||
|
||||
XCTAssertEqual(matches.count, 1)
|
||||
XCTAssertEqual(matches.first?.serviceName, "Title Only newyorktimes")
|
||||
}
|
||||
|
||||
// [#14] - Domain name part matching
|
||||
func testDomainNamePartMatch() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://coolblue.be")
|
||||
|
||||
XCTAssertEqual(matches.count, 0)
|
||||
}
|
||||
|
||||
// [#15] - Package name matching
|
||||
func testPackageNameMatch() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "com.coolblue.app")
|
||||
|
||||
XCTAssertEqual(matches.count, 1)
|
||||
XCTAssertEqual(matches.first?.serviceName, "Coolblue App")
|
||||
}
|
||||
|
||||
// [#16] - Invalid URL handling
|
||||
func testInvalidUrl() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "not a url")
|
||||
|
||||
XCTAssertTrue(matches.isEmpty)
|
||||
}
|
||||
|
||||
// [#17] - Anti-phishing protection
|
||||
func testAntiPhishingProtection() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "https://secure-bankk.com")
|
||||
XCTAssertTrue(matches.isEmpty)
|
||||
}
|
||||
|
||||
// [#18] - Ensure only full words are matched
|
||||
func testOnlyFullWordsMatch() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "Express Yourself App | Description")
|
||||
XCTAssertTrue(matches.isEmpty)
|
||||
}
|
||||
|
||||
// [#19] - Ensure separators and punctuation are stripped for matching
|
||||
func testSeparatorsAndPunctuationStripped() {
|
||||
let matches = CredentialMatcher.filterCredentials(testCredentials, searchText: "Reddit, social media platform")
|
||||
|
||||
// Should match "Coolblue" even though it's followed by a comma and description
|
||||
XCTAssertEqual(matches.count, 1)
|
||||
XCTAssertEqual(matches.first?.serviceName, "Reddit")
|
||||
}
|
||||
|
||||
// [#20] - Test multi-part TLDs like .com.au don't match incorrectly
|
||||
func testMultiPartTldNoFalseMatches() {
|
||||
// Create test data with different .com.au domains
|
||||
let australianCredentials = [
|
||||
createTestCredential(serviceName: "Example Site AU", serviceUrl: "https://example.com.au", username: "user@example.com.au"),
|
||||
createTestCredential(serviceName: "BlaBla AU", serviceUrl: "https://blabla.blabla.com.au", username: "user@blabla.com.au"),
|
||||
createTestCredential(serviceName: "Another AU", serviceUrl: "https://another.com.au", username: "user@another.com.au"),
|
||||
createTestCredential(serviceName: "UK Site", serviceUrl: "https://example.co.uk", username: "user@example.co.uk"),
|
||||
]
|
||||
|
||||
// Test that blabla.blabla.com.au doesn't match other .com.au sites
|
||||
let blablaMatches = CredentialMatcher.filterCredentials(australianCredentials, searchText: "https://blabla.blabla.com.au")
|
||||
XCTAssertEqual(blablaMatches.count, 1, "Should only match the exact domain, not all .com.au sites")
|
||||
XCTAssertEqual(blablaMatches.first?.serviceName, "BlaBla AU")
|
||||
|
||||
// Test that example.com.au doesn't match blabla.blabla.com.au
|
||||
let exampleMatches = CredentialMatcher.filterCredentials(australianCredentials, searchText: "https://example.com.au")
|
||||
XCTAssertEqual(exampleMatches.count, 1, "Should only match example.com.au")
|
||||
XCTAssertEqual(exampleMatches.first?.serviceName, "Example Site AU")
|
||||
|
||||
// Test that .co.uk domains work correctly too
|
||||
let ukMatches = CredentialMatcher.filterCredentials(australianCredentials, searchText: "https://example.co.uk")
|
||||
XCTAssertEqual(ukMatches.count, 1, "Should only match the .co.uk domain")
|
||||
XCTAssertEqual(ukMatches.first?.serviceName, "UK Site")
|
||||
}
|
||||
|
||||
// [#20] - Test reversed domain (app package name) doesn't match on TLD
|
||||
func testReversedDomainTldCheck() {
|
||||
// Test that dumpert.nl credential doesn't match nl.marktplaats.android package
|
||||
// They both contain "nl" in the name but shouldn't match since "nl" is just a TLD
|
||||
let reversedDomainCredentials = [
|
||||
createTestCredential(serviceName: "Dumpert.nl", serviceUrl: "dumpert.nl", username: "user@dumpert.nl"),
|
||||
createTestCredential(serviceName: "Marktplaats.nl", serviceUrl: "nl.marktplaats.android", username: "user@marktplaats.nl"),
|
||||
]
|
||||
|
||||
let matches = CredentialMatcher.filterCredentials(reversedDomainCredentials, searchText: "nl.marktplaats.android")
|
||||
|
||||
// Should only match Marktplaats, not Dumpert (even though both have "nl")
|
||||
XCTAssertEqual(matches.count, 1, "Should only match Marktplaats, not Dumpert")
|
||||
XCTAssertEqual(matches.first?.serviceName, "Marktplaats.nl")
|
||||
}
|
||||
|
||||
// [#21] - Test app package names are properly detected and handled
|
||||
func testAppPackageNameDetection() {
|
||||
let packageCredentials = [
|
||||
createTestCredential(serviceName: "Google App", serviceUrl: "com.google.android.googlequicksearchbox", username: "user@google.com"),
|
||||
createTestCredential(serviceName: "Facebook", serviceUrl: "com.facebook.katana", username: "user@facebook.com"),
|
||||
createTestCredential(serviceName: "WhatsApp", serviceUrl: "com.whatsapp", username: "user@whatsapp.com"),
|
||||
createTestCredential(serviceName: "Generic Site", serviceUrl: "example.com", username: "user@example.com"),
|
||||
]
|
||||
|
||||
// Test com.google.android package matches
|
||||
let googleMatches = CredentialMatcher.filterCredentials(packageCredentials, searchText: "com.google.android.googlequicksearchbox")
|
||||
XCTAssertEqual(googleMatches.count, 1)
|
||||
XCTAssertEqual(googleMatches.first?.serviceName, "Google App")
|
||||
|
||||
// Test com.facebook package matches
|
||||
let facebookMatches = CredentialMatcher.filterCredentials(packageCredentials, searchText: "com.facebook.katana")
|
||||
XCTAssertEqual(facebookMatches.count, 1)
|
||||
XCTAssertEqual(facebookMatches.first?.serviceName, "Facebook")
|
||||
|
||||
// Test that web domain doesn't match package name
|
||||
let webMatches = CredentialMatcher.filterCredentials(packageCredentials, searchText: "https://example.com")
|
||||
XCTAssertEqual(webMatches.count, 1)
|
||||
XCTAssertEqual(webMatches.first?.serviceName, "Generic Site")
|
||||
}
|
||||
|
||||
// MARK: - Shared Test Data
|
||||
|
||||
/**
|
||||
* Creates the shared test credential dataset used across all platforms.
|
||||
* This ensures consistent testing across Browser Extension, iOS, and Android.
|
||||
*/
|
||||
private func createSharedTestCredentials() -> [AutofillCredential] {
|
||||
return [
|
||||
createTestCredential(serviceName: "Gmail", serviceUrl: "https://gmail.com", username: "user@gmail.com"),
|
||||
createTestCredential(serviceName: "Google", serviceUrl: "https://google.com", username: "user@google.com"),
|
||||
createTestCredential(serviceName: "Coolblue", serviceUrl: "https://www.coolblue.nl", username: "user@coolblue.nl"),
|
||||
createTestCredential(serviceName: "Amazon", serviceUrl: "https://amazon.com", username: "user@amazon.com"),
|
||||
createTestCredential(serviceName: "Coolblue App", serviceUrl: "com.coolblue.app", username: "user@coolblue.nl"),
|
||||
createTestCredential(serviceName: "Dumpert", serviceUrl: "dumpert.nl", username: "user@dumpert.nl"),
|
||||
createTestCredential(serviceName: "GitHub", serviceUrl: "github.com", username: "user@github.com"),
|
||||
createTestCredential(serviceName: "Stack Overflow", serviceUrl: "https://stackoverflow.com", username: "user@stackoverflow.com"),
|
||||
createTestCredential(serviceName: "Subdomain Example", serviceUrl: "https://app.example.com", username: "user@example.com"),
|
||||
createTestCredential(serviceName: "Title Only newyorktimes", serviceUrl: "", username: ""),
|
||||
createTestCredential(serviceName: "Bank Account", serviceUrl: "https://secure-bank.com", username: "user@bank.com"),
|
||||
createTestCredential(serviceName: "AliExpress", serviceUrl: "https://aliexpress.com", username: "user@aliexpress.com"),
|
||||
createTestCredential(serviceName: "Reddit", serviceUrl: "", username: "user@reddit.com"),
|
||||
createTestCredential(serviceName: "Marktplaats", serviceUrl: "nl.marktplaats.android", username: "user@marktplaats.nl"),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create test credentials with standardized structure.
|
||||
* @param serviceName The name of the service
|
||||
* @param serviceUrl The URL of the service
|
||||
* @param username The username for the service
|
||||
* @returns A test credential matching the iOS AutofillCredential type
|
||||
*/
|
||||
private func createTestCredential(
|
||||
serviceName: String,
|
||||
serviceUrl: String,
|
||||
username: String
|
||||
) -> AutofillCredential {
|
||||
return AutofillCredential(
|
||||
id: UUID(),
|
||||
serviceName: serviceName,
|
||||
serviceUrl: serviceUrl,
|
||||
logo: nil,
|
||||
username: username,
|
||||
email: nil,
|
||||
password: "password123",
|
||||
notes: nil,
|
||||
passkeys: nil,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user