mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 21:40:41 -04:00
Update Rust Core credential matcher to support multiple urls (#1473)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 == ¤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<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(¤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<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(¤t_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
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user