Update Rust Core credential matcher to support multiple urls (#1473)

This commit is contained in:
Leendert de Borst
2026-01-21 17:10:16 +01:00
parent 4c40a8ff92
commit dd9d33cd69
11 changed files with 310 additions and 227 deletions

View File

@@ -34,14 +34,18 @@ async function ensureInit(): Promise<void> {
}
/**
* 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,

View File

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

View File

@@ -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<String> {
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<String>
get() = getFieldValues(FieldKey.LOGIN_URL)
/**
* Get the username field value (login.username).
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,8 +34,10 @@ pub enum AutofillMatchingMode {
#[serde(rename_all = "PascalCase")]
pub struct Credential {
pub id: String,
pub service_name: Option<String>,
pub service_url: Option<String>,
pub item_name: Option<String>,
/// List of URLs associated with this item (supports multi-value URL fields)
#[serde(default)]
pub item_urls: Vec<String>,
#[serde(default)]
pub username: Option<String>,
}
@@ -103,10 +105,9 @@ pub fn filter_credentials(input: CredentialMatcherInput) -> CredentialMatcherOut
let package_match_ids: Vec<String> = credentials
.iter()
.filter(|cred| {
cred.service_url
.as_ref()
.map(|url| !url.is_empty() && url == &current_url)
.unwrap_or(false)
cred.item_urls
.iter()
.any(|url| !url.is_empty() && url == &current_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<u8> = 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(&current_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(&current_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<String> = 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(&current_url);
@@ -249,13 +266,13 @@ pub fn filter_credentials(input: CredentialMatcherInput) -> CredentialMatcherOut
let text_match_ids: Vec<String> = 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
}

View File

@@ -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::<Vec<_>>());
println!("example.com matches: {:?}", example_matches.iter().map(|c| c.item_name.as_deref()).collect::<Vec<_>>());
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"));
}

View File

@@ -78,7 +78,7 @@ pub fn prune_vault_json(input_json: String) -> Result<String, VaultError> {
/// * `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"