Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ xcuserdata/
**/xcshareddata/WorkspaceSettings.xcsettings
DerivedData/
KeePassium.xcodeproj/xcshareddata/
build/

# License verification files
KeePassiumLib/KeePassiumLib/resources/licensing/v1-proofs.sha256
59 changes: 2 additions & 57 deletions KeePassium AutoFill/util/SearchHelper+extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions KeePassiumLib/KeePassiumLib/util/URLSimilarity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// KeePassium Password Manager
// Copyright © 2018-2025 KeePassium Labs <info@keepassium.com>
//
// 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
}
}
191 changes: 191 additions & 0 deletions KeePassiumTests/URLSimilarityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// KeePassium Password Manager
// Copyright © 2018-2025 KeePassium Labs <info@keepassium.com>
//
// 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"
)
}
}