diff --git a/.gitignore b/.gitignore index dc0ed006e..b1a6c8e7a 100755 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ xcuserdata/ **/xcshareddata/WorkspaceSettings.xcsettings DerivedData/ KeePassium.xcodeproj/xcshareddata/ +build/ # License verification files KeePassiumLib/KeePassiumLib/resources/licensing/v1-proofs.sha256 diff --git a/KeePassium AutoFill/util/SearchHelper+extensions.swift b/KeePassium AutoFill/util/SearchHelper+extensions.swift index 2cb59aded..1b1dc9f94 100644 --- a/KeePassium AutoFill/util/SearchHelper+extensions.swift +++ b/KeePassium AutoFill/util/SearchHelper+extensions.swift @@ -108,18 +108,7 @@ extension SearchHelper { } private func howSimilar(domain: String, with url: URL?) -> Double { - guard let host = url?.host?.localizedLowercase else { return 0.0 } - - if host == domain { - return 1.0 - } - if let simplifiedURLHost = DomainNameHelper.shared.getMainDomain(host: host), - domain == simplifiedURLHost - { - return 0.95 - } - - return 0.0 + URLSimilarity.howSimilar(domain: domain, with: url) } private func getSimilarity(domain: String, entry: Entry, options: String.CompareOptions) -> Double { @@ -152,51 +141,7 @@ extension SearchHelper { parsedHost parsedHost1: ParsedHost?, with url2: URL? ) -> Double { - guard let url2 else { return 0.0 } - - if url1 == url2 { return 1.0 } - - var isSimilarHosts = false - guard let host1 = url1.host?.localizedLowercase, - let host2 = url2.host?.localizedLowercase else { return 0.0 } - - var parsedHost2: ParsedHost? - if host1 == host2 { - isSimilarHosts = true - } else { - parsedHost2 = DomainNameHelper.shared.parse(host: host2) - if let mainDomain1 = parsedHost1?.domain, - let mainDomain2 = parsedHost2?.domain - { - isSimilarHosts = (mainDomain1 == mainDomain2) - } - } - - if isSimilarHosts { - var portMismatchPenalty = 0.0 - if let port1 = url1.port, - let port2 = url2.port, - port1 != port2 - { - portMismatchPenalty = -0.2 - } - guard url2.path.isNotEmpty else { return 0.7 } - let lowercasePath1 = url1.path.localizedLowercase - let lowercasePath2 = url2.path.localizedLowercase - let commonPrefixCount = Double(lowercasePath1.commonPrefix(with: lowercasePath2).count) - let maxPathCount = Double(max(lowercasePath1.count, lowercasePath2.count)) - let pathSimilarity = commonPrefixCount / maxPathCount - - return 0.7 + portMismatchPenalty + 0.3 * pathSimilarity - } else { - if let serviceName1 = parsedHost1?.serviceName, - let serviceName2 = parsedHost2?.serviceName, - serviceName1 == serviceName2 - { - return 0.5 - } - } - return 0.0 + URLSimilarity.howSimilar(url1, parsedHost1: parsedHost1, with: url2) } private func getSimilarity(url: URL, parsedHost: ParsedHost?, entry: Entry) -> Double { diff --git a/KeePassiumLib/KeePassiumLib/util/URLSimilarity.swift b/KeePassiumLib/KeePassiumLib/util/URLSimilarity.swift new file mode 100644 index 000000000..41f34b4e2 --- /dev/null +++ b/KeePassiumLib/KeePassiumLib/util/URLSimilarity.swift @@ -0,0 +1,78 @@ +// KeePassium Password Manager +// Copyright © 2018-2025 KeePassium Labs +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License version 3 as published +// by the Free Software Foundation: https://www.gnu.org/licenses/). +// For commercial licensing, please contact the author. + +import Foundation + +public enum URLSimilarity { + + public static func howSimilar(domain: String, with url: URL?) -> Double { + guard let host = url?.host?.localizedLowercase else { return 0.0 } + + if host == domain { + return 1.0 + } + if let simplifiedURLHost = DomainNameHelper.shared.getMainDomain(host: host), + domain == simplifiedURLHost + { + return 0.95 + } + + return 0.0 + } + + public static func howSimilar(_ url1: URL, parsedHost1: ParsedHost? = nil, with url2: URL?) -> Double { + guard let url2 else { return 0.0 } + + if url1 == url2 { return 1.0 } + + guard let host1 = url1.host?.localizedLowercase, + let host2 = url2.host?.localizedLowercase else { return 0.0 } + + let parsedHost1 = parsedHost1 ?? url1.parsedHost + let parsedHost2 = url2.parsedHost + + var isExactHostMatch = false + var isDomainMatch = false + if host1 == host2 { + isExactHostMatch = true + } else { + if let mainDomain1 = parsedHost1?.domain, + let mainDomain2 = parsedHost2?.domain + { + isDomainMatch = (mainDomain1 == mainDomain2) + } + } + + if isExactHostMatch || isDomainMatch { + let hostMatchBonus = isExactHostMatch ? 0.1 : 0.0 + var portMismatchPenalty = 0.0 + if let port1 = url1.port, + let port2 = url2.port, + port1 != port2 + { + portMismatchPenalty = -0.2 + } + guard url2.path.isNotEmpty else { return 0.7 + hostMatchBonus } + let lowercasePath1 = url1.path.localizedLowercase + let lowercasePath2 = url2.path.localizedLowercase + let commonPrefixCount = Double(lowercasePath1.commonPrefix(with: lowercasePath2).count) + let maxPathCount = Double(max(lowercasePath1.count, lowercasePath2.count)) + let pathSimilarity = commonPrefixCount / maxPathCount + + return min(1.0, 0.7 + hostMatchBonus + portMismatchPenalty + 0.3 * pathSimilarity) + } else { + if let serviceName1 = parsedHost1?.serviceName, + let serviceName2 = parsedHost2?.serviceName, + serviceName1 == serviceName2 + { + return 0.5 + } + } + return 0.0 + } +} diff --git a/KeePassiumTests/URLSimilarityTests.swift b/KeePassiumTests/URLSimilarityTests.swift new file mode 100644 index 000000000..6a4fbe41b --- /dev/null +++ b/KeePassiumTests/URLSimilarityTests.swift @@ -0,0 +1,191 @@ +// KeePassium Password Manager +// Copyright © 2018-2025 KeePassium Labs +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License version 3 as published +// by the Free Software Foundation: https://www.gnu.org/licenses/). +// For commercial licensing, please contact the author. + +@testable import KeePassiumLib +import XCTest + +final class URLSimilarityTests: XCTestCase { + + // MARK: - howSimilar(domain:with:) + + func testDomainExactHostMatch() { + let url = URL(string: "https://mycompany.com")! + XCTAssertEqual(URLSimilarity.howSimilar(domain: "mycompany.com", with: url), 1.0) + } + + func testDomainSubdomainMatch() { + let url = URL(string: "https://mail.mycompany.com")! + XCTAssertEqual(URLSimilarity.howSimilar(domain: "mycompany.com", with: url), 0.95) + } + + func testDomainNoMatch() { + let url = URL(string: "https://example.com")! + XCTAssertEqual(URLSimilarity.howSimilar(domain: "mycompany.com", with: url), 0.0) + } + + func testDomainNilURL() { + XCTAssertEqual(URLSimilarity.howSimilar(domain: "mycompany.com", with: nil), 0.0) + } + + func testDomainURLWithoutHost() { + let url = URL(string: "file:///path/to/file")! + XCTAssertEqual(URLSimilarity.howSimilar(domain: "mycompany.com", with: url), 0.0) + } + + func testDomainCaseInsensitive() { + let url = URL(string: "https://Mycompany.COM")! + XCTAssertEqual(URLSimilarity.howSimilar(domain: "mycompany.com", with: url), 1.0) + } + + // MARK: - howSimilar(_:with:) — Exact host match vs domain-only match + + func testExactHostMatchScoresHigherThanDomainOnlyMatch() { + let searchURL = URL(string: "https://mail.mycompany.com")! + let exactMatchURL = URL(string: "https://mail.mycompany.com")! + let domainOnlyMatchURL = URL(string: "https://xyz.mycompany.com")! + + let exactScore = URLSimilarity.howSimilar(searchURL, with: exactMatchURL) + let domainOnlyScore = URLSimilarity.howSimilar(searchURL, with: domainOnlyMatchURL) + + XCTAssertGreaterThan( + exactScore, + domainOnlyScore, + "Exact host match (\(exactScore)) should score higher than domain-only match (\(domainOnlyScore))" + ) + } + + func testExactHostMatchWithoutPath() { + let url1 = URL(string: "https://mail.mycompany.com")! + let url2 = URL(string: "https://mail.mycompany.com")! + + let score = URLSimilarity.howSimilar(url1, with: url2) + XCTAssertEqual(score, 1.0, "Identical URLs should score 1.0") + } + + func testDomainOnlyMatchWithoutPath() { + let url1 = URL(string: "https://mail.mycompany.com")! + let url2 = URL(string: "https://xyz.mycompany.com")! + + let score = URLSimilarity.howSimilar(url1, with: url2) + XCTAssertEqual(score, 0.7, accuracy: 0.001, "Domain-only match without path should score 0.7") + } + + func testExactHostMatchBaseScore() { + let url1 = URL(string: "https://mail.mycompany.com")! + let url2 = URL(string: "https://mail.mycompany.com/different/path")! + + let score = URLSimilarity.howSimilar(url1, with: url2) + XCTAssertEqual(score, 0.8, accuracy: 0.001, "Exact host match should have base score ~0.8") + } + + // MARK: - howSimilar(_:with:) — Exact URL match + + func testExactURLMatch() { + let url = URL(string: "https://example.com/path/to/page")! + XCTAssertEqual(URLSimilarity.howSimilar(url, with: url), 1.0) + } + + // MARK: - howSimilar(_:with:) — nil and missing host + + func testNilURL2() { + let url1 = URL(string: "https://example.com")! + XCTAssertEqual(URLSimilarity.howSimilar(url1, with: nil), 0.0) + } + + // MARK: - howSimilar(_:with:) — Same host, path similarity + + func testSameHostMatchingPaths() { + let url1 = URL(string: "https://example.com/app/login")! + let url2 = URL(string: "https://example.com/app/login")! + + let score = URLSimilarity.howSimilar(url1, with: url2) + XCTAssertEqual(score, 1.0, "Same host with identical paths should score 1.0") + } + + func testSameHostDifferentPaths() { + let url1 = URL(string: "https://example.com/app/login")! + let url2 = URL(string: "https://example.com/other/page")! + + let score = URLSimilarity.howSimilar(url1, with: url2) + XCTAssertGreaterThan(score, 0.7, "Same host with different paths should score > 0.7 (has path overlap from /)") + XCTAssertLessThan(score, 1.0, "Same host with different paths should score < 1.0") + } + + func testSameHostPartialPathOverlap() { + let url1 = URL(string: "https://example.com/app/login")! + let url2 = URL(string: "https://example.com/app/settings")! + + let score = URLSimilarity.howSimilar(url1, with: url2) + XCTAssertGreaterThan(score, 0.8, "Same host with partially overlapping paths should score > 0.8") + } + + // MARK: - howSimilar(_:with:) — Port mismatch + + func testPortMismatchPenalty() { + let url1 = URL(string: "https://example.com:443/path")! + let url2 = URL(string: "https://example.com:8443/path")! + + let score = URLSimilarity.howSimilar(url1, with: url2) + let urlWithoutPort = URL(string: "https://example.com/path")! + let scoreWithoutPort = URLSimilarity.howSimilar(url1, with: urlWithoutPort) + + XCTAssertLessThan( + score, + scoreWithoutPort, + "Port mismatch should reduce the score" + ) + } + + // MARK: - howSimilar(_:with:) — Service name match + + func testServiceNameMatch() { + let url1 = URL(string: "https://mycompany.com")! + let url2 = URL(string: "https://mycompany.ch")! + + let score = URLSimilarity.howSimilar(url1, with: url2) + XCTAssertEqual(score, 0.5, "Same service name with different TLD should score 0.5") + } + + func testNoMatch() { + let url1 = URL(string: "https://example.com")! + let url2 = URL(string: "https://different.org")! + + let score = URLSimilarity.howSimilar(url1, with: url2) + XCTAssertEqual(score, 0.0, "Completely different domains should score 0.0") + } + + // MARK: - Regression: the original bug scenario + + func testSubdomainPreference() { + let searchURL = URL(string: "https://mail.mycompany.com")! + + let entryURLExact = URL(string: "https://mail.mycompany.com")! + let entryURLOtherSubdomain = URL(string: "https://xyz.mycompany.com")! + let entryURLBaseDomain = URL(string: "https://mycompany.com")! + + let scoreExact = URLSimilarity.howSimilar(searchURL, with: entryURLExact) + let scoreOtherSubdomain = URLSimilarity.howSimilar(searchURL, with: entryURLOtherSubdomain) + let scoreBaseDomain = URLSimilarity.howSimilar(searchURL, with: entryURLBaseDomain) + + XCTAssertGreaterThan( + scoreExact, + scoreOtherSubdomain, + "mail.mycompany.com should rank higher than xyz.mycompany.com when searching for mail.mycompany.com" + ) + XCTAssertGreaterThan( + scoreExact, + scoreBaseDomain, + "mail.mycompany.com should rank higher than mycompany.com when searching for mail.mycompany.com" + ) + XCTAssertGreaterThanOrEqual( + scoreBaseDomain, + scoreOtherSubdomain, + "mycompany.com should rank at least as high as xyz.mycompany.com" + ) + } +}