From 9b7e1f22a3d7a7f3ec8ac8ca83af0f778f5502c0 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 5 Jan 2026 14:55:35 +0100 Subject: [PATCH] Replace Swift and Kotlin credential matching logic with Rust Core interface (#1404) --- .../app/autofill/AutofillService.kt | 4 +- .../app/autofill/utils/CredentialMatcher.kt | 356 -------------- .../autofill/utils/RustCredentialMatcher.kt | 81 ++++ .../app/nativevaultmanager/AutofillTest.kt | 443 ------------------ apps/mobile-app/ios/.gitignore | 5 + .../ios/AliasVault.xcodeproj/project.pbxproj | 30 ++ .../mobile-app/ios/VaultUI/RustCore/README.md | 26 + .../Selection/CredentialProviderView.swift | 2 +- .../Selection/Utils/CredentialMatcher.swift | 350 -------------- .../Utils/RustCredentialMatcher.swift | 76 +++ .../VaultUITests/CredentialMatcherTests.swift | 310 ------------ 11 files changed, 221 insertions(+), 1462 deletions(-) delete mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt create mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/RustCredentialMatcher.kt delete mode 100644 apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt create mode 100644 apps/mobile-app/ios/VaultUI/RustCore/README.md delete mode 100644 apps/mobile-app/ios/VaultUI/Selection/Utils/CredentialMatcher.swift create mode 100644 apps/mobile-app/ios/VaultUI/Selection/Utils/RustCredentialMatcher.swift delete mode 100644 apps/mobile-app/ios/VaultUITests/CredentialMatcherTests.swift diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt index 8d889d9d3..e153bd03a 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt @@ -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 } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt deleted file mode 100644 index e2f3b9024..000000000 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/CredentialMatcher.kt +++ /dev/null @@ -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 { - 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, - searchText: String, - ): List { - // 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) - } - } - } -} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/RustCredentialMatcher.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/RustCredentialMatcher.kt new file mode 100644 index 000000000..db225f92e --- /dev/null +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/RustCredentialMatcher.kt @@ -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, + searchText: String, + ): List { + // 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() + + 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() + 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 + } + } +} diff --git a/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt b/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt deleted file mode 100644 index 6e363b982..000000000 --- a/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/AutofillTest.kt +++ /dev/null @@ -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 - - @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 { - 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, - ) - } -} diff --git a/apps/mobile-app/ios/.gitignore b/apps/mobile-app/ios/.gitignore index 69b9fc167..c00d61a11 100644 --- a/apps/mobile-app/ios/.gitignore +++ b/apps/mobile-app/ios/.gitignore @@ -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 diff --git a/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj b/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj index 0b31e2857..24623092a 100644 --- a/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj +++ b/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj @@ -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; diff --git a/apps/mobile-app/ios/VaultUI/RustCore/README.md b/apps/mobile-app/ios/VaultUI/RustCore/README.md new file mode 100644 index 000000000..2217984ca --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/RustCore/README.md @@ -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. diff --git a/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift b/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift index 9c66eaf57..b551adb7a 100644 --- a/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift +++ b/apps/mobile-app/ios/VaultUI/Selection/CredentialProviderView.swift @@ -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) { diff --git a/apps/mobile-app/ios/VaultUI/Selection/Utils/CredentialMatcher.swift b/apps/mobile-app/ios/VaultUI/Selection/Utils/CredentialMatcher.swift deleted file mode 100644 index 30f94110f..000000000 --- a/apps/mobile-app/ios/VaultUI/Selection/Utils/CredentialMatcher.swift +++ /dev/null @@ -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 = [ - // 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[.. "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 = [ - // 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 -} diff --git a/apps/mobile-app/ios/VaultUI/Selection/Utils/RustCredentialMatcher.swift b/apps/mobile-app/ios/VaultUI/Selection/Utils/RustCredentialMatcher.swift new file mode 100644 index 000000000..1b452e20c --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Selection/Utils/RustCredentialMatcher.swift @@ -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 + } + } +} diff --git a/apps/mobile-app/ios/VaultUITests/CredentialMatcherTests.swift b/apps/mobile-app/ios/VaultUITests/CredentialMatcherTests.swift deleted file mode 100644 index 4fba17629..000000000 --- a/apps/mobile-app/ios/VaultUITests/CredentialMatcherTests.swift +++ /dev/null @@ -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() - ) - } -}