From dd9d33cd69471ad2efc4cea4e715c18c0e94e018 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 21 Jan 2026 17:10:16 +0100 Subject: [PATCH] Update Rust Core credential matcher to support multiple urls (#1473) --- .../src/utils/itemMatcher/ItemMatcher.ts | 16 +- .../app/autofill/utils/RustItemMatcher.kt | 6 +- .../aliasvault/app/vaultstore/models/Item.kt | 13 ++ .../ios/VaultModels/AutofillCredential.swift | 5 + apps/mobile-app/ios/VaultModels/Item.swift | 10 + .../CredentialIdentityStore.swift | 35 ++-- .../Database/Mappers/FieldMapper.swift | 175 +++++------------ .../Utils/RustCredentialMatcher.swift | 4 +- core/rust/src/credential_matcher/mod.rs | 91 +++++---- core/rust/src/credential_matcher/tests.rs | 180 ++++++++++++++---- core/rust/src/uniffi_api.rs | 2 +- 11 files changed, 310 insertions(+), 227 deletions(-) diff --git a/apps/browser-extension/src/utils/itemMatcher/ItemMatcher.ts b/apps/browser-extension/src/utils/itemMatcher/ItemMatcher.ts index d6425416e..1f00ba01e 100644 --- a/apps/browser-extension/src/utils/itemMatcher/ItemMatcher.ts +++ b/apps/browser-extension/src/utils/itemMatcher/ItemMatcher.ts @@ -34,14 +34,18 @@ async function ensureInit(): Promise { } /** - * Helper to get field value from an item's fields array. + * Helper to get all field values from an item's fields array. + * Returns an array of strings for multi-value fields. */ -function getFieldValue(item: Item, fieldKey: string): string | undefined { +function getFieldValues(item: Item, fieldKey: string): string[] { const field = item.Fields?.find(f => f.FieldKey === fieldKey); if (!field) { - return undefined; + return []; } - return Array.isArray(field.Value) ? field.Value[0] : field.Value; + if (Array.isArray(field.Value)) { + return field.Value.filter(v => v && v.length > 0); + } + return field.Value ? [field.Value] : []; } /** @@ -60,8 +64,8 @@ export async function filterItems( const result = wasmFilterItems({ credentials: items.map(item => ({ Id: item.Id, - ServiceName: item.Name ?? '', - ServiceUrl: getFieldValue(item, FieldKey.LoginUrl) + ItemName: item.Name ?? '', + ItemUrls: getFieldValues(item, FieldKey.LoginUrl) })), current_url: currentUrl, page_title: pageTitle, diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/RustItemMatcher.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/RustItemMatcher.kt index 66e77f40f..0b12c8884 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/RustItemMatcher.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/utils/RustItemMatcher.kt @@ -40,10 +40,12 @@ object RustItemMatcher { for (item in items) { val idString = item.id.toString() + val urlsArray = JSONArray() + item.urls.forEach { urlsArray.put(it) } val credJson = JSONObject().apply { put("Id", idString) - put("ServiceName", item.name ?: JSONObject.NULL) - put("ServiceUrl", item.url ?: JSONObject.NULL) + put("ItemName", item.name ?: JSONObject.NULL) + put("ItemUrls", urlsArray) put("Username", item.username ?: JSONObject.NULL) } rustCredentials.put(credJson) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Item.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Item.kt index 42c7b1574..86abf7a44 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Item.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/Item.kt @@ -40,12 +40,25 @@ data class Item( return fields.find { it.fieldKey == fieldKey }?.value } + /** + * Get all values for a field by its key (for multi-value fields like URLs). + */ + fun getFieldValues(fieldKey: String): List { + return fields.filter { it.fieldKey == fieldKey }.map { it.value } + } + /** * Get the URL field value (login.url). */ val url: String? get() = getFieldValue(FieldKey.LOGIN_URL) + /** + * Get all URL field values (login.url) for multi-URL support. + */ + val urls: List + get() = getFieldValues(FieldKey.LOGIN_URL) + /** * Get the username field value (login.username). */ diff --git a/apps/mobile-app/ios/VaultModels/AutofillCredential.swift b/apps/mobile-app/ios/VaultModels/AutofillCredential.swift index ef78257a2..a75032874 100644 --- a/apps/mobile-app/ios/VaultModels/AutofillCredential.swift +++ b/apps/mobile-app/ios/VaultModels/AutofillCredential.swift @@ -7,6 +7,8 @@ public struct AutofillCredential: Codable, Hashable, Equatable { public let id: UUID public let serviceName: String? public let serviceUrl: String? + /// All URLs associated with this credential (for multi-URL support) + public let serviceUrls: [String] public let logo: Data? public let username: String? public let email: String? @@ -20,6 +22,7 @@ public struct AutofillCredential: Codable, Hashable, Equatable { id: UUID, serviceName: String?, serviceUrl: String?, + serviceUrls: [String] = [], logo: Data?, username: String?, email: String?, @@ -32,6 +35,7 @@ public struct AutofillCredential: Codable, Hashable, Equatable { self.id = id self.serviceName = serviceName self.serviceUrl = serviceUrl + self.serviceUrls = serviceUrls.isEmpty ? (serviceUrl.map { [$0] } ?? []) : serviceUrls self.logo = logo self.username = username self.email = email @@ -50,6 +54,7 @@ public struct AutofillCredential: Codable, Hashable, Equatable { self.id = item.id self.serviceName = item.name self.serviceUrl = item.url + self.serviceUrls = item.urls self.logo = item.logo self.username = item.username self.email = item.email diff --git a/apps/mobile-app/ios/VaultModels/Item.swift b/apps/mobile-app/ios/VaultModels/Item.swift index 22b8598e1..1c29a631a 100644 --- a/apps/mobile-app/ios/VaultModels/Item.swift +++ b/apps/mobile-app/ios/VaultModels/Item.swift @@ -58,11 +58,21 @@ public struct Item: Codable, Hashable, Equatable { return fields.first { $0.fieldKey == fieldKey }?.value } + /// Get all values for a field by its key (for multi-value fields like URLs). + public func getFieldValues(_ fieldKey: String) -> [String] { + return fields.filter { $0.fieldKey == fieldKey }.map { $0.value } + } + /// Get the URL field value (login.url). public var url: String? { return getFieldValue(FieldKey.loginUrl) } + /// Get all URL field values (login.url) for multi-URL support. + public var urls: [String] { + return getFieldValues(FieldKey.loginUrl) + } + /// Get the username field value (login.username). public var username: String? { return getFieldValue(FieldKey.loginUsername) diff --git a/apps/mobile-app/ios/VaultStoreKit/CredentialIdentityStore.swift b/apps/mobile-app/ios/VaultStoreKit/CredentialIdentityStore.swift index 35d34c977..ebf731882 100644 --- a/apps/mobile-app/ios/VaultStoreKit/CredentialIdentityStore.swift +++ b/apps/mobile-app/ios/VaultStoreKit/CredentialIdentityStore.swift @@ -77,36 +77,39 @@ public class CredentialIdentityStore { } /// Create password credential identities from credentials + /// Creates one identity per URL for multi-URL support (iOS matches by domain itself) private func createPasswordIdentities(from credentials: [AutofillCredential]) -> [ASPasswordCredentialIdentity] { - return credentials.compactMap { credential in + return credentials.flatMap { credential -> [ASPasswordCredentialIdentity] in guard !credential.hasPasskey else { // Skip if this record has a passkey as they will be saved in createPasskeyIdentities - return nil + return [] } guard credential.hasPassword else { // Skip credentials with no password - return nil - } - - guard let urlString = credential.serviceUrl, - let url = URL(string: urlString), - let host = url.host else { - return nil + return [] } let identifier = credential.identifier guard !identifier.isEmpty else { - return nil // Skip credentials with no identifier + return [] // Skip credentials with no identifier } - let effectiveDomain = Self.effectiveDomain(from: host) + // Create one identity per URL for multi-URL support + return credential.serviceUrls.compactMap { urlString -> ASPasswordCredentialIdentity? in + guard let url = URL(string: urlString), + let host = url.host else { + return nil + } - return ASPasswordCredentialIdentity( - serviceIdentifier: ASCredentialServiceIdentifier(identifier: effectiveDomain, type: .domain), - user: identifier, - recordIdentifier: credential.id.uuidString - ) + let effectiveDomain = Self.effectiveDomain(from: host) + + return ASPasswordCredentialIdentity( + serviceIdentifier: ASCredentialServiceIdentifier(identifier: effectiveDomain, type: .domain), + user: identifier, + recordIdentifier: credential.id.uuidString + ) + } } } diff --git a/apps/mobile-app/ios/VaultStoreKit/Database/Mappers/FieldMapper.swift b/apps/mobile-app/ios/VaultStoreKit/Database/Mappers/FieldMapper.swift index a4620cc4b..7bd6a8022 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Database/Mappers/FieldMapper.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Database/Mappers/FieldMapper.swift @@ -92,72 +92,34 @@ public struct ProcessedField { /// Handles both system fields (with FieldKey) and custom fields (with FieldDefinitionId). public struct FieldMapper { /// Process raw field rows from database into a map of ItemId -> [ItemField]. - /// Handles system vs custom fields and multi-value field grouping. + /// Handles system vs custom fields. Multi-value fields (like URLs) create separate ItemField entries + /// with the same fieldKey but different values, allowing Item.getFieldValues() to return all values. /// - Parameter rows: Raw field rows from database /// - Returns: Dictionary of ItemId to array of ItemField objects public static func processFieldRows(_ rows: [FieldRow]) -> [String: [ItemField]] { // First, convert rows to processed fields with proper metadata let processedFields = rows.map { processFieldRow($0) } - // Group by ItemId and FieldKey (to handle multi-value fields) + // Group fields by ItemId, keeping separate entries for multi-value fields var fieldsByItem: [String: [ItemField]] = [:] - var fieldValuesByKey: [String: [String]] = [:] for field in processedFields { - let key = "\(field.itemId)_\(field.fieldKey)" - - // Accumulate values for the same field - if fieldValuesByKey[key] == nil { - fieldValuesByKey[key] = [] - } - fieldValuesByKey[key]!.append(field.value) - - // Create ItemField entry only once per unique FieldKey per item if fieldsByItem[field.itemId] == nil { fieldsByItem[field.itemId] = [] } - let itemFields = fieldsByItem[field.itemId]! - let existingField = itemFields.first { $0.fieldKey == field.fieldKey } - - if existingField == nil { - let itemField = ItemField( - fieldKey: field.fieldKey, - label: field.label, - fieldType: field.fieldType, - value: "", // Will be set below - isHidden: field.isHidden, - displayOrder: field.displayOrder, - isCustomField: field.isCustomField, - enableHistory: field.enableHistory - ) - fieldsByItem[field.itemId]!.append(itemField) - } - } - - // Set Values (using first value for single value or concatenated for multi-value) - for (itemId, fields) in fieldsByItem { - var updatedFields: [ItemField] = [] - for field in fields { - let key = "\(itemId)_\(field.fieldKey)" - let values = fieldValuesByKey[key] ?? [] - - // Use first value (multi-value fields would need different handling in the model) - let value = values.first ?? "" - - let updatedField = ItemField( - fieldKey: field.fieldKey, - label: field.label, - fieldType: field.fieldType, - value: value, - isHidden: field.isHidden, - displayOrder: field.displayOrder, - isCustomField: field.isCustomField, - enableHistory: field.enableHistory - ) - updatedFields.append(updatedField) - } - fieldsByItem[itemId] = updatedFields + // Create an ItemField for each row (including duplicates for multi-value fields) + let itemField = ItemField( + fieldKey: field.fieldKey, + label: field.label, + fieldType: field.fieldType, + value: field.value, + isHidden: field.isHidden, + displayOrder: field.displayOrder, + isCustomField: field.isCustomField, + enableHistory: field.enableHistory + ) + fieldsByItem[field.itemId]!.append(itemField) } return fieldsByItem @@ -209,72 +171,48 @@ public struct FieldMapper { /// Process field rows for a single item (without ItemId in result). /// Used when fetching a single item by ID. + /// Multi-value fields (like URLs) create separate ItemField entries with the same fieldKey. /// - Parameter rows: Raw field rows for a single item /// - Returns: Array of ItemField objects public static func processFieldRowsForSingleItem(_ rows: [SingleItemFieldRow]) -> [ItemField] { - var fieldValuesByKey: [String: [String]] = [:] - var uniqueFields: [String: UniqueFieldData] = [:] - - for row in rows { + // Create an ItemField for each row (including duplicates for multi-value fields) + return rows.map { row in let fieldKey = row.fieldKey ?? row.fieldDefinitionId ?? "" + let isCustomField = row.fieldKey == nil || row.fieldKey!.isEmpty - // Accumulate values - if fieldValuesByKey[fieldKey] == nil { - fieldValuesByKey[fieldKey] = [] + if !isCustomField, let rowFieldKey = row.fieldKey { + // System field + let metadata = resolveFieldMetadata( + fieldKey: rowFieldKey, + customLabel: nil, + customFieldType: nil, + customIsHidden: false, + customEnableHistory: false, + isCustomField: false + ) + return ItemField( + fieldKey: rowFieldKey, + label: metadata.label, + fieldType: metadata.fieldType, + value: row.value, + isHidden: metadata.isHidden, + displayOrder: row.displayOrder, + isCustomField: false, + enableHistory: metadata.enableHistory + ) + } else { + // Custom field + return ItemField( + fieldKey: fieldKey, + label: row.customLabel ?? "", + fieldType: row.customFieldType ?? FieldType.text, + value: row.value, + isHidden: row.customIsHidden == 1, + displayOrder: row.displayOrder, + isCustomField: true, + enableHistory: row.customEnableHistory == 1 + ) } - fieldValuesByKey[fieldKey]!.append(row.value) - - // Store field metadata (only once per FieldKey) - if uniqueFields[fieldKey] == nil { - if let rowFieldKey = row.fieldKey, !rowFieldKey.isEmpty { - // System field - let metadata = resolveFieldMetadata( - fieldKey: rowFieldKey, - customLabel: nil, - customFieldType: nil, - customIsHidden: false, - customEnableHistory: false, - isCustomField: false - ) - uniqueFields[fieldKey] = UniqueFieldData( - fieldKey: rowFieldKey, - label: metadata.label, - fieldType: metadata.fieldType, - isHidden: metadata.isHidden, - displayOrder: row.displayOrder, - isCustomField: false, - enableHistory: metadata.enableHistory - ) - } else { - // Custom field - uniqueFields[fieldKey] = UniqueFieldData( - fieldKey: fieldKey, - label: row.customLabel ?? "", - fieldType: row.customFieldType ?? FieldType.text, - isHidden: row.customIsHidden == 1, - displayOrder: row.displayOrder, - isCustomField: true, - enableHistory: row.customEnableHistory == 1 - ) - } - } - } - - // Build fields array with proper single/multi values - return uniqueFields.map { (fieldKey, fieldData) in - let values = fieldValuesByKey[fieldKey] ?? [] - let value = values.first ?? "" - - return ItemField( - fieldKey: fieldData.fieldKey, - label: fieldData.label, - fieldType: fieldData.fieldType, - value: value, - isHidden: fieldData.isHidden, - displayOrder: fieldData.displayOrder, - isCustomField: fieldData.isCustomField, - enableHistory: fieldData.enableHistory - ) }.sorted { $0.displayOrder < $1.displayOrder } } @@ -288,17 +226,6 @@ public struct FieldMapper { let enableHistory: Bool } - /// Helper struct to hold unique field data for single item processing. - private struct UniqueFieldData { - let fieldKey: String - let label: String - let fieldType: String - let isHidden: Bool - let displayOrder: Int - let isCustomField: Bool - let enableHistory: Bool - } - /// Resolve field metadata for system fields and custom fields. private static func resolveFieldMetadata( fieldKey: String, diff --git a/apps/mobile-app/ios/VaultUI/Selection/Utils/RustCredentialMatcher.swift b/apps/mobile-app/ios/VaultUI/Selection/Utils/RustCredentialMatcher.swift index 954353e09..6a040d48e 100644 --- a/apps/mobile-app/ios/VaultUI/Selection/Utils/RustCredentialMatcher.swift +++ b/apps/mobile-app/ios/VaultUI/Selection/Utils/RustCredentialMatcher.swift @@ -22,8 +22,8 @@ public class RustCredentialMatcher { let rustCredentials = credentials.map { credential -> [String: Any?] in return [ "Id": credential.id.uuidString, - "ServiceName": credential.serviceName, - "ServiceUrl": credential.serviceUrl, + "ItemName": credential.serviceName, + "ItemUrls": credential.serviceUrls, "Username": credential.username ] } diff --git a/core/rust/src/credential_matcher/mod.rs b/core/rust/src/credential_matcher/mod.rs index 050aac0ec..2e0b90cc4 100644 --- a/core/rust/src/credential_matcher/mod.rs +++ b/core/rust/src/credential_matcher/mod.rs @@ -34,8 +34,10 @@ pub enum AutofillMatchingMode { #[serde(rename_all = "PascalCase")] pub struct Credential { pub id: String, - pub service_name: Option, - pub service_url: Option, + pub item_name: Option, + /// List of URLs associated with this item (supports multi-value URL fields) + #[serde(default)] + pub item_urls: Vec, #[serde(default)] pub username: Option, } @@ -103,10 +105,9 @@ pub fn filter_credentials(input: CredentialMatcherInput) -> CredentialMatcherOut let package_match_ids: Vec = credentials .iter() .filter(|cred| { - cred.service_url - .as_ref() - .map(|url| !url.is_empty() && url == ¤t_url) - .unwrap_or(false) + cred.item_urls + .iter() + .any(|url| !url.is_empty() && url == ¤t_url) }) .map(|cred| cred.id.clone()) .take(3) @@ -139,32 +140,46 @@ pub fn filter_credentials(input: CredentialMatcherInput) -> CredentialMatcherOut AutofillMatchingMode::Default | AutofillMatchingMode::UrlSubdomain ); - // Process credentials with service URLs + // Process credentials with item URLs (check all URLs for each credential) for cred in &credentials { - let service_url = match &cred.service_url { - Some(url) if !url.is_empty() => url, - _ => continue, // Handle these in Priority 3 - }; - - let cred_domain = extract_domain(service_url); - if cred_domain.is_empty() { + // Skip credentials with no URLs - handle these in Priority 3 + if cred.item_urls.is_empty() { continue; } - // Check for exact match (priority 1) - if enable_exact_match && current_domain == cred_domain { - filtered.push(CredentialWithPriority { - credential: cred.clone(), - priority: 1, - }); - continue; + // Track best match priority for this credential across all its URLs + let mut best_priority: Option = None; + + for item_url in &cred.item_urls { + if item_url.is_empty() { + continue; + } + + let cred_domain = extract_domain(item_url); + if cred_domain.is_empty() { + continue; + } + + // Check for exact match (priority 1) + if enable_exact_match && current_domain == cred_domain { + best_priority = Some(1); + break; // Can't do better than exact match + } + + // Check for subdomain/partial match (priority 2) + if enable_subdomain_match + && domains_match(¤t_domain, &cred_domain) + && best_priority.is_none() + { + best_priority = Some(2); + // Don't break - might find exact match in another URL + } } - // Check for subdomain/partial match (priority 2) - if enable_subdomain_match && domains_match(¤t_domain, &cred_domain) { + if let Some(priority) = best_priority { filtered.push(CredentialWithPriority { credential: cred.clone(), - priority: 2, + priority, }); } } @@ -188,9 +203,9 @@ pub fn filter_credentials(input: CredentialMatcherInput) -> CredentialMatcherOut } // ═══════════════════════════════════════════════════════════════════════════ - // PRIORITY 3: Page Title / Service Name Fallback (Anti-Phishing Protection) - // No domain matches found - search in service names using page title - // CRITICAL: Only search credentials with NO service URL defined + // PRIORITY 3: Page Title / Item Name Fallback (Anti-Phishing Protection) + // No domain matches found - search in item names using page title + // CRITICAL: Only search credentials with NO URLs defined // ═══════════════════════════════════════════════════════════════════════════ if !page_title.is_empty() { let title_words = extract_words(&page_title); @@ -199,14 +214,16 @@ pub fn filter_credentials(input: CredentialMatcherInput) -> CredentialMatcherOut let name_match_ids: Vec = credentials .iter() .filter(|cred| { - // SECURITY: Skip credentials that have a URL defined - if cred.service_url.as_ref().map(|u| !u.is_empty()).unwrap_or(false) { + // SECURITY: Skip credentials that have URLs defined + if !cred.item_urls.is_empty() + && cred.item_urls.iter().any(|u| !u.is_empty()) + { return false; } - // Check page title match with service name - if let Some(service_name) = &cred.service_name { - let cred_name_words = extract_words(service_name); + // Check page title match with item name + if let Some(item_name) = &cred.item_name { + let cred_name_words = extract_words(item_name); // Match only complete words, not substrings title_words.iter().any(|title_word| { @@ -241,7 +258,7 @@ pub fn filter_credentials(input: CredentialMatcherInput) -> CredentialMatcherOut // ═══════════════════════════════════════════════════════════════════════════════ // PRIORITY 4: Text Matching // Used when: 1) Package name didn't match in Priority 1, OR 2) URL extraction failed - // Performs word-based matching on service names + // Performs word-based matching on item names // ═══════════════════════════════════════════════════════════════════════════════ let search_words = extract_words(¤t_url); @@ -249,13 +266,13 @@ pub fn filter_credentials(input: CredentialMatcherInput) -> CredentialMatcherOut let text_match_ids: Vec = credentials .iter() .filter(|cred| { - if let Some(service_name) = &cred.service_name { - let service_name_words = extract_words(service_name); + if let Some(item_name) = &cred.item_name { + let item_name_words = extract_words(item_name); - // Check if any search word matches any service name word exactly + // Check if any search word matches any item name word exactly search_words .iter() - .any(|search_word| service_name_words.contains(search_word)) + .any(|search_word| item_name_words.contains(search_word)) } else { false } diff --git a/core/rust/src/credential_matcher/tests.rs b/core/rust/src/credential_matcher/tests.rs index 61ee34709..1acbf3955 100644 --- a/core/rust/src/credential_matcher/tests.rs +++ b/core/rust/src/credential_matcher/tests.rs @@ -3,15 +3,29 @@ use super::*; /// Helper function to create test credentials with standardized structure. -fn create_test_credential(service_name: &str, service_url: &str, username: &str) -> Credential { +fn create_test_credential(item_name: &str, item_url: &str, username: &str) -> Credential { Credential { id: uuid_v4(), - service_name: Some(service_name.to_string()), - service_url: if service_url.is_empty() { + item_name: Some(item_name.to_string()), + item_urls: if item_url.is_empty() { + vec![] + } else { + vec![item_url.to_string()] + }, + username: if username.is_empty() { None } else { - Some(service_url.to_string()) + Some(username.to_string()) }, + } +} + +/// Helper function to create test credentials with multiple URLs. +fn create_test_credential_multi_url(item_name: &str, item_urls: Vec<&str>, username: &str) -> Credential { + Credential { + id: uuid_v4(), + item_name: Some(item_name.to_string()), + item_urls: item_urls.into_iter().map(String::from).collect(), username: if username.is_empty() { None } else { @@ -75,7 +89,7 @@ fn test_exact_url_match() { let matches = filter(credentials, "www.coolblue.nl", ""); assert_eq!(matches.len(), 1); - assert_eq!(matches[0].service_name.as_deref(), Some("Coolblue")); + assert_eq!(matches[0].item_name.as_deref(), Some("Coolblue")); } /// [#2] - Base URL with path match @@ -85,7 +99,7 @@ fn test_base_url_with_path_match() { let matches = filter(credentials, "https://gmail.com/signin", ""); assert_eq!(matches.len(), 1); - assert_eq!(matches[0].service_name.as_deref(), Some("Gmail")); + assert_eq!(matches[0].item_name.as_deref(), Some("Gmail")); } /// [#3] - Root domain with subdomain match @@ -95,7 +109,7 @@ fn test_root_domain_with_subdomain_match() { let matches = filter(credentials, "https://mail.google.com", ""); assert_eq!(matches.len(), 1); - assert_eq!(matches[0].service_name.as_deref(), Some("Google")); + assert_eq!(matches[0].item_name.as_deref(), Some("Google")); } /// [#4] - No matches for non-existent domain @@ -114,7 +128,7 @@ fn test_partial_url_matches_full_url() { let matches = filter(credentials, "https://www.dumpert.nl", ""); assert_eq!(matches.len(), 1); - assert_eq!(matches[0].service_name.as_deref(), Some("Dumpert")); + assert_eq!(matches[0].item_name.as_deref(), Some("Dumpert")); } /// [#6] - Full URL stored matches partial URL search @@ -124,7 +138,7 @@ fn test_full_url_matches_partial_url() { let matches = filter(credentials, "coolblue.nl", ""); assert_eq!(matches.len(), 1); - assert_eq!(matches[0].service_name.as_deref(), Some("Coolblue")); + assert_eq!(matches[0].item_name.as_deref(), Some("Coolblue")); } /// [#7] - Protocol variations (http/https/none) match @@ -139,9 +153,9 @@ fn test_protocol_variations() { assert_eq!(https_matches.len(), 1); assert_eq!(http_matches.len(), 1); assert_eq!(no_protocol_matches.len(), 1); - assert_eq!(https_matches[0].service_name.as_deref(), Some("GitHub")); - assert_eq!(http_matches[0].service_name.as_deref(), Some("GitHub")); - assert_eq!(no_protocol_matches[0].service_name.as_deref(), Some("GitHub")); + assert_eq!(https_matches[0].item_name.as_deref(), Some("GitHub")); + assert_eq!(http_matches[0].item_name.as_deref(), Some("GitHub")); + assert_eq!(no_protocol_matches[0].item_name.as_deref(), Some("GitHub")); } /// [#8] - WWW prefix variations match @@ -154,8 +168,8 @@ fn test_www_variations() { assert_eq!(with_www.len(), 1); assert_eq!(without_www.len(), 1); - assert_eq!(with_www[0].service_name.as_deref(), Some("Dumpert")); - assert_eq!(without_www[0].service_name.as_deref(), Some("Dumpert")); + assert_eq!(with_www[0].item_name.as_deref(), Some("Dumpert")); + assert_eq!(without_www[0].item_name.as_deref(), Some("Dumpert")); } /// [#9] - Subdomain matching @@ -168,11 +182,11 @@ fn test_subdomain_matching() { let no_subdomain = filter(credentials, "https://example.com", ""); assert_eq!(app_subdomain.len(), 1); - assert_eq!(app_subdomain[0].service_name.as_deref(), Some("Subdomain Example")); + assert_eq!(app_subdomain[0].item_name.as_deref(), Some("Subdomain Example")); assert_eq!(www_subdomain.len(), 1); - assert_eq!(www_subdomain[0].service_name.as_deref(), Some("Subdomain Example")); + assert_eq!(www_subdomain[0].item_name.as_deref(), Some("Subdomain Example")); assert_eq!(no_subdomain.len(), 1); - assert_eq!(no_subdomain[0].service_name.as_deref(), Some("Subdomain Example")); + assert_eq!(no_subdomain[0].item_name.as_deref(), Some("Subdomain Example")); } /// [#10] - Paths and query strings ignored @@ -185,11 +199,11 @@ fn test_paths_and_query_strings_ignored() { let with_fragment = filter(credentials, "https://gmail.com#inbox", ""); assert_eq!(with_path.len(), 1); - assert_eq!(with_path[0].service_name.as_deref(), Some("GitHub")); + assert_eq!(with_path[0].item_name.as_deref(), Some("GitHub")); assert_eq!(with_query.len(), 1); - assert_eq!(with_query[0].service_name.as_deref(), Some("Stack Overflow")); + assert_eq!(with_query[0].item_name.as_deref(), Some("Stack Overflow")); assert_eq!(with_fragment.len(), 1); - assert_eq!(with_fragment[0].service_name.as_deref(), Some("Gmail")); + assert_eq!(with_fragment[0].item_name.as_deref(), Some("Gmail")); } /// [#11] - Complex URL variations @@ -199,7 +213,7 @@ fn test_complex_url_variations() { let matches = filter(credentials, "https://www.coolblue.nl/product/12345?ref=google", ""); assert_eq!(matches.len(), 1); - assert_eq!(matches[0].service_name.as_deref(), Some("Coolblue")); + assert_eq!(matches[0].item_name.as_deref(), Some("Coolblue")); } /// [#12] - Priority ordering @@ -209,7 +223,7 @@ fn test_priority_ordering() { let matches = filter(credentials, "coolblue.nl", ""); assert_eq!(matches.len(), 1); - assert_eq!(matches[0].service_name.as_deref(), Some("Coolblue")); + assert_eq!(matches[0].item_name.as_deref(), Some("Coolblue")); } /// [#13] - Title-only matching @@ -219,7 +233,7 @@ fn test_title_only_matching() { let matches = filter(credentials, "https://nomatch.com", "newyorktimes"); assert_eq!(matches.len(), 1); - assert_eq!(matches[0].service_name.as_deref(), Some("Title Only newyorktimes")); + assert_eq!(matches[0].item_name.as_deref(), Some("Title Only newyorktimes")); } /// [#14] - Domain name part matching @@ -239,7 +253,7 @@ fn test_package_name_matching() { let matches = filter(credentials, "com.coolblue.app", ""); assert_eq!(matches.len(), 1); - assert_eq!(matches[0].service_name.as_deref(), Some("Coolblue App")); + assert_eq!(matches[0].item_name.as_deref(), Some("Coolblue App")); } /// [#16] - Invalid URL handling @@ -279,7 +293,7 @@ fn test_separators_and_punctuation_stripped() { // Should match "Reddit" even though it's followed by a comma and description assert_eq!(matches.len(), 1); - assert_eq!(matches[0].service_name.as_deref(), Some("Reddit")); + assert_eq!(matches[0].item_name.as_deref(), Some("Reddit")); } /// [#20] - Test reversed domain (app package name) doesn't match on TLD @@ -294,7 +308,7 @@ fn test_reversed_domain_no_tld_match() { // Should only match Marktplaats, not Dumpert (even though both have "nl") assert_eq!(matches.len(), 1); - assert_eq!(matches[0].service_name.as_deref(), Some("Marktplaats.nl")); + assert_eq!(matches[0].item_name.as_deref(), Some("Marktplaats.nl")); } /// [#21] - Test app package names are properly detected and handled @@ -310,17 +324,17 @@ fn test_app_package_names_handling() { // Test com.google.android package matches let google_matches = filter(credentials.clone(), "com.google.android.googlequicksearchbox", ""); assert_eq!(google_matches.len(), 1); - assert_eq!(google_matches[0].service_name.as_deref(), Some("Google App")); + assert_eq!(google_matches[0].item_name.as_deref(), Some("Google App")); // Test com.facebook package matches let facebook_matches = filter(credentials.clone(), "com.facebook.katana", ""); assert_eq!(facebook_matches.len(), 1); - assert_eq!(facebook_matches[0].service_name.as_deref(), Some("Facebook")); + assert_eq!(facebook_matches[0].item_name.as_deref(), Some("Facebook")); // Test that web domain doesn't match package name let web_matches = filter(credentials, "https://example.com", ""); assert_eq!(web_matches.len(), 1); - assert_eq!(web_matches[0].service_name.as_deref(), Some("Generic Site")); + assert_eq!(web_matches[0].item_name.as_deref(), Some("Generic Site")); } /// [#22] - Test multi-part TLDs like .com.au don't match incorrectly @@ -336,17 +350,17 @@ fn test_multi_part_tlds() { // Test that blabla.blabla.com.au doesn't match other .com.au sites let blabla_matches = filter(credentials.clone(), "https://blabla.blabla.com.au", ""); assert_eq!(blabla_matches.len(), 1); - assert_eq!(blabla_matches[0].service_name.as_deref(), Some("BlaBla AU")); + assert_eq!(blabla_matches[0].item_name.as_deref(), Some("BlaBla AU")); // Test that example.com.au doesn't match blabla.blabla.com.au let example_matches = filter(credentials.clone(), "https://example.com.au", ""); assert_eq!(example_matches.len(), 1); - assert_eq!(example_matches[0].service_name.as_deref(), Some("Example Site AU")); + assert_eq!(example_matches[0].item_name.as_deref(), Some("Example Site AU")); // Test that .co.uk domains work correctly too let uk_matches = filter(credentials, "https://example.co.uk", ""); assert_eq!(uk_matches.len(), 1); - assert_eq!(uk_matches[0].service_name.as_deref(), Some("UK Site")); + assert_eq!(uk_matches[0].item_name.as_deref(), Some("UK Site")); } /// Test JSON serialization/deserialization @@ -367,7 +381,7 @@ fn test_json_roundtrip() { assert_eq!(output.matched_ids.len(), 1); // Look up the credential by ID to verify it's GitHub let matched = credentials.iter().find(|c| c.id == output.matched_ids[0]).unwrap(); - assert_eq!(matched.service_name.as_deref(), Some("GitHub")); + assert_eq!(matched.item_name.as_deref(), Some("GitHub")); } /// Test empty URL returns empty results @@ -412,23 +426,111 @@ fn test_e2e_scenario_url_only_matching() { // Test 2: example.com should only match Example Site (and possibly subdomain due to root domain matching) let example_matches = filter(credentials.clone(), "https://example.com/login", "E2E Test Form"); - println!("example.com matches: {:?}", example_matches.iter().map(|c| c.service_name.as_deref()).collect::>()); + println!("example.com matches: {:?}", example_matches.iter().map(|c| c.item_name.as_deref()).collect::>()); assert!(example_matches.len() >= 1, "example.com should match at least one credential"); - assert!(example_matches.iter().any(|c| c.service_name.as_deref() == Some("Example Site")), + assert!(example_matches.iter().any(|c| c.item_name.as_deref() == Some("Example Site")), "example.com should match Example Site"); - assert!(!example_matches.iter().any(|c| c.service_name.as_deref() == Some("Another Site")), + assert!(!example_matches.iter().any(|c| c.item_name.as_deref() == Some("Another Site")), "example.com should NOT match Another Site"); // Test 3: another-example.com should only match Another Site let another_matches = filter(credentials.clone(), "https://another-example.com/signin", "E2E Test Form"); assert_eq!(another_matches.len(), 1, "another-example.com should match exactly one credential"); - assert_eq!(another_matches[0].service_name.as_deref(), Some("Another Site")); + assert_eq!(another_matches[0].item_name.as_deref(), Some("Another Site")); // Test 4: test.example.com subdomain should match Example Subdomain let subdomain_matches = filter(credentials, "https://test.example.com/auth", "E2E Test Form"); assert!(subdomain_matches.len() >= 1, "test.example.com should match at least one credential"); - assert!(subdomain_matches.iter().any(|c| c.service_name.as_deref() == Some("Example Subdomain")), + assert!(subdomain_matches.iter().any(|c| c.item_name.as_deref() == Some("Example Subdomain")), "test.example.com should match Example Subdomain"); - assert!(!subdomain_matches.iter().any(|c| c.service_name.as_deref() == Some("Another Site")), + assert!(!subdomain_matches.iter().any(|c| c.item_name.as_deref() == Some("Another Site")), "test.example.com should NOT match Another Site"); } + +/// [#24] - Multi-URL support: credentials with multiple URLs should match any of them +#[test] +fn test_multi_url_matching() { + let credentials = vec![ + create_test_credential_multi_url( + "Vodafone", + vec!["https://www.vodafone.com", "https://my.vodafone.de", "https://www.vodafone.nl"], + "user@vodafone.com" + ), + create_test_credential("Other Site", "https://example.com", "user@example.com"), + ]; + + // Test 1: First URL should match + let first_url_matches = filter(credentials.clone(), "https://www.vodafone.com/account", ""); + assert_eq!(first_url_matches.len(), 1); + assert_eq!(first_url_matches[0].item_name.as_deref(), Some("Vodafone")); + + // Test 2: Second URL should match + let second_url_matches = filter(credentials.clone(), "https://my.vodafone.de/login", ""); + assert_eq!(second_url_matches.len(), 1); + assert_eq!(second_url_matches[0].item_name.as_deref(), Some("Vodafone")); + + // Test 3: Third URL should match (this was the original bug!) + let third_url_matches = filter(credentials.clone(), "https://www.vodafone.nl/inloggen", ""); + assert_eq!(third_url_matches.len(), 1, "Third URL should match"); + assert_eq!(third_url_matches[0].item_name.as_deref(), Some("Vodafone")); + + // Test 4: Subdomain of any URL should match + let subdomain_matches = filter(credentials.clone(), "https://portal.vodafone.nl", ""); + assert_eq!(subdomain_matches.len(), 1); + assert_eq!(subdomain_matches[0].item_name.as_deref(), Some("Vodafone")); + + // Test 5: Unrelated domain should not match + let unrelated_matches = filter(credentials, "https://vodafone.be", ""); + assert_eq!(unrelated_matches.len(), 0); +} + +/// [#25] - Multi-URL with exact match priority: exact match on any URL beats subdomain match +#[test] +fn test_multi_url_exact_match_priority() { + let credentials = vec![ + create_test_credential_multi_url( + "Site with subdomain first", + vec!["https://app.example.com", "https://example.org"], + "user@example.com" + ), + ]; + + // Searching example.org should be exact match (priority 1), not subdomain match + let input = CredentialMatcherInput { + credentials: credentials.clone(), + current_url: "https://example.org".to_string(), + page_title: String::new(), + matching_mode: AutofillMatchingMode::Default, + }; + let output = filter_credentials(input); + + assert_eq!(output.matched_ids.len(), 1); + assert_eq!(output.matched_priority, 2); // Priority 2 = URL domain matching +} + +/// [#26] - Multi-URL app package matching +#[test] +fn test_multi_url_app_package_matching() { + let credentials = vec![ + create_test_credential_multi_url( + "Coolblue", + vec!["https://www.coolblue.nl", "com.coolblue.app", "nl.coolblue.ios"], + "user@coolblue.nl" + ), + ]; + + // Web URL should match + let web_matches = filter(credentials.clone(), "https://coolblue.nl", ""); + assert_eq!(web_matches.len(), 1); + assert_eq!(web_matches[0].item_name.as_deref(), Some("Coolblue")); + + // Android package should match + let android_matches = filter(credentials.clone(), "com.coolblue.app", ""); + assert_eq!(android_matches.len(), 1); + assert_eq!(android_matches[0].item_name.as_deref(), Some("Coolblue")); + + // iOS package should match + let ios_matches = filter(credentials, "nl.coolblue.ios", ""); + assert_eq!(ios_matches.len(), 1); + assert_eq!(ios_matches[0].item_name.as_deref(), Some("Coolblue")); +} diff --git a/core/rust/src/uniffi_api.rs b/core/rust/src/uniffi_api.rs index 330867a5f..8c55ca762 100644 --- a/core/rust/src/uniffi_api.rs +++ b/core/rust/src/uniffi_api.rs @@ -78,7 +78,7 @@ pub fn prune_vault_json(input_json: String) -> Result { /// * `input_json` - JSON string with format: /// ```json /// { -/// "credentials": [{"Id": "...", "ServiceName": "...", "ServiceUrl": "..."}], +/// "credentials": [{"Id": "...", "ItemName": "...", "ItemUrls": ["url1", "url2"]}], /// "current_url": "https://github.com", /// "page_title": "GitHub", /// "matching_mode": "default"