Replace Swift and Kotlin credential matching logic with Rust Core interface (#1404)

This commit is contained in:
Leendert de Borst
2026-01-05 14:55:35 +01:00
parent b6e3bb8332
commit 9b7e1f22a3
11 changed files with 221 additions and 1462 deletions

View File

@@ -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
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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

View File

@@ -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;

View 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.

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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()
)
}
}