From 33b670510c7de33140604ef053c1b293cb0ae7c6 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Fri, 22 May 2026 14:50:36 +0200 Subject: [PATCH] feat: add CredentialMatcher for AutoFill URL matching --- .../AutoFill/CredentialMatcher.swift | 42 +++++++++++++++++ .../CredentialMatcherTests.swift | 45 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 MyPassCore/Sources/MyPassCore/AutoFill/CredentialMatcher.swift create mode 100644 MyPassCore/Tests/MyPassCoreTests/CredentialMatcherTests.swift diff --git a/MyPassCore/Sources/MyPassCore/AutoFill/CredentialMatcher.swift b/MyPassCore/Sources/MyPassCore/AutoFill/CredentialMatcher.swift new file mode 100644 index 0000000..02acbf6 --- /dev/null +++ b/MyPassCore/Sources/MyPassCore/AutoFill/CredentialMatcher.swift @@ -0,0 +1,42 @@ +import Foundation + +public enum CredentialMatcher { + public static func matches(entry: Entry, serviceIdentifier: String) -> Bool { + guard !entry.url.isEmpty else { return false } + guard let entryHost = host(from: entry.url), + let serviceHost = host(from: serviceIdentifier) + else { + return entry.url.lowercased().contains(serviceIdentifier.lowercased()) + } + return hostsMatch(entryHost, serviceHost) + } + + public static func filter( + entries: [Entry], + for serviceIdentifiers: [String] + ) -> (suggested: [Entry], all: [Entry]) { + guard !serviceIdentifiers.isEmpty else { return ([], entries) } + let suggested = entries.filter { entry in + serviceIdentifiers.contains { matches(entry: entry, serviceIdentifier: $0) } + } + let suggestedIDs = Set(suggested.map(\.id)) + let rest = entries.filter { !suggestedIDs.contains($0.id) } + return (suggested: suggested, all: rest) + } + + private static func host(from urlString: String) -> String? { + URL(string: urlString)?.host + } + + private static func hostsMatch(_ a: String, _ b: String) -> Bool { + let na = stripped(a) + let nb = stripped(b) + return na == nb + || na.hasSuffix("." + nb) + || nb.hasSuffix("." + na) + } + + private static func stripped(_ host: String) -> String { + host.hasPrefix("www.") ? String(host.dropFirst(4)).lowercased() : host.lowercased() + } +} diff --git a/MyPassCore/Tests/MyPassCoreTests/CredentialMatcherTests.swift b/MyPassCore/Tests/MyPassCoreTests/CredentialMatcherTests.swift new file mode 100644 index 0000000..49d0fbd --- /dev/null +++ b/MyPassCore/Tests/MyPassCoreTests/CredentialMatcherTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import MyPassCore + +final class CredentialMatcherTests: XCTestCase { + func makeEntry(url: String) -> Entry { + Entry(title: "Test", url: url) + } + + func test_exactURLMatch() { + let e = makeEntry(url: "https://github.com/login") + XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com")) + } + + func test_wwwStripped() { + let e = makeEntry(url: "https://www.github.com") + XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com")) + } + + func test_subdomainMatch() { + let e = makeEntry(url: "https://api.github.com") + XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com")) + } + + func test_differentDomain_noMatch() { + let e = makeEntry(url: "https://gitlab.com") + XCTAssertFalse(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com")) + } + + func test_emptyURL_noMatch() { + let e = makeEntry(url: "") + XCTAssertFalse(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com")) + } + + func test_filter_suggestedAndRest() { + let entries = [ + makeEntry(url: "https://github.com"), + makeEntry(url: "https://gitlab.com"), + makeEntry(url: ""), + ] + let result = CredentialMatcher.filter(entries: entries, for: ["https://github.com"]) + XCTAssertEqual(result.suggested.count, 1) + XCTAssertEqual(result.suggested[0].url, "https://github.com") + XCTAssertEqual(result.all.count, 2) + } +}